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