1import { css } from '@emotion/react';
2import { theme } 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 } 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';
20
21const isDev = process.env.NODE_ENV === 'development';
22
23export enum TypeDocKind {
24  LegacyEnum = 4,
25  Enum = 8,
26  Variable = 32,
27  Function = 64,
28  Class = 128,
29  Interface = 256,
30  Property = 1024,
31  Method = 2048,
32  TypeAlias = 4194304,
33}
34
35export type MDComponents = React.ComponentProps<typeof ReactMarkdown>['components'];
36
37const getInvalidLinkMessage = (href: string) =>
38  `Using "../" when linking other packages in doc comments produce a broken link! Please use "./" instead. Problematic link:\n\t${href}`;
39
40export const mdComponents: MDComponents = {
41  blockquote: ({ children }) => (
42    <Quote>
43      {/* @ts-ignore - current implementation produce type issues, this would be fixed in docs redesign */}
44      {children.map(child => (child?.props?.node?.tagName === 'p' ? child?.props.children : child))}
45    </Quote>
46  ),
47  code: ({ children, className }) =>
48    className ? <Code className={className}>{children}</Code> : <InlineCode>{children}</InlineCode>,
49  h1: ({ children }) => <H4>{children}</H4>,
50  ul: ({ children }) => <UL>{children}</UL>,
51  li: ({ children }) => <LI>{children}</LI>,
52  a: ({ href, children }) => {
53    if (
54      href?.startsWith('../') &&
55      !href?.startsWith('../..') &&
56      !href?.startsWith('../react-native')
57    ) {
58      if (isDev) {
59        throw new Error(getInvalidLinkMessage(href));
60      } else {
61        console.warn(getInvalidLinkMessage(href));
62      }
63    }
64    return <Link href={href}>{children}</Link>;
65  },
66  p: ({ children }) => (children ? <P>{children}</P> : null),
67  strong: ({ children }) => <B>{children}</B>,
68  span: ({ children }) => (children ? <span>{children}</span> : null),
69};
70
71export const mdInlineComponents: MDComponents = {
72  ...mdComponents,
73  p: ({ children }) => (children ? <span>{children}</span> : null),
74};
75
76const nonLinkableTypes = [
77  'ColorValue',
78  'Component',
79  'E',
80  'EventSubscription',
81  'File',
82  'FileList',
83  'Manifest',
84  'NativeSyntheticEvent',
85  'ParsedQs',
86  'ServiceActionResult',
87  'T',
88  'TaskOptions',
89  'Uint8Array',
90  // React & React Native
91  'React.FC',
92  'ForwardRefExoticComponent',
93  'StyleProp',
94  // Cross-package permissions management
95  'RequestPermissionMethod',
96  'GetPermissionMethod',
97  'Options',
98  'PermissionHookBehavior',
99];
100
101/**
102 * List of type names that should not be visible in the docs.
103 */
104const omittableTypes = [
105  // Internal React type that adds `ref` prop to the component
106  'RefAttributes',
107];
108
109/**
110 * Map of internal names/type names that should be replaced with something more developer-friendly.
111 */
112const replaceableTypes: Partial<Record<string, string>> = {
113  ForwardRefExoticComponent: 'Component',
114};
115
116const hardcodedTypeLinks: Record<string, string> = {
117  AVPlaybackSource: '/versions/latest/sdk/av/#playback-api',
118  AVPlaybackStatus: '/versions/latest/sdk/av/#playback-status',
119  AVPlaybackStatusToSet: '/versions/latest/sdk/av/#default-initial--avplaybackstatustoset',
120  Date: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date',
121  Element: 'https://www.typescriptlang.org/docs/handbook/jsx.html#function-component',
122  Error: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error',
123  ExpoConfig: 'https://github.com/expo/expo-cli/blob/main/packages/config-types/src/ExpoConfig.ts',
124  MessageEvent: 'https://developer.mozilla.org/en-US/docs/Web/API/MessageEvent',
125  Omit: 'https://www.typescriptlang.org/docs/handbook/utility-types.html#omittype-keys',
126  Pick: 'https://www.typescriptlang.org/docs/handbook/utility-types.html#picktype-keys',
127  Partial: 'https://www.typescriptlang.org/docs/handbook/utility-types.html#partialtype',
128  Promise:
129    'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise',
130  View: '/versions/latest/react-native/view',
131  ViewProps: '/versions/latest/react-native/view#props',
132  ViewStyle: '/versions/latest/react-native/view-style-props',
133};
134
135const renderWithLink = (name: string, type?: string) => {
136  const replacedName = replaceableTypes[name] ?? name;
137
138  return nonLinkableTypes.includes(replacedName) ? (
139    replacedName + (type === 'array' ? '[]' : '')
140  ) : (
141    <Link
142      href={hardcodedTypeLinks[replacedName] || `#${replacedName.toLowerCase()}`}
143      key={`type-link-${replacedName}`}>
144      {replacedName}
145      {type === 'array' && '[]'}
146    </Link>
147  );
148};
149
150const renderUnion = (types: TypeDefinitionData[]) =>
151  types.map(resolveTypeName).map((valueToRender, index) => (
152    <span key={`union-type-${index}`}>
153      {valueToRender}
154      {index + 1 !== types.length && ' | '}
155    </span>
156  ));
157
158export const resolveTypeName = ({
159  elements,
160  elementType,
161  name,
162  type,
163  types,
164  typeArguments,
165  declaration,
166  value,
167  queryType,
168  operator,
169  objectType,
170  indexType,
171}: TypeDefinitionData): string | JSX.Element | (string | JSX.Element)[] => {
172  try {
173    if (name) {
174      if (type === 'reference') {
175        if (typeArguments) {
176          if (name === 'Record' || name === 'React.ComponentProps') {
177            return (
178              <>
179                {name}&lt;
180                {typeArguments.map((type, index) => (
181                  <span key={`record-type-${index}`}>
182                    {resolveTypeName(type)}
183                    {index !== typeArguments.length - 1 ? ', ' : null}
184                  </span>
185                ))}
186                &gt;
187              </>
188            );
189          } else {
190            return (
191              <>
192                {renderWithLink(name)}
193                &lt;
194                {typeArguments.map((type, index) => (
195                  <span key={`${name}-nested-type-${index}`}>
196                    {resolveTypeName(type)}
197                    {index !== typeArguments.length - 1 ? ', ' : null}
198                  </span>
199                ))}
200                &gt;
201              </>
202            );
203          }
204        } else {
205          return renderWithLink(name);
206        }
207      } else {
208        return name;
209      }
210    } else if (elementType?.name) {
211      if (elementType.type === 'reference') {
212        return renderWithLink(elementType.name, type);
213      } else if (type === 'array') {
214        return elementType.name + '[]';
215      }
216      return elementType.name + type;
217    } else if (elementType?.declaration) {
218      if (type === 'array') {
219        const { parameters, type: paramType } = elementType.declaration.indexSignature || {};
220        if (parameters && paramType) {
221          return `{ [${listParams(parameters)}]: ${resolveTypeName(paramType)} }`;
222        }
223      }
224      return elementType.name + type;
225    } else if (type === 'union' && types?.length) {
226      return renderUnion(types);
227    } else if (elementType && elementType.type === 'union' && elementType?.types?.length) {
228      const unionTypes = elementType?.types || [];
229      return (
230        <>
231          ({renderUnion(unionTypes)}){type === 'array' && '[]'}
232        </>
233      );
234    } else if (declaration?.signatures) {
235      const baseSignature = declaration.signatures[0];
236      if (baseSignature?.parameters?.length) {
237        return (
238          <>
239            (
240            {baseSignature.parameters?.map((param, index) => (
241              <span key={`param-${index}-${param.name}`}>
242                {param.name}: {resolveTypeName(param.type)}
243                {index + 1 !== baseSignature.parameters?.length && ', '}
244              </span>
245            ))}
246            ) {'=>'} {resolveTypeName(baseSignature.type)}
247          </>
248        );
249      } else {
250        return (
251          <>
252            {'() =>'} {resolveTypeName(baseSignature.type)}
253          </>
254        );
255      }
256    } else if (type === 'reflection' && declaration?.children) {
257      return (
258        <>
259          {'{ '}
260          {declaration?.children.map((child: PropData, i) => (
261            <span key={`reflection-${name}-${i}`}>
262              {child.name + ': ' + resolveTypeName(child.type)}
263              {i + 1 !== declaration?.children?.length ? ', ' : null}
264            </span>
265          ))}
266          {' }'}
267        </>
268      );
269    } else if (type === 'tuple' && elements) {
270      return (
271        <>
272          [
273          {elements.map((elem, i) => (
274            <span key={`tuple-${name}-${i}`}>
275              {resolveTypeName(elem)}
276              {i + 1 !== elements.length ? ', ' : null}
277            </span>
278          ))}
279          ]
280        </>
281      );
282    } else if (type === 'query' && queryType) {
283      return queryType.name;
284    } else if (type === 'literal' && typeof value === 'boolean') {
285      return `${value}`;
286    } else if (type === 'literal' && value) {
287      return `'${value}'`;
288    } else if (type === 'intersection' && types) {
289      return types
290        .filter(({ name }) => !omittableTypes.includes(name ?? ''))
291        .map((value, index, array) => (
292          <span key={`intersection-${name}-${index}`}>
293            {resolveTypeName(value)}
294            {index + 1 !== array.length && ' & '}
295          </span>
296        ));
297    } else if (type === 'indexedAccess') {
298      return `${objectType?.name}['${indexType?.value}']`;
299    } else if (type === 'typeOperator') {
300      return operator || 'undefined';
301    } else if (value === null) {
302      return 'null';
303    }
304    return 'undefined';
305  } catch (e) {
306    console.warn('Type resolve has failed!', e);
307    return 'undefined';
308  }
309};
310
311export const parseParamName = (name: string) => (name.startsWith('__') ? name.substr(2) : name);
312
313export const renderParam = ({ comment, name, type, flags }: MethodParamData): JSX.Element => (
314  <LI key={`param-${name}`}>
315    <B>
316      {parseParamName(name)}
317      {flags?.isOptional && '?'} (<InlineCode>{resolveTypeName(type)}</InlineCode>)
318    </B>
319    <CommentTextBlock comment={comment} components={mdInlineComponents} withDash />
320  </LI>
321);
322
323export const listParams = (parameters: MethodParamData[]) =>
324  parameters ? parameters?.map(param => parseParamName(param.name)).join(', ') : '';
325
326export const renderTypeOrSignatureType = (
327  type?: TypeDefinitionData,
328  signatures?: MethodSignatureData[],
329  includeParamType: boolean = false
330) => {
331  if (type) {
332    return <InlineCode key={`signature-type-${type.name}`}>{resolveTypeName(type)}</InlineCode>;
333  } else if (signatures && signatures.length) {
334    return signatures.map(({ name, type, parameters }) => (
335      <InlineCode key={`signature-type-${name}`}>
336        (
337        {parameters && includeParamType
338          ? parameters.map(param => (
339              <span key={`signature-param-${param.name}`}>
340                {param.name}
341                {param.flags?.isOptional && '?'}: {resolveTypeName(param.type)}
342              </span>
343            ))
344          : listParams(parameters)}
345        ) =&gt; {resolveTypeName(type)}
346      </InlineCode>
347    ));
348  }
349  return undefined;
350};
351
352export const renderFlags = (flags?: TypePropertyDataFlags) =>
353  flags?.isOptional ? (
354    <>
355      <br />
356      <span css={STYLES_OPTIONAL}>(optional)</span>
357    </>
358  ) : undefined;
359
360export type CommentTextBlockProps = {
361  comment?: CommentData;
362  components?: MDComponents;
363  withDash?: boolean;
364  beforeContent?: JSX.Element | null;
365  afterContent?: JSX.Element | null;
366  includePlatforms?: boolean;
367};
368
369export const parseCommentContent = (content?: string): string =>
370  content && content.length ? content.replace(/&ast;/g, '*').replace(/\t/g, '') : '';
371
372export const getCommentOrSignatureComment = (
373  comment?: CommentData,
374  signatures?: MethodSignatureData[]
375) => comment || (signatures && signatures[0]?.comment);
376
377export const getTagData = (tagName: string, comment?: CommentData) =>
378  getAllTagData(tagName, comment)?.[0];
379
380export const getAllTagData = (tagName: string, comment?: CommentData) =>
381  comment?.tags?.filter(tag => tag.tag === tagName);
382
383const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);
384
385const formatPlatformName = (name: string) => {
386  const cleanName = name.toLowerCase().replace('\n', '');
387  return cleanName.includes('ios')
388    ? cleanName.replace('ios', 'iOS')
389    : cleanName.includes('expo')
390    ? cleanName.replace('expo', 'Expo Go')
391    : capitalize(name);
392};
393
394export const getPlatformTags = (comment?: CommentData, breakLine: boolean = true) => {
395  const platforms = getAllTagData('platform', comment);
396  return platforms?.length ? (
397    <>
398      {platforms.map(platform => (
399        <div key={platform.text} css={STYLES_PLATFORM}>
400          {formatPlatformName(platform.text)} Only
401        </div>
402      ))}
403      {breakLine && <br />}
404    </>
405  ) : null;
406};
407
408export const CommentTextBlock = ({
409  comment,
410  components = mdComponents,
411  withDash,
412  beforeContent,
413  afterContent,
414  includePlatforms = true,
415}: CommentTextBlockProps) => {
416  const shortText = comment?.shortText?.trim().length ? (
417    <ReactMarkdown components={components} remarkPlugins={[remarkGfm]}>
418      {parseCommentContent(comment.shortText)}
419    </ReactMarkdown>
420  ) : null;
421  const text = comment?.text?.trim().length ? (
422    <ReactMarkdown components={components} remarkPlugins={[remarkGfm]}>
423      {parseCommentContent(comment.text)}
424    </ReactMarkdown>
425  ) : null;
426
427  const examples = getAllTagData('example', comment);
428  const exampleText = examples?.map((example, index) => (
429    <React.Fragment key={'example-' + index}>
430      {components !== mdComponents ? (
431        <div css={STYLES_EXAMPLE_IN_TABLE}>
432          <B>Example</B>
433        </div>
434      ) : (
435        <H4>Example</H4>
436      )}
437      <ReactMarkdown components={components}>{example.text}</ReactMarkdown>
438    </React.Fragment>
439  ));
440
441  const deprecation = getTagData('deprecated', comment);
442  const deprecationNote = deprecation ? (
443    <Quote key="deprecation-note">
444      {deprecation.text.trim().length ? (
445        <ReactMarkdown
446          components={mdInlineComponents}>{`**Deprecated.** ${deprecation.text}`}</ReactMarkdown>
447      ) : (
448        <B>Deprecated</B>
449      )}
450    </Quote>
451  ) : null;
452
453  const see = getTagData('see', comment);
454  const seeText = see ? (
455    <Quote>
456      <B>See: </B>
457      <ReactMarkdown components={mdInlineComponents}>{see.text}</ReactMarkdown>
458    </Quote>
459  ) : null;
460
461  return (
462    <>
463      {deprecationNote}
464      {beforeContent}
465      {withDash && (shortText || text) && ' - '}
466      {includePlatforms && getPlatformTags(comment, !withDash)}
467      {shortText}
468      {text}
469      {afterContent}
470      {seeText}
471      {exampleText}
472    </>
473  );
474};
475
476export const getComponentName = (name?: string, children: PropData[] = []) => {
477  if (name && name !== 'default') return name;
478  const ctor = children.filter((child: PropData) => child.name === 'constructor')[0];
479  return ctor?.signatures?.[0]?.type?.name ?? 'default';
480};
481
482export const STYLES_OPTIONAL = css`
483  color: ${theme.text.secondary};
484  font-size: 90%;
485  padding-top: 22px;
486`;
487
488export const STYLES_SECONDARY = css`
489  color: ${theme.text.secondary};
490  font-size: 90%;
491  font-weight: 600;
492`;
493
494export const STYLES_PLATFORM = css`
495  & {
496    display: inline-block;
497    background-color: ${theme.background.tertiary};
498    color: ${theme.text.default};
499    font-size: 90%;
500    font-weight: 700;
501    padding: 6px 12px;
502    margin-bottom: 8px;
503    margin-right: 8px;
504    border-radius: 4px;
505  }
506
507  table & {
508    margin-bottom: 1rem;
509  }
510`;
511
512const STYLES_EXAMPLE_IN_TABLE = css`
513  margin: 8px 0;
514`;
515