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