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  MethodDefinitionData,
17  MethodParamData,
18  MethodSignatureData,
19  PropData,
20  TypeDefinitionData,
21  TypePropertyDataFlags,
22} from '~/components/plugins/api/APIDataTypes';
23import { APISectionPlatformTags } from '~/components/plugins/api/APISectionPlatformTags';
24import { Callout } from '~/ui/components/Callout';
25import { Cell, HeaderCell, Row, Table, TableHead } from '~/ui/components/Table';
26import { tableWrapperStyle } from '~/ui/components/Table/Table';
27import { A } from '~/ui/components/Text';
28
29const isDev = process.env.NODE_ENV === 'development';
30
31export enum TypeDocKind {
32  LegacyEnum = 4,
33  Enum = 8,
34  Variable = 32,
35  Function = 64,
36  Class = 128,
37  Interface = 256,
38  Property = 1024,
39  Method = 2048,
40  Parameter = 32768,
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 <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
86export const mdInlineComponentsNoValidation: MDComponents = {
87  ...mdInlineComponents,
88  a: ({ href, children }) => <Link href={href}>{children}</Link>,
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  return nonLinkableTypes.includes(replacedName) ? (
160    replacedName + (type === 'array' ? '[]' : '')
161  ) : (
162    <Link
163      href={hardcodedTypeLinks[replacedName] || `#${replacedName.toLowerCase()}`}
164      key={`type-link-${replacedName}`}>
165      {replacedName}
166      {type === 'array' && '[]'}
167    </Link>
168  );
169};
170
171const renderUnion = (types: TypeDefinitionData[]) =>
172  types
173    .map(type => resolveTypeName(type))
174    .map((valueToRender, index) => (
175      <span key={`union-type-${index}`}>
176        {valueToRender}
177        {index + 1 !== types.length && ' | '}
178      </span>
179    ));
180
181export const resolveTypeName = (
182  typeDefinition: TypeDefinitionData
183): string | JSX.Element | (string | JSX.Element)[] => {
184  if (!typeDefinition) {
185    return 'undefined';
186  }
187
188  const {
189    elements,
190    elementType,
191    name,
192    type,
193    types,
194    typeArguments,
195    declaration,
196    value,
197    queryType,
198    operator,
199    objectType,
200    indexType,
201  } = typeDefinition;
202
203  try {
204    if (name) {
205      if (type === 'reference') {
206        if (typeArguments) {
207          if (name === 'Record' || name === 'React.ComponentProps') {
208            return (
209              <>
210                {name}&lt;
211                {typeArguments.map((type, index) => (
212                  <span key={`record-type-${index}`}>
213                    {resolveTypeName(type)}
214                    {index !== typeArguments.length - 1 ? ', ' : null}
215                  </span>
216                ))}
217                &gt;
218              </>
219            );
220          } else {
221            return (
222              <>
223                {renderWithLink(name)}
224                &lt;
225                {typeArguments.map((type, index) => (
226                  <span key={`${name}-nested-type-${index}`}>
227                    {resolveTypeName(type)}
228                    {index !== typeArguments.length - 1 ? ', ' : null}
229                  </span>
230                ))}
231                &gt;
232              </>
233            );
234          }
235        } else {
236          return renderWithLink(name);
237        }
238      } else {
239        return name;
240      }
241    } else if (elementType?.name) {
242      if (elementType.type === 'reference') {
243        return renderWithLink(elementType.name, type);
244      } else if (type === 'array') {
245        return elementType.name + '[]';
246      }
247      return elementType.name + type;
248    } else if (elementType?.declaration) {
249      if (type === 'array') {
250        const { parameters, type: paramType } = elementType.declaration.indexSignature || {};
251        if (parameters && paramType) {
252          return `{ [${listParams(parameters)}]: ${resolveTypeName(paramType)} }`;
253        }
254      }
255      return elementType.name + type;
256    } else if (type === 'union' && types?.length) {
257      return renderUnion(types);
258    } else if (elementType && elementType.type === 'union' && elementType?.types?.length) {
259      const unionTypes = elementType?.types || [];
260      return (
261        <>
262          ({renderUnion(unionTypes)}){type === 'array' && '[]'}
263        </>
264      );
265    } else if (declaration?.signatures) {
266      const baseSignature = declaration.signatures[0];
267      if (baseSignature?.parameters?.length) {
268        return (
269          <>
270            (
271            {baseSignature.parameters?.map((param, index) => (
272              <span key={`param-${index}-${param.name}`}>
273                {param.name}: {resolveTypeName(param.type)}
274                {index + 1 !== baseSignature.parameters?.length && ', '}
275              </span>
276            ))}
277            ) {'=>'} {resolveTypeName(baseSignature.type)}
278          </>
279        );
280      } else {
281        return (
282          <>
283            {'() =>'} {resolveTypeName(baseSignature.type)}
284          </>
285        );
286      }
287    } else if (type === 'reflection' && declaration?.children) {
288      return (
289        <>
290          {'{\n'}
291          {declaration?.children.map((child: PropData, i) => (
292            <span key={`reflection-${name}-${i}`}>
293              {'  '}
294              {child.name + ': '}
295              {resolveTypeName(child.type)}
296              {i + 1 !== declaration?.children?.length ? ', ' : null}
297              {'\n'}
298            </span>
299          ))}
300          {'}'}
301        </>
302      );
303    } else if (type === 'tuple' && elements) {
304      return (
305        <>
306          [
307          {elements.map((elem, i) => (
308            <span key={`tuple-${name}-${i}`}>
309              {resolveTypeName(elem)}
310              {i + 1 !== elements.length ? ', ' : null}
311            </span>
312          ))}
313          ]
314        </>
315      );
316    } else if (type === 'query' && queryType) {
317      return queryType.name;
318    } else if (type === 'literal' && typeof value === 'boolean') {
319      return `${value}`;
320    } else if (type === 'literal' && (value || (typeof value === 'number' && value === 0))) {
321      return `'${value}'`;
322    } else if (type === 'intersection' && types) {
323      return types
324        .filter(({ name }) => !omittableTypes.includes(name ?? ''))
325        .map((value, index, array) => (
326          <span key={`intersection-${name}-${index}`}>
327            {resolveTypeName(value)}
328            {index + 1 !== array.length && ' & '}
329          </span>
330        ));
331    } else if (type === 'indexedAccess') {
332      return `${objectType?.name}['${indexType?.value}']`;
333    } else if (type === 'typeOperator') {
334      return operator || 'undefined';
335    } else if (value === null) {
336      return 'null';
337    }
338    return 'undefined';
339  } catch (e) {
340    console.warn('Type resolve has failed!', e);
341    return 'undefined';
342  }
343};
344
345export const parseParamName = (name: string) => (name.startsWith('__') ? name.substr(2) : name);
346
347export const renderParamRow = ({
348  comment,
349  name,
350  type,
351  flags,
352  defaultValue,
353}: MethodParamData): JSX.Element => {
354  const initValue = parseCommentContent(defaultValue || getTagData('default', comment)?.text);
355  return (
356    <Row key={`param-${name}`}>
357      <Cell>
358        <B>{parseParamName(name)}</B>
359        {renderFlags(flags, initValue)}
360      </Cell>
361      <Cell>
362        <APIDataType typeDefinition={type} />
363      </Cell>
364      <Cell>
365        <CommentTextBlock
366          comment={comment}
367          afterContent={renderDefaultValue(initValue)}
368          emptyCommentFallback="-"
369        />
370      </Cell>
371    </Row>
372  );
373};
374
375export const renderTableHeadRow = () => (
376  <TableHead>
377    <Row>
378      <HeaderCell>Name</HeaderCell>
379      <HeaderCell>Type</HeaderCell>
380      <HeaderCell>Description</HeaderCell>
381    </Row>
382  </TableHead>
383);
384
385export const renderParams = (parameters: MethodParamData[]) => (
386  <Table>
387    {renderTableHeadRow()}
388    <tbody>{parameters?.map(renderParamRow)}</tbody>
389  </Table>
390);
391
392export const listParams = (parameters: MethodParamData[]) =>
393  parameters ? parameters?.map(param => parseParamName(param.name)).join(', ') : '';
394
395export const renderDefaultValue = (defaultValue?: string) =>
396  defaultValue && defaultValue !== '...' ? (
397    <div css={defaultValueContainerStyle}>
398      <B>Default:</B> <InlineCode>{defaultValue}</InlineCode>
399    </div>
400  ) : undefined;
401
402export const renderTypeOrSignatureType = (
403  type?: TypeDefinitionData,
404  signatures?: MethodSignatureData[],
405  allowBlock: boolean = false
406) => {
407  if (signatures && signatures.length) {
408    return (
409      <InlineCode key={`signature-type-${signatures[0].name}`}>
410        (
411        {signatures?.map(({ parameters }) =>
412          parameters?.map(param => (
413            <span key={`signature-param-${param.name}`}>
414              {param.name}
415              {param.flags?.isOptional && '?'}: {resolveTypeName(param.type)}
416            </span>
417          ))
418        )}
419        ) =&gt;{' '}
420        {type ? (
421          <InlineCode key={`signature-type-${type.name}`}>{resolveTypeName(type)}</InlineCode>
422        ) : (
423          'void'
424        )}
425      </InlineCode>
426    );
427  } else if (type) {
428    if (allowBlock) {
429      return <APIDataType typeDefinition={type} />;
430    }
431    return <InlineCode key={`signature-type-${type.name}`}>{resolveTypeName(type)}</InlineCode>;
432  }
433  return undefined;
434};
435
436export const renderFlags = (flags?: TypePropertyDataFlags, defaultValue?: string) =>
437  (flags?.isOptional || defaultValue) && (
438    <>
439      <br />
440      <span css={STYLES_OPTIONAL}>(optional)</span>
441    </>
442  );
443
444export const renderIndexSignature = (kind: TypeDocKind) =>
445  kind === TypeDocKind.Parameter && (
446    <>
447      <br />
448      <A
449        css={STYLES_OPTIONAL}
450        href="https://www.typescriptlang.org/docs/handbook/2/objects.html#index-signatures"
451        openInNewTab
452        isStyled>
453        (index signature)
454      </A>
455    </>
456  );
457
458export type CommentTextBlockProps = {
459  comment?: CommentData;
460  components?: MDComponents;
461  withDash?: boolean;
462  beforeContent?: JSX.Element;
463  afterContent?: JSX.Element;
464  includePlatforms?: boolean;
465  emptyCommentFallback?: string;
466};
467
468export const parseCommentContent = (content?: string): string =>
469  content && content.length ? content.replace(/&ast;/g, '*').replace(/\t/g, '') : '';
470
471export const getCommentOrSignatureComment = (
472  comment?: CommentData,
473  signatures?: MethodSignatureData[]
474) => comment || (signatures && signatures[0]?.comment);
475
476export const getTagData = (tagName: string, comment?: CommentData) =>
477  getAllTagData(tagName, comment)?.[0];
478
479export const getAllTagData = (tagName: string, comment?: CommentData) =>
480  comment?.tags?.filter(tag => tag.tag === tagName);
481
482export const getTagNamesList = (comment?: CommentData) =>
483  comment && [
484    ...(getAllTagData('platform', comment)?.map(platformData => platformData.text) || []),
485    ...(getTagData('deprecated', comment) ? ['deprecated'] : []),
486    ...(getTagData('experimental', comment) ? ['experimental'] : []),
487  ];
488
489export const getMethodName = (
490  method: MethodDefinitionData,
491  apiName?: string,
492  name?: string,
493  parameters?: MethodParamData[]
494) => {
495  const isProperty = method.kind === TypeDocKind.Property && !parameters?.length;
496  const methodName = ((apiName && `${apiName}.`) ?? '') + (method.name || name);
497  if (!isProperty) {
498    return `${methodName}(${parameters ? listParams(parameters) : ''})`;
499  }
500
501  return methodName;
502};
503
504export const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);
505
506export const CommentTextBlock = ({
507  comment,
508  components = mdComponents,
509  withDash,
510  beforeContent,
511  afterContent,
512  includePlatforms = true,
513  emptyCommentFallback,
514}: CommentTextBlockProps) => {
515  const shortText = comment?.shortText?.trim().length ? (
516    <ReactMarkdown components={components} remarkPlugins={[remarkGfm]}>
517      {parseCommentContent(comment.shortText)}
518    </ReactMarkdown>
519  ) : null;
520  const text = comment?.text?.trim().length ? (
521    <ReactMarkdown components={components} remarkPlugins={[remarkGfm]}>
522      {parseCommentContent(comment.text)}
523    </ReactMarkdown>
524  ) : null;
525
526  if (emptyCommentFallback && (!comment || (!shortText && !text))) {
527    return <>{emptyCommentFallback}</>;
528  }
529
530  const examples = getAllTagData('example', comment);
531  const exampleText = examples?.map((example, index) => (
532    <React.Fragment key={'example-' + index}>
533      {components !== mdComponents ? (
534        <div css={STYLES_EXAMPLE_IN_TABLE}>
535          <B>Example</B>
536        </div>
537      ) : (
538        <div css={STYLES_NESTED_SECTION_HEADER}>
539          <H4>Example</H4>
540        </div>
541      )}
542      <ReactMarkdown components={components}>{example.text}</ReactMarkdown>
543    </React.Fragment>
544  ));
545
546  const see = getTagData('see', comment);
547  const seeText = see && (
548    <Callout>
549      <B>See: </B>
550      <ReactMarkdown components={mdInlineComponents}>{see.text}</ReactMarkdown>
551    </Callout>
552  );
553
554  const hasPlatforms = (getAllTagData('platform', comment)?.length || 0) > 0;
555
556  return (
557    <>
558      {!withDash && includePlatforms && hasPlatforms && (
559        <APISectionPlatformTags comment={comment} prefix="Only for:" />
560      )}
561      {beforeContent}
562      {withDash && (shortText || text) && ' - '}
563      {withDash && includePlatforms && <APISectionPlatformTags comment={comment} />}
564      {shortText}
565      {text}
566      {afterContent}
567      {seeText}
568      {exampleText}
569    </>
570  );
571};
572
573export const getComponentName = (name?: string, children: PropData[] = []) => {
574  if (name && name !== 'default') return name;
575  const ctor = children.filter((child: PropData) => child.name === 'constructor')[0];
576  return ctor?.signatures?.[0]?.type?.name ?? 'default';
577};
578
579export const STYLES_APIBOX = css({
580  borderRadius: borderRadius.medium,
581  borderWidth: 1,
582  borderStyle: 'solid',
583  borderColor: theme.border.default,
584  padding: `${spacing[5]}px ${spacing[5]}px 0`,
585  boxShadow: shadows.micro,
586  marginBottom: spacing[6],
587  overflowX: 'hidden',
588
589  h3: {
590    marginBottom: spacing[2],
591  },
592
593  'h3, h4': {
594    marginTop: 0,
595  },
596
597  th: {
598    color: theme.text.secondary,
599    padding: `${spacing[3]}px ${spacing[4]}px`,
600  },
601
602  [`.css-${tableWrapperStyle.name}`]: {
603    boxShadow: 'none',
604  },
605
606  [`@media screen and (max-width: ${breakpoints.medium + 124}px)`]: {
607    padding: `0 ${spacing[4]}px`,
608  },
609});
610
611export const STYLES_APIBOX_NESTED = css({
612  boxShadow: 'none',
613
614  h4: {
615    marginTop: 0,
616  },
617});
618
619export const STYLES_NESTED_SECTION_HEADER = css({
620  display: 'flex',
621  borderTop: `1px solid ${theme.border.default}`,
622  borderBottom: `1px solid ${theme.border.default}`,
623  margin: `${spacing[4]}px -${spacing[5]}px ${spacing[4]}px`,
624  padding: `${spacing[2.5]}px ${spacing[5]}px`,
625  backgroundColor: theme.background.secondary,
626
627  h4: {
628    ...typography.fontSizes[16],
629    fontFamily: typography.fontFaces.medium,
630    marginBottom: 0,
631    marginTop: 0,
632    color: theme.text.secondary,
633  },
634});
635
636export const STYLES_NOT_EXPOSED_HEADER = css({
637  marginBottom: spacing[1],
638  display: 'inline-block',
639
640  code: {
641    marginBottom: 0,
642  },
643});
644
645export const STYLES_OPTIONAL = css({
646  color: theme.text.secondary,
647  fontSize: '90%',
648  paddingTop: 22,
649});
650
651export const STYLES_SECONDARY = css({
652  color: theme.text.secondary,
653  fontSize: '90%',
654  fontWeight: 600,
655});
656
657const defaultValueContainerStyle = css({
658  marginTop: spacing[2],
659  marginBottom: spacing[2],
660
661  '&:last-child': {
662    marginBottom: 0,
663  },
664});
665
666const STYLES_EXAMPLE_IN_TABLE = css({
667  margin: `${spacing[2]}px 0`,
668});
669