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