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