1import { css } from '@emotion/react';
2import { theme } from '@expo/styleguide';
3import React from 'react';
4import ReactMarkdown from 'react-markdown';
5import remarkGfm from 'remark-gfm';
6
7import { Code, InlineCode } from '~/components/base/code';
8import { H4 } from '~/components/base/headings';
9import Link from '~/components/base/link';
10import { LI, UL } from '~/components/base/list';
11import { B, P, Quote } from '~/components/base/paragraph';
12import {
13  CommentData,
14  MethodParamData,
15  MethodSignatureData,
16  PropData,
17  TypeDefinitionData,
18  TypePropertyDataFlags,
19} from '~/components/plugins/api/APIDataTypes';
20import { Row, Cell, Table, TableHead, HeaderCell } from '~/ui/components/Table';
21
22const isDev = process.env.NODE_ENV === 'development';
23
24export enum TypeDocKind {
25  LegacyEnum = 4,
26  Enum = 8,
27  Variable = 32,
28  Function = 64,
29  Class = 128,
30  Interface = 256,
31  Property = 1024,
32  Method = 2048,
33  TypeAlias = 4194304,
34}
35
36export type MDComponents = React.ComponentProps<typeof ReactMarkdown>['components'];
37
38const getInvalidLinkMessage = (href: string) =>
39  `Using "../" when linking other packages in doc comments produce a broken link! Please use "./" instead. Problematic link:\n\t${href}`;
40
41export const mdComponents: MDComponents = {
42  blockquote: ({ children }) => (
43    <Quote>
44      {/* @ts-ignore - current implementation produce type issues, this would be fixed in docs redesign */}
45      {children.map(child => (child?.props?.node?.tagName === 'p' ? child?.props.children : child))}
46    </Quote>
47  ),
48  code: ({ children, className }) =>
49    className ? <Code className={className}>{children}</Code> : <InlineCode>{children}</InlineCode>,
50  h1: ({ children }) => <H4>{children}</H4>,
51  ul: ({ children }) => <UL>{children}</UL>,
52  li: ({ children }) => <LI>{children}</LI>,
53  a: ({ href, children }) => {
54    if (
55      href?.startsWith('../') &&
56      !href?.startsWith('../..') &&
57      !href?.startsWith('../react-native')
58    ) {
59      if (isDev) {
60        throw new Error(getInvalidLinkMessage(href));
61      } else {
62        console.warn(getInvalidLinkMessage(href));
63      }
64    }
65    return <Link href={href}>{children}</Link>;
66  },
67  p: ({ children }) => (children ? <P>{children}</P> : null),
68  strong: ({ children }) => <B>{children}</B>,
69  span: ({ children }) => (children ? <span>{children}</span> : null),
70};
71
72export const mdInlineComponents: MDComponents = {
73  ...mdComponents,
74  p: ({ children }) => (children ? <span>{children}</span> : null),
75};
76
77const nonLinkableTypes = [
78  'ColorValue',
79  'Component',
80  'E',
81  'EventSubscription',
82  'File',
83  'FileList',
84  'Manifest',
85  'NativeSyntheticEvent',
86  'ParsedQs',
87  'ServiceActionResult',
88  'T',
89  'TaskOptions',
90  'Uint8Array',
91  // React & React Native
92  'React.FC',
93  'ForwardRefExoticComponent',
94  'StyleProp',
95  // Cross-package permissions management
96  'RequestPermissionMethod',
97  'GetPermissionMethod',
98  'Options',
99  'PermissionHookBehavior',
100];
101
102/**
103 * List of type names that should not be visible in the docs.
104 */
105const omittableTypes = [
106  // Internal React type that adds `ref` prop to the component
107  'RefAttributes',
108];
109
110/**
111 * Map of internal names/type names that should be replaced with something more developer-friendly.
112 */
113const replaceableTypes: Partial<Record<string, string>> = {
114  ForwardRefExoticComponent: 'Component',
115};
116
117const hardcodedTypeLinks: Record<string, string> = {
118  AVPlaybackSource: '/versions/latest/sdk/av/#playback-api',
119  AVPlaybackStatus: '/versions/latest/sdk/av/#playback-status',
120  AVPlaybackStatusToSet: '/versions/latest/sdk/av/#default-initial--avplaybackstatustoset',
121  Date: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date',
122  Element: 'https://www.typescriptlang.org/docs/handbook/jsx.html#function-component',
123  Error: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error',
124  ExpoConfig: 'https://github.com/expo/expo-cli/blob/main/packages/config-types/src/ExpoConfig.ts',
125  MessageEvent: 'https://developer.mozilla.org/en-US/docs/Web/API/MessageEvent',
126  Omit: 'https://www.typescriptlang.org/docs/handbook/utility-types.html#omittype-keys',
127  Pick: 'https://www.typescriptlang.org/docs/handbook/utility-types.html#picktype-keys',
128  Partial: 'https://www.typescriptlang.org/docs/handbook/utility-types.html#partialtype',
129  Promise:
130    'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise',
131  View: '/versions/latest/react-native/view',
132  ViewProps: '/versions/latest/react-native/view#props',
133  ViewStyle: '/versions/latest/react-native/view-style-props',
134};
135
136const renderWithLink = (name: string, type?: string) => {
137  const replacedName = replaceableTypes[name] ?? name;
138
139  return nonLinkableTypes.includes(replacedName) ? (
140    replacedName + (type === 'array' ? '[]' : '')
141  ) : (
142    <Link
143      href={hardcodedTypeLinks[replacedName] || `#${replacedName.toLowerCase()}`}
144      key={`type-link-${replacedName}`}>
145      {replacedName}
146      {type === 'array' && '[]'}
147    </Link>
148  );
149};
150
151const renderUnion = (types: TypeDefinitionData[]) =>
152  types.map(resolveTypeName).map((valueToRender, index) => (
153    <span key={`union-type-${index}`}>
154      {valueToRender}
155      {index + 1 !== types.length && ' | '}
156    </span>
157  ));
158
159export const resolveTypeName = ({
160  elements,
161  elementType,
162  name,
163  type,
164  types,
165  typeArguments,
166  declaration,
167  value,
168  queryType,
169  operator,
170  objectType,
171  indexType,
172}: TypeDefinitionData): string | JSX.Element | (string | JSX.Element)[] => {
173  try {
174    if (name) {
175      if (type === 'reference') {
176        if (typeArguments) {
177          if (name === 'Record' || name === 'React.ComponentProps') {
178            return (
179              <>
180                {name}&lt;
181                {typeArguments.map((type, index) => (
182                  <span key={`record-type-${index}`}>
183                    {resolveTypeName(type)}
184                    {index !== typeArguments.length - 1 ? ', ' : null}
185                  </span>
186                ))}
187                &gt;
188              </>
189            );
190          } else {
191            return (
192              <>
193                {renderWithLink(name)}
194                &lt;
195                {typeArguments.map((type, index) => (
196                  <span key={`${name}-nested-type-${index}`}>
197                    {resolveTypeName(type)}
198                    {index !== typeArguments.length - 1 ? ', ' : null}
199                  </span>
200                ))}
201                &gt;
202              </>
203            );
204          }
205        } else {
206          return renderWithLink(name);
207        }
208      } else {
209        return name;
210      }
211    } else if (elementType?.name) {
212      if (elementType.type === 'reference') {
213        return renderWithLink(elementType.name, type);
214      } else if (type === 'array') {
215        return elementType.name + '[]';
216      }
217      return elementType.name + type;
218    } else if (elementType?.declaration) {
219      if (type === 'array') {
220        const { parameters, type: paramType } = elementType.declaration.indexSignature || {};
221        if (parameters && paramType) {
222          return `{ [${listParams(parameters)}]: ${resolveTypeName(paramType)} }`;
223        }
224      }
225      return elementType.name + type;
226    } else if (type === 'union' && types?.length) {
227      return renderUnion(types);
228    } else if (elementType && elementType.type === 'union' && elementType?.types?.length) {
229      const unionTypes = elementType?.types || [];
230      return (
231        <>
232          ({renderUnion(unionTypes)}){type === 'array' && '[]'}
233        </>
234      );
235    } else if (declaration?.signatures) {
236      const baseSignature = declaration.signatures[0];
237      if (baseSignature?.parameters?.length) {
238        return (
239          <>
240            (
241            {baseSignature.parameters?.map((param, index) => (
242              <span key={`param-${index}-${param.name}`}>
243                {param.name}: {resolveTypeName(param.type)}
244                {index + 1 !== baseSignature.parameters?.length && ', '}
245              </span>
246            ))}
247            ) {'=>'} {resolveTypeName(baseSignature.type)}
248          </>
249        );
250      } else {
251        return (
252          <>
253            {'() =>'} {resolveTypeName(baseSignature.type)}
254          </>
255        );
256      }
257    } else if (type === 'reflection' && declaration?.children) {
258      return (
259        <>
260          {'{ '}
261          {declaration?.children.map((child: PropData, i) => (
262            <span key={`reflection-${name}-${i}`}>
263              {child.name + ': ' + resolveTypeName(child.type)}
264              {i + 1 !== declaration?.children?.length ? ', ' : null}
265            </span>
266          ))}
267          {' }'}
268        </>
269      );
270    } else if (type === 'tuple' && elements) {
271      return (
272        <>
273          [
274          {elements.map((elem, i) => (
275            <span key={`tuple-${name}-${i}`}>
276              {resolveTypeName(elem)}
277              {i + 1 !== elements.length ? ', ' : null}
278            </span>
279          ))}
280          ]
281        </>
282      );
283    } else if (type === 'query' && queryType) {
284      return queryType.name;
285    } else if (type === 'literal' && typeof value === 'boolean') {
286      return `${value}`;
287    } else if (type === 'literal' && value) {
288      return `'${value}'`;
289    } else if (type === 'intersection' && types) {
290      return types
291        .filter(({ name }) => !omittableTypes.includes(name ?? ''))
292        .map((value, index, array) => (
293          <span key={`intersection-${name}-${index}`}>
294            {resolveTypeName(value)}
295            {index + 1 !== array.length && ' & '}
296          </span>
297        ));
298    } else if (type === 'indexedAccess') {
299      return `${objectType?.name}['${indexType?.value}']`;
300    } else if (type === 'typeOperator') {
301      return operator || 'undefined';
302    } else if (value === null) {
303      return 'null';
304    }
305    return 'undefined';
306  } catch (e) {
307    console.warn('Type resolve has failed!', e);
308    return 'undefined';
309  }
310};
311
312export const parseParamName = (name: string) => (name.startsWith('__') ? name.substr(2) : name);
313
314export const renderParamRow = ({ comment, name, type, flags }: MethodParamData): JSX.Element => {
315  const defaultValue = parseCommentContent(getTagData('default', comment)?.text);
316  return (
317    <Row key={`param-${name}`}>
318      <Cell>
319        <B>{parseParamName(name)}</B>
320        {renderFlags(flags)}
321      </Cell>
322      <Cell>
323        <InlineCode>{resolveTypeName(type)}</InlineCode>
324      </Cell>
325      <Cell>
326        <CommentTextBlock
327          comment={comment}
328          components={mdInlineComponents}
329          afterContent={renderDefaultValue(defaultValue)}
330          emptyCommentFallback="-"
331        />
332      </Cell>
333    </Row>
334  );
335};
336
337export const renderTableHeadRow = () => (
338  <TableHead>
339    <Row>
340      <HeaderCell>Name</HeaderCell>
341      <HeaderCell>Type</HeaderCell>
342      <HeaderCell>Description</HeaderCell>
343    </Row>
344  </TableHead>
345);
346
347export const renderParams = (parameters: MethodParamData[]) => (
348  <>
349    <H4>Arguments</H4>
350    <Table>
351      {renderTableHeadRow()}
352      {parameters?.map(renderParamRow)}
353    </Table>
354  </>
355);
356
357export const listParams = (parameters: MethodParamData[]) =>
358  parameters ? parameters?.map(param => parseParamName(param.name)).join(', ') : '';
359
360export const renderDefaultValue = (defaultValue?: string) =>
361  defaultValue ? (
362    <>
363      <br />
364      <br />
365      <B>Default: </B>
366      <InlineCode>{defaultValue}</InlineCode>
367    </>
368  ) : undefined;
369
370export const renderTypeOrSignatureType = (
371  type?: TypeDefinitionData,
372  signatures?: MethodSignatureData[],
373  includeParamType: boolean = false
374) => {
375  if (type) {
376    return <InlineCode key={`signature-type-${type.name}`}>{resolveTypeName(type)}</InlineCode>;
377  } else if (signatures && signatures.length) {
378    return signatures.map(({ name, type, parameters }) => (
379      <InlineCode key={`signature-type-${name}`}>
380        (
381        {parameters && includeParamType
382          ? parameters.map(param => (
383              <span key={`signature-param-${param.name}`}>
384                {param.name}
385                {param.flags?.isOptional && '?'}: {resolveTypeName(param.type)}
386              </span>
387            ))
388          : listParams(parameters)}
389        ) =&gt; {resolveTypeName(type)}
390      </InlineCode>
391    ));
392  }
393  return undefined;
394};
395
396export const renderFlags = (flags?: TypePropertyDataFlags) =>
397  flags?.isOptional ? (
398    <>
399      <br />
400      <span css={STYLES_OPTIONAL}>(optional)</span>
401    </>
402  ) : undefined;
403
404export type CommentTextBlockProps = {
405  comment?: CommentData;
406  components?: MDComponents;
407  withDash?: boolean;
408  beforeContent?: JSX.Element | null;
409  afterContent?: JSX.Element | null;
410  includePlatforms?: boolean;
411  emptyCommentFallback?: string;
412};
413
414export const parseCommentContent = (content?: string): string =>
415  content && content.length ? content.replace(/&ast;/g, '*').replace(/\t/g, '') : '';
416
417export const getCommentOrSignatureComment = (
418  comment?: CommentData,
419  signatures?: MethodSignatureData[]
420) => comment || (signatures && signatures[0]?.comment);
421
422export const getTagData = (tagName: string, comment?: CommentData) =>
423  getAllTagData(tagName, comment)?.[0];
424
425export const getAllTagData = (tagName: string, comment?: CommentData) =>
426  comment?.tags?.filter(tag => tag.tag === tagName);
427
428const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);
429
430const formatPlatformName = (name: string) => {
431  const cleanName = name.toLowerCase().replace('\n', '');
432  return cleanName.includes('ios')
433    ? cleanName.replace('ios', 'iOS')
434    : cleanName.includes('expo')
435    ? cleanName.replace('expo', 'Expo Go')
436    : capitalize(name);
437};
438
439export const getPlatformTags = (comment?: CommentData, breakLine: boolean = true) => {
440  const platforms = getAllTagData('platform', comment);
441  return platforms?.length ? (
442    <>
443      {platforms.map(platform => (
444        <div key={platform.text} css={STYLES_PLATFORM}>
445          {formatPlatformName(platform.text)} Only
446        </div>
447      ))}
448      {breakLine && <br />}
449    </>
450  ) : null;
451};
452
453export const CommentTextBlock = ({
454  comment,
455  components = mdComponents,
456  withDash,
457  beforeContent,
458  afterContent,
459  includePlatforms = true,
460  emptyCommentFallback,
461}: CommentTextBlockProps) => {
462  const shortText = comment?.shortText?.trim().length ? (
463    <ReactMarkdown components={components} remarkPlugins={[remarkGfm]}>
464      {parseCommentContent(comment.shortText)}
465    </ReactMarkdown>
466  ) : null;
467  const text = comment?.text?.trim().length ? (
468    <ReactMarkdown components={components} remarkPlugins={[remarkGfm]}>
469      {parseCommentContent(comment.text)}
470    </ReactMarkdown>
471  ) : null;
472
473  if (emptyCommentFallback && (!comment || (!shortText && !text))) {
474    return <>{emptyCommentFallback}</>;
475  }
476
477  const examples = getAllTagData('example', comment);
478  const exampleText = examples?.map((example, index) => (
479    <React.Fragment key={'example-' + index}>
480      {components !== mdComponents ? (
481        <div css={STYLES_EXAMPLE_IN_TABLE}>
482          <B>Example</B>
483        </div>
484      ) : (
485        <H4>Example</H4>
486      )}
487      <ReactMarkdown components={components}>{example.text}</ReactMarkdown>
488    </React.Fragment>
489  ));
490
491  const deprecation = getTagData('deprecated', comment);
492  const deprecationNote = deprecation ? (
493    <Quote key="deprecation-note">
494      {deprecation.text.trim().length ? (
495        <ReactMarkdown
496          components={mdInlineComponents}>{`**Deprecated.** ${deprecation.text}`}</ReactMarkdown>
497      ) : (
498        <B>Deprecated</B>
499      )}
500    </Quote>
501  ) : null;
502
503  const see = getTagData('see', comment);
504  const seeText = see ? (
505    <Quote>
506      <B>See: </B>
507      <ReactMarkdown components={mdInlineComponents}>{see.text}</ReactMarkdown>
508    </Quote>
509  ) : null;
510
511  return (
512    <>
513      {deprecationNote}
514      {beforeContent}
515      {withDash && (shortText || text) && ' - '}
516      {includePlatforms && getPlatformTags(comment, !withDash)}
517      {shortText}
518      {text}
519      {afterContent}
520      {seeText}
521      {exampleText}
522    </>
523  );
524};
525
526export const getComponentName = (name?: string, children: PropData[] = []) => {
527  if (name && name !== 'default') return name;
528  const ctor = children.filter((child: PropData) => child.name === 'constructor')[0];
529  return ctor?.signatures?.[0]?.type?.name ?? 'default';
530};
531
532export const STYLES_OPTIONAL = css`
533  color: ${theme.text.secondary};
534  font-size: 90%;
535  padding-top: 22px;
536`;
537
538export const STYLES_SECONDARY = css`
539  color: ${theme.text.secondary};
540  font-size: 90%;
541  font-weight: 600;
542`;
543
544export const STYLES_PLATFORM = css`
545  & {
546    display: inline-block;
547    background-color: ${theme.background.tertiary};
548    color: ${theme.text.default};
549    font-size: 90%;
550    font-weight: 700;
551    padding: 6px 12px;
552    margin-bottom: 8px;
553    margin-right: 8px;
554    border-radius: 4px;
555  }
556
557  table & {
558    margin-bottom: 1rem;
559  }
560`;
561
562const STYLES_EXAMPLE_IN_TABLE = css`
563  margin: 8px 0;
564`;
565