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