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