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