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