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