Files
upstream-tldraw/apps/docs/utils/TldrawApiModel.ts
upstream-mirror a774ac673a
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
2026-09-23 16:12:52 +05:00

246 lines
7.6 KiB
TypeScript

import {
ApiDocumentedItem,
ApiFunction,
ApiItem,
ApiModel,
ApiPackage,
ApiVariable,
ExcerptToken,
ExcerptTokenKind,
} from '@microsoft/api-extractor-model'
import { assert } from '@tldraw/utils'
import { MarkdownWriter } from '@/scripts/lib/utils'
export class TldrawApiModel extends ApiModel {
private reactComponents = new Set<ApiItem>()
private reactComponentProps = new Set<ApiItem>()
nonBlockingErrors: Error[] = []
async preprocessReactComponents() {
for (const packageModel of this.members) {
assert(packageModel instanceof ApiPackage)
const entrypoint = packageModel.entryPoints[0]
for (const member of entrypoint.members) {
assert(member instanceof ApiDocumentedItem)
if (!member.tsdocComment) continue
if (!member.tsdocComment.modifierTagSet.hasTagName('@react')) continue
this.reactComponents.add(member)
try {
const props = this.getReactPropsItem(member)
if (props instanceof ApiDocumentedItem && props.tsdocComment) {
const markdown = await MarkdownWriter.docNodeToMarkdown(
props,
props.tsdocComment.summarySection
)
if (markdown.trim()) {
this.nonBlockingError(
props,
"Component props should not contain documentation as it won't be included in the docs site. Add it to the component instead."
)
}
}
if (props) this.reactComponentProps.add(props)
} catch (e: any) {
this.nonBlockingErrors.push(e)
}
}
}
}
resolveToken(origin: ApiItem, token: ExcerptToken) {
const apiItemResult = this.resolveDeclarationReference(token.canonicalReference!, origin)
if (apiItemResult.errorMessage) {
this.error(origin, apiItemResult.errorMessage)
}
return apiItemResult.resolvedApiItem!
}
tryResolveToken(origin: ApiItem, token: ExcerptToken) {
const apiItemResult = this.resolveDeclarationReference(token.canonicalReference!, origin)
if (apiItemResult.errorMessage) {
return null
}
return apiItemResult.resolvedApiItem!
}
getReactPropsItem(component: ApiItem): ApiItem | null {
if (component instanceof ApiFunction) {
if (component.parameters.length === 0) return null
this.assert(
component,
component.parameters.length === 1,
`Expected 1 parameter for @react component`
)
const propsParam = component.parameters[0]
const tokens = propsParam.parameterTypeExcerpt.spannedTokens
if (tokens.length === 1 && tokens[0].kind === 'Reference') {
return this.resolveToken(component, tokens[0])
} else if (
tokens.length === 2 &&
tokens[0].kind === 'Reference' &&
tokens[1].text.startsWith('<')
) {
return this.resolveToken(component, tokens[0])
}
this.nonBlockingError(
component,
`Expected props parameter to be a simple reference. Rewrite this to use a \`${component.displayName}Props\` interface.\nFound: ${propsParam.parameterTypeExcerpt.text}`
)
return null
} else if (component instanceof ApiVariable) {
const tokens = component.variableTypeExcerpt.spannedTokens
if (
tokens.length === 5 &&
tokens[0].text === 'import("react").' &&
tokens[1].text === 'NamedExoticComponent' &&
tokens[2].text === '<' &&
tokens[3].kind === ExcerptTokenKind.Reference &&
tokens[4].text === '>'
) {
return this.resolveToken(component, tokens[3])
}
if (
tokens.length === 4 &&
tokens[0].text === 'React.NamedExoticComponent' &&
tokens[1].text === '<' &&
tokens[2].kind === ExcerptTokenKind.Reference &&
tokens[3].text === '>'
) {
return this.resolveToken(component, tokens[2])
}
if (
tokens.length === 8 &&
tokens[0].text === 'React.ForwardRefExoticComponent' &&
tokens[1].text === '<' &&
tokens[2].kind === ExcerptTokenKind.Reference &&
tokens[3].text === ' & ' &&
tokens[4].text === 'React.RefAttributes' &&
tokens[5].text === '<' &&
tokens[6].kind === ExcerptTokenKind.Reference &&
tokens[7].text === '>>'
) {
return this.resolveToken(component, tokens[2])
}
if (
tokens.length === 9 &&
tokens[0].text === 'import("react").' &&
tokens[1].text === 'ForwardRefExoticComponent' &&
tokens[2].text === '<' &&
tokens[3].kind === ExcerptTokenKind.Reference &&
tokens[4].text === ' & import("react").' &&
tokens[5].text === 'RefAttributes' &&
tokens[6].text === '<' &&
tokens[7].kind === ExcerptTokenKind.Reference &&
tokens[8].text === '>>'
) {
return this.resolveToken(component, tokens[3])
}
if (component.variableTypeExcerpt.text === 'import("react").NamedExoticComponent<object>') {
// this is a `memo` component with no props
return null
}
const memoProps = this.getMemoComponentPropsToken(tokens)
if (memoProps !== undefined) {
return memoProps ? this.resolveToken(component, memoProps) : null
}
this.nonBlockingError(
component,
`Expected a simple props interface for react component. Got: ${component.variableTypeExcerpt.text}`
)
return null
} else {
this.nonBlockingError(component, `Unknown item kind for @react component: ${component.kind}`)
return null
}
}
/**
* TypeScript 7 doesn't resolve `memo(...)` components to
* `NamedExoticComponent<SomeProps>` like TypeScript 5 did. Instead it keeps the wrapped
* function's signature, e.g. `MemoExoticComponent<({ children }: SomeProps) => JSX.Element>`.
*
* Returns the props reference token, `null` if the component takes no props, or `undefined` if
* this isn't a memo component we know how to read.
*/
private getMemoComponentPropsToken(
tokens: readonly ExcerptToken[]
): ExcerptToken | null | undefined {
const headIndex = tokens.findIndex(
(token) =>
token.kind === ExcerptTokenKind.Reference &&
(token.text === 'MemoExoticComponent' || token.text === 'React.MemoExoticComponent')
)
if (headIndex === -1) return undefined
const typeArgument = tokens[headIndex + 1]
if (!typeArgument?.text.startsWith('<(')) return undefined
// a memo component with no props, e.g. `MemoExoticComponent<() => JSX.Element>`
if (typeArgument.text.startsWith('<()')) return null
// walk back from the end of the parameter list to the reference it annotates. we can't take
// the first reference we find: renamed destructured properties are references too, as in
// `({ onKeyDown: handleKeyDown }: SomeProps)`, and they aren't annotated with `: `.
const closeIndex = tokens.findIndex((token, i) => i > headIndex && token.text.includes(') =>'))
if (closeIndex === -1) return undefined
for (let i = closeIndex - 1; i > headIndex; i--) {
const token = tokens[i]
if (token.kind !== ExcerptTokenKind.Reference) continue
if (!tokens[i - 1].text.endsWith(': ')) continue
return token
}
return undefined
}
isComponent(item: ApiItem): boolean {
return this.reactComponents.has(item)
}
isComponentProps(item: ApiItem): boolean {
return this.reactComponentProps.has(item)
}
private createError(item: ApiItem, message: string) {
const suffix =
'_fileUrlPath' in item && typeof item._fileUrlPath === 'string'
? `\nin ${item._fileUrlPath}`
: ''
return new Error(`${item.displayName}: ${message}${suffix}`)
}
nonBlockingError(item: ApiItem, message: string) {
this.nonBlockingErrors.push(this.createError(item, message))
}
throwEncounteredErrors() {
if (this.nonBlockingErrors.length > 0) {
throw new Error(this.nonBlockingErrors.map((e) => (e as any).message).join('\n\n'))
}
}
error(item: ApiItem, message: string): never {
throw this.createError(item, message)
}
assert(item: ApiItem, condition: unknown, message: string): asserts condition {
if (!condition) {
this.error(item, message)
}
}
}