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