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