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