1import { css } from '@emotion/react';
2import { borderRadius, breakpoints, shadows, spacing, theme, typography } from '@expo/styleguide';
3import React from 'react';
4import ReactMarkdown from 'react-markdown';
5import remarkGfm from 'remark-gfm';
6
7import { APIDataType } from './APIDataType';
8
9import { Code, InlineCode } from '~/components/base/code';
10import { H4 } from '~/components/base/headings';
11import Link from '~/components/base/link';
12import { LI, UL, OL } from '~/components/base/list';
13import { B, P } from '~/components/base/paragraph';
14import {
15  CommentData,
16  MethodDefinitionData,
17  MethodParamData,
18  MethodSignatureData,
19  PropData,
20  TypeDefinitionData,
21  TypePropertyDataFlags,
22} from '~/components/plugins/api/APIDataTypes';
23import { APISectionPlatformTags } from '~/components/plugins/api/APISectionPlatformTags';
24import { Callout } from '~/ui/components/Callout';
25import { Cell, HeaderCell, Row, Table, TableHead } from '~/ui/components/Table';
26import { tableWrapperStyle } from '~/ui/components/Table/Table';
27import { Tag } from '~/ui/components/Tag';
28import { A } from '~/ui/components/Text';
29
30const isDev = process.env.NODE_ENV === 'development';
31
32export enum TypeDocKind {
33  Namespace = 4,
34  Enum = 8,
35  Variable = 32,
36  Function = 64,
37  Class = 128,
38  Interface = 256,
39  Property = 1024,
40  Method = 2048,
41  Parameter = 32768,
42  Accessor = 262144,
43  TypeAlias = 4194304,
44}
45
46export type MDComponents = React.ComponentProps<typeof ReactMarkdown>['components'];
47
48const getInvalidLinkMessage = (href: string) =>
49  `Using "../" when linking other packages in doc comments produce a broken link! Please use "./" instead. Problematic link:\n\t${href}`;
50
51export const mdComponents: MDComponents = {
52  blockquote: ({ children }) => <Callout>{children}</Callout>,
53  code: ({ children, className }) =>
54    className ? <Code className={className}>{children}</Code> : <InlineCode>{children}</InlineCode>,
55  h1: ({ children }) => <H4>{children}</H4>,
56  ul: ({ children }) => <UL>{children}</UL>,
57  ol: ({ children }) => <OL>{children}</OL>,
58  li: ({ children }) => <LI>{children}</LI>,
59  a: ({ href, children }) => {
60    if (
61      href?.startsWith('../') &&
62      !href?.startsWith('../..') &&
63      !href?.startsWith('../react-native')
64    ) {
65      if (isDev) {
66        throw new Error(getInvalidLinkMessage(href));
67      } else {
68        console.warn(getInvalidLinkMessage(href));
69      }
70    }
71    return <Link href={href}>{children}</Link>;
72  },
73  p: ({ children }) => (children ? <P>{children}</P> : null),
74  strong: ({ children }) => <B>{children}</B>,
75  span: ({ children }) => (children ? <span>{children}</span> : null),
76  table: ({ children }) => <Table>{children}</Table>,
77  thead: ({ children }) => <TableHead>{children}</TableHead>,
78  tr: ({ children }) => <Row>{children}</Row>,
79  th: ({ children }) => <HeaderCell>{children}</HeaderCell>,
80  td: ({ children }) => <Cell>{children}</Cell>,
81};
82
83export const mdInlineComponents: MDComponents = {
84  ...mdComponents,
85  p: ({ children }) => (children ? <span>{children}</span> : null),
86};
87
88export const mdInlineComponentsNoValidation: MDComponents = {
89  ...mdInlineComponents,
90  a: ({ href, children }) => <Link href={href}>{children}</Link>,
91};
92
93const nonLinkableTypes = [
94  'ColorValue',
95  'Component',
96  'ComponentClass',
97  'E',
98  'EventSubscription',
99  'Listener',
100  'NativeSyntheticEvent',
101  'ParsedQs',
102  'ServiceActionResult',
103  'T',
104  'TaskOptions',
105  'Uint8Array',
106  // React & React Native
107  'React.FC',
108  'ForwardRefExoticComponent',
109  'StyleProp',
110  // Cross-package permissions management
111  'RequestPermissionMethod',
112  'GetPermissionMethod',
113  'Options',
114  'PermissionHookBehavior',
115];
116
117/**
118 * List of type names that should not be visible in the docs.
119 */
120const omittableTypes = [
121  // Internal React type that adds `ref` prop to the component
122  'RefAttributes',
123];
124
125/**
126 * Map of internal names/type names that should be replaced with something more developer-friendly.
127 */
128const replaceableTypes: Partial<Record<string, string>> = {
129  ForwardRefExoticComponent: 'Component',
130};
131
132const hardcodedTypeLinks: Record<string, string> = {
133  AVPlaybackSource: '/versions/latest/sdk/av/#avplaybacksource',
134  AVPlaybackStatus: '/versions/latest/sdk/av/#avplaybackstatus',
135  AVPlaybackStatusToSet: '/versions/latest/sdk/av/#avplaybackstatustoset',
136  Blob: 'https://developer.mozilla.org/en-US/docs/Web/API/Blob',
137  Date: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date',
138  DeviceSensor: '/versions/latest/sdk/sensors',
139  Element: 'https://www.typescriptlang.org/docs/handbook/jsx.html#function-component',
140  Error: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error',
141  ExpoConfig:
142    'https://github.com/expo/expo/blob/main/packages/%40expo/config-types/src/ExpoConfig.ts',
143  File: 'https://developer.mozilla.org/en-US/docs/Web/API/File',
144  FileList: 'https://developer.mozilla.org/en-US/docs/Web/API/FileList',
145  MessageEvent: 'https://developer.mozilla.org/en-US/docs/Web/API/MessageEvent',
146  Omit: 'https://www.typescriptlang.org/docs/handbook/utility-types.html#omittype-keys',
147  Pick: 'https://www.typescriptlang.org/docs/handbook/utility-types.html#picktype-keys',
148  Partial: 'https://www.typescriptlang.org/docs/handbook/utility-types.html#partialtype',
149  Promise:
150    'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise',
151  View: '/versions/latest/react-native/view',
152  ViewProps: '/versions/latest/react-native/view#props',
153  ViewStyle: '/versions/latest/react-native/view-style-props',
154  WebGL2RenderingContext: 'https://developer.mozilla.org/en-US/docs/Web/API/WebGL2RenderingContext',
155  WebGLFramebuffer: 'https://developer.mozilla.org/en-US/docs/Web/API/WebGLFramebuffer',
156};
157
158const renderWithLink = (name: string, type?: string) => {
159  const replacedName = replaceableTypes[name] ?? name;
160
161  if (name.includes('.')) return name;
162
163  return nonLinkableTypes.includes(replacedName) ? (
164    replacedName + (type === 'array' ? '[]' : '')
165  ) : (
166    <Link
167      href={hardcodedTypeLinks[replacedName] || `#${replacedName.toLowerCase()}`}
168      key={`type-link-${replacedName}`}>
169      {replacedName}
170      {type === 'array' && '[]'}
171    </Link>
172  );
173};
174
175const renderUnion = (types: TypeDefinitionData[]) =>
176  types
177    .map(type => resolveTypeName(type))
178    .map((valueToRender, index) => (
179      <span key={`union-type-${index}`}>
180        {valueToRender}
181        {index + 1 !== types.length && ' | '}
182      </span>
183    ));
184
185export const resolveTypeName = (
186  typeDefinition: TypeDefinitionData
187): string | JSX.Element | (string | JSX.Element)[] => {
188  if (!typeDefinition) {
189    return 'undefined';
190  }
191
192  const {
193    elements,
194    elementType,
195    name,
196    type,
197    types,
198    typeArguments,
199    declaration,
200    value,
201    queryType,
202    operator,
203    objectType,
204    indexType,
205  } = typeDefinition;
206
207  try {
208    if (name) {
209      if (type === 'reference') {
210        if (typeArguments) {
211          if (name === 'Record' || name === 'React.ComponentProps') {
212            return (
213              <>
214                {name}&lt;
215                {typeArguments.map((type, index) => (
216                  <span key={`record-type-${index}`}>
217                    {resolveTypeName(type)}
218                    {index !== typeArguments.length - 1 ? ', ' : null}
219                  </span>
220                ))}
221                &gt;
222              </>
223            );
224          } else {
225            return (
226              <>
227                {renderWithLink(name)}
228                &lt;
229                {typeArguments.map((type, index) => (
230                  <span key={`${name}-nested-type-${index}`}>
231                    {resolveTypeName(type)}
232                    {index !== typeArguments.length - 1 ? ', ' : null}
233                  </span>
234                ))}
235                &gt;
236              </>
237            );
238          }
239        } else {
240          return renderWithLink(name);
241        }
242      } else {
243        return name;
244      }
245    } else if (elementType?.name) {
246      if (elementType.type === 'reference') {
247        return renderWithLink(elementType.name, type);
248      } else if (type === 'array') {
249        return elementType.name + '[]';
250      }
251      return elementType.name + type;
252    } else if (elementType?.declaration) {
253      if (type === 'array') {
254        const { parameters, type: paramType } = elementType.declaration.indexSignature || {};
255        if (parameters && paramType) {
256          return `{ [${listParams(parameters)}]: ${resolveTypeName(paramType)} }`;
257        }
258      }
259      return elementType.name + type;
260    } else if (type === 'union' && types?.length) {
261      return renderUnion(types);
262    } else if (elementType && elementType.type === 'union' && elementType?.types?.length) {
263      const unionTypes = elementType?.types || [];
264      return (
265        <>
266          ({renderUnion(unionTypes)}){type === 'array' && '[]'}
267        </>
268      );
269    } else if (declaration?.signatures) {
270      const baseSignature = declaration.signatures[0];
271      if (baseSignature?.parameters?.length) {
272        return (
273          <>
274            (
275            {baseSignature.parameters?.map((param, index) => (
276              <span key={`param-${index}-${param.name}`}>
277                {param.name}: {resolveTypeName(param.type)}
278                {index + 1 !== baseSignature.parameters?.length && ', '}
279              </span>
280            ))}
281            ) {'=>'} {resolveTypeName(baseSignature.type)}
282          </>
283        );
284      } else {
285        return (
286          <>
287            {'() =>'} {resolveTypeName(baseSignature.type)}
288          </>
289        );
290      }
291    } else if (type === 'reflection' && declaration?.children) {
292      return (
293        <>
294          {'{\n'}
295          {declaration?.children.map((child: PropData, i) => (
296            <span key={`reflection-${name}-${i}`}>
297              {'  '}
298              {child.name + ': '}
299              {resolveTypeName(child.type)}
300              {i + 1 !== declaration?.children?.length ? ', ' : null}
301              {'\n'}
302            </span>
303          ))}
304          {'}'}
305        </>
306      );
307    } else if (type === 'tuple' && elements) {
308      return (
309        <>
310          [
311          {elements.map((elem, i) => (
312            <span key={`tuple-${name}-${i}`}>
313              {resolveTypeName(elem)}
314              {i + 1 !== elements.length ? ', ' : null}
315            </span>
316          ))}
317          ]
318        </>
319      );
320    } else if (type === 'query' && queryType) {
321      return queryType.name;
322    } else if (type === 'literal' && typeof value === 'boolean') {
323      return `${value}`;
324    } else if (type === 'literal' && (value || (typeof value === 'number' && value === 0))) {
325      return `'${value}'`;
326    } else if (type === 'intersection' && types) {
327      return types
328        .filter(({ name }) => !omittableTypes.includes(name ?? ''))
329        .map((value, index, array) => (
330          <span key={`intersection-${name}-${index}`}>
331            {resolveTypeName(value)}
332            {index + 1 !== array.length && ' & '}
333          </span>
334        ));
335    } else if (type === 'indexedAccess') {
336      return `${objectType?.name}['${indexType?.value}']`;
337    } else if (type === 'typeOperator') {
338      return operator || 'undefined';
339    } else if (type === 'intrinsic') {
340      return name || 'undefined';
341    } else if (value === null) {
342      return 'null';
343    }
344    return 'undefined';
345  } catch (e) {
346    console.warn('Type resolve has failed!', e);
347    return 'undefined';
348  }
349};
350
351export const parseParamName = (name: string) => (name.startsWith('__') ? name.substr(2) : name);
352
353export const renderParamRow = ({
354  comment,
355  name,
356  type,
357  flags,
358  defaultValue,
359}: MethodParamData): JSX.Element => {
360  const initValue = parseCommentContent(defaultValue || getTagData('default', comment)?.text);
361  return (
362    <Row key={`param-${name}`}>
363      <Cell>
364        <B>{parseParamName(name)}</B>
365        {renderFlags(flags, initValue)}
366      </Cell>
367      <Cell>
368        <APIDataType typeDefinition={type} />
369      </Cell>
370      <Cell>
371        <CommentTextBlock
372          comment={comment}
373          afterContent={renderDefaultValue(initValue)}
374          emptyCommentFallback="-"
375        />
376      </Cell>
377    </Row>
378  );
379};
380
381export const ParamsTableHeadRow = () => (
382  <TableHead>
383    <Row>
384      <HeaderCell>Name</HeaderCell>
385      <HeaderCell>Type</HeaderCell>
386      <HeaderCell>Description</HeaderCell>
387    </Row>
388  </TableHead>
389);
390
391export const renderParams = (parameters: MethodParamData[]) => (
392  <Table>
393    <ParamsTableHeadRow />
394    <tbody>{parameters?.map(renderParamRow)}</tbody>
395  </Table>
396);
397
398export const listParams = (parameters: MethodParamData[]) =>
399  parameters ? parameters?.map(param => parseParamName(param.name)).join(', ') : '';
400
401export const renderDefaultValue = (defaultValue?: string) =>
402  defaultValue && defaultValue !== '...' ? (
403    <div css={defaultValueContainerStyle}>
404      <B>Default:</B> <InlineCode>{defaultValue}</InlineCode>
405    </div>
406  ) : undefined;
407
408export const renderTypeOrSignatureType = (
409  type?: TypeDefinitionData,
410  signatures?: MethodSignatureData[],
411  allowBlock: boolean = false
412) => {
413  if (signatures && signatures.length) {
414    return (
415      <InlineCode key={`signature-type-${signatures[0].name}`}>
416        (
417        {signatures?.map(({ parameters }) =>
418          parameters?.map(param => (
419            <span key={`signature-param-${param.name}`}>
420              {param.name}
421              {param.flags?.isOptional && '?'}: {resolveTypeName(param.type)}
422            </span>
423          ))
424        )}
425        ) =&gt;{' '}
426        {type ? (
427          <InlineCode key={`signature-type-${type.name}`}>{resolveTypeName(type)}</InlineCode>
428        ) : (
429          'void'
430        )}
431      </InlineCode>
432    );
433  } else if (type) {
434    if (allowBlock) {
435      return <APIDataType typeDefinition={type} />;
436    }
437    return <InlineCode key={`signature-type-${type.name}`}>{resolveTypeName(type)}</InlineCode>;
438  }
439  return undefined;
440};
441
442export const renderFlags = (flags?: TypePropertyDataFlags, defaultValue?: string) =>
443  (flags?.isOptional || defaultValue) && (
444    <>
445      <br />
446      <span css={STYLES_OPTIONAL}>(optional)</span>
447    </>
448  );
449
450export const renderIndexSignature = (kind: TypeDocKind) =>
451  kind === TypeDocKind.Parameter && (
452    <>
453      <br />
454      <A
455        css={STYLES_OPTIONAL}
456        href="https://www.typescriptlang.org/docs/handbook/2/objects.html#index-signatures"
457        openInNewTab
458        isStyled>
459        (index signature)
460      </A>
461    </>
462  );
463
464export type CommentTextBlockProps = {
465  comment?: CommentData;
466  components?: MDComponents;
467  withDash?: boolean;
468  beforeContent?: JSX.Element;
469  afterContent?: JSX.Element;
470  includePlatforms?: boolean;
471  emptyCommentFallback?: string;
472};
473
474export const parseCommentContent = (content?: string): string =>
475  content && content.length ? content.replace(/&ast;/g, '*').replace(/\t/g, '') : '';
476
477export const getCommentOrSignatureComment = (
478  comment?: CommentData,
479  signatures?: MethodSignatureData[]
480) => comment || (signatures && signatures[0]?.comment);
481
482export const getTagData = (tagName: string, comment?: CommentData) =>
483  getAllTagData(tagName, comment)?.[0];
484
485export const getAllTagData = (tagName: string, comment?: CommentData) =>
486  comment?.tags?.filter(tag => tag.tag === tagName);
487
488export const getTagNamesList = (comment?: CommentData) =>
489  comment && [
490    ...(getAllTagData('platform', comment)?.map(platformData => platformData.text) || []),
491    ...(getTagData('deprecated', comment) ? ['deprecated'] : []),
492    ...(getTagData('experimental', comment) ? ['experimental'] : []),
493  ];
494
495export const getMethodName = (
496  method: MethodDefinitionData,
497  apiName?: string,
498  name?: string,
499  parameters?: MethodParamData[]
500) => {
501  const isProperty = method.kind === TypeDocKind.Property && !parameters?.length;
502  const methodName = ((apiName && `${apiName}.`) ?? '') + (method.name || name);
503  if (!isProperty) {
504    return `${methodName}(${parameters ? listParams(parameters) : ''})`;
505  }
506
507  return methodName;
508};
509
510export const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);
511
512const PARAM_TAGS_REGEX = /@tag-\S*/g;
513
514const getParamTags = (shortText?: string) => {
515  if (!shortText || !shortText.includes('@tag-')) {
516    return undefined;
517  }
518  return Array.from(shortText.matchAll(PARAM_TAGS_REGEX), match => match[0]);
519};
520
521export const CommentTextBlock = ({
522  comment,
523  components = mdComponents,
524  withDash,
525  beforeContent,
526  afterContent,
527  includePlatforms = true,
528  emptyCommentFallback,
529}: CommentTextBlockProps) => {
530  const paramTags = getParamTags(comment?.shortText?.trim());
531
532  const shortText = comment?.shortText?.trim().length ? (
533    <ReactMarkdown components={components} remarkPlugins={[remarkGfm]}>
534      {parseCommentContent(
535        paramTags ? comment.shortText.replaceAll(PARAM_TAGS_REGEX, '') : comment.shortText
536      )}
537    </ReactMarkdown>
538  ) : null;
539  const text = comment?.text?.trim().length ? (
540    <ReactMarkdown components={components} remarkPlugins={[remarkGfm]}>
541      {parseCommentContent(comment.text)}
542    </ReactMarkdown>
543  ) : null;
544
545  if (emptyCommentFallback && (!comment || (!shortText && !text))) {
546    return <>{emptyCommentFallback}</>;
547  }
548
549  const examples = getAllTagData('example', comment);
550  const exampleText = examples?.map((example, index) => (
551    <React.Fragment key={'example-' + index}>
552      {components !== mdComponents ? (
553        <div css={STYLES_EXAMPLE_IN_TABLE}>
554          <B>Example</B>
555        </div>
556      ) : (
557        <div css={STYLES_NESTED_SECTION_HEADER}>
558          <H4>Example</H4>
559        </div>
560      )}
561      <ReactMarkdown components={components}>{example.text}</ReactMarkdown>
562    </React.Fragment>
563  ));
564
565  const see = getTagData('see', comment);
566  const seeText = see && (
567    <Callout>
568      <B>See: </B>
569      <ReactMarkdown components={mdInlineComponents}>{see.text}</ReactMarkdown>
570    </Callout>
571  );
572
573  const hasPlatforms = (getAllTagData('platform', comment)?.length || 0) > 0;
574
575  return (
576    <>
577      {!withDash && includePlatforms && hasPlatforms && (
578        <APISectionPlatformTags comment={comment} prefix="Only for:" />
579      )}
580      {paramTags && (
581        <>
582          <B>Only for:&ensp;</B>
583          {paramTags.map(tag => (
584            <Tag key={tag} name={tag.split('-')[1]} />
585          ))}
586        </>
587      )}
588      {beforeContent}
589      {withDash && (shortText || text) && ' - '}
590      {withDash && includePlatforms && <APISectionPlatformTags comment={comment} />}
591      {shortText}
592      {text}
593      {afterContent}
594      {seeText}
595      {exampleText}
596    </>
597  );
598};
599
600export const getComponentName = (name?: string, children: PropData[] = []) => {
601  if (name && name !== 'default') return name;
602  const ctor = children.filter((child: PropData) => child.name === 'constructor')[0];
603  return ctor?.signatures?.[0]?.type?.name ?? 'default';
604};
605
606export const STYLES_APIBOX = css({
607  borderRadius: borderRadius.medium,
608  borderWidth: 1,
609  borderStyle: 'solid',
610  borderColor: theme.border.default,
611  padding: `${spacing[5]}px ${spacing[5]}px 0`,
612  boxShadow: shadows.micro,
613  marginBottom: spacing[6],
614  overflowX: 'hidden',
615
616  h3: {
617    marginBottom: spacing[2],
618  },
619
620  'h3, h4': {
621    marginTop: 0,
622  },
623
624  th: {
625    color: theme.text.secondary,
626    padding: `${spacing[3]}px ${spacing[4]}px`,
627  },
628
629  [`.css-${tableWrapperStyle.name}`]: {
630    boxShadow: 'none',
631  },
632
633  [`@media screen and (max-width: ${breakpoints.medium + 124}px)`]: {
634    padding: `0 ${spacing[4]}px`,
635  },
636});
637
638export const STYLES_APIBOX_NESTED = css({
639  boxShadow: 'none',
640
641  h4: {
642    marginTop: 0,
643  },
644});
645
646export const STYLES_NESTED_SECTION_HEADER = css({
647  display: 'flex',
648  borderTop: `1px solid ${theme.border.default}`,
649  borderBottom: `1px solid ${theme.border.default}`,
650  margin: `${spacing[4]}px -${spacing[5]}px ${spacing[4]}px`,
651  padding: `${spacing[2.5]}px ${spacing[5]}px`,
652  backgroundColor: theme.background.secondary,
653
654  h4: {
655    ...typography.fontSizes[16],
656    fontFamily: typography.fontFaces.medium,
657    marginBottom: 0,
658    marginTop: 0,
659    color: theme.text.secondary,
660  },
661});
662
663export const STYLES_NOT_EXPOSED_HEADER = css({
664  marginBottom: spacing[1],
665  display: 'inline-block',
666
667  code: {
668    marginBottom: 0,
669  },
670});
671
672export const STYLES_OPTIONAL = css({
673  color: theme.text.secondary,
674  fontSize: '90%',
675  paddingTop: 22,
676});
677
678export const STYLES_SECONDARY = css({
679  color: theme.text.secondary,
680  fontSize: '90%',
681  fontWeight: 600,
682});
683
684const defaultValueContainerStyle = css({
685  marginTop: spacing[2],
686  marginBottom: spacing[2],
687
688  '&:last-child': {
689    marginBottom: 0,
690  },
691});
692
693const STYLES_EXAMPLE_IN_TABLE = css({
694  margin: `${spacing[2]}px 0`,
695});
696