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