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