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