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