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