Files
upstream-tldraw/apps/mcp-app/scripts/extract-editor-api.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

1375 lines
38 KiB
TypeScript

import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
import ts from 'typescript'
const scriptPath = fileURLToPath(import.meta.url)
const __dirname = path.dirname(scriptPath)
const distDir = path.join(__dirname, '..', 'dist')
const outPath = path.join(distDir, 'editor-api.json')
const methodMapOutPath = path.join(distDir, 'method-map.json')
const formatTsPath = path.join(__dirname, '..', 'src', 'widget', 'focused', 'format.ts')
const execHelpersPath = path.join(__dirname, '..', 'src', 'widget', 'exec-helpers.ts')
// In the tldraw monorepo, packages build to .tsbuild/
const repoRoot = path.resolve(__dirname, '..', '..', '..')
const editorDtsPath = path.join(
repoRoot,
'packages',
'editor',
'.tsbuild',
'lib',
'editor',
'Editor.d.ts'
)
const storeDtsPath = path.join(repoRoot, 'packages', 'store', '.tsbuild', 'index.d.ts')
const tlschemaDtsPath = path.join(repoRoot, 'packages', 'tlschema', '.tsbuild', 'index.d.ts')
for (const p of [editorDtsPath, storeDtsPath, tlschemaDtsPath]) {
if (!fs.existsSync(p)) {
console.error(`Missing: ${p}\nRun 'yarn lazy build' first to generate .d.ts files.`)
process.exit(1)
}
}
// --- Types ---
interface ExtractedParam {
name: string
description: string
}
interface ExtractedMember {
name: string
kind: 'method' | 'property' | 'getter'
signature: string
description: string
params: ExtractedParam[]
examples: string[]
category: string
}
interface ExtractedTypeProperty {
name: string
signature: string
description: string
optional: boolean
}
interface ExtractedShapeType {
name: string
shapeType: string
signature: string
description: string
propsType: string
propsDescription: string
props: ExtractedTypeProperty[]
}
interface ExtractedTypesSection {
shapeTypes: string[]
shapes: ExtractedShapeType[]
}
interface ExtractedTypeMember {
name: string
kind: 'method' | 'property' | 'getter'
signature: string
description: string
params: ExtractedParam[]
examples: string[]
optional: boolean
static: boolean
}
interface ExtractedNamedType {
name: string
kind: 'class' | 'interface' | 'type' | 'function' | 'const'
signature: string
description: string
params?: ExtractedParam[]
examples?: string[]
members?: ExtractedTypeMember[]
aliasedTo?: string
resolvedType?: ExtractedNamedType
relatedTypes?: ExtractedNamedType[]
}
interface ExtractedExecHelper {
name: string
source: 'local' | 'tldraw'
origin: string
signature: string
description: string
params: ExtractedParam[]
examples: string[]
typeInfo?: ExtractedNamedType
}
interface ExtractedExecSection {
helperCount: number
helpers: ExtractedExecHelper[]
}
type NamedDeclaration =
| ts.ClassDeclaration
| ts.InterfaceDeclaration
| ts.TypeAliasDeclaration
| ts.FunctionDeclaration
| ts.VariableDeclaration
interface DeclarationContext {
program: ts.Program
checker: ts.TypeChecker
declarations: Map<string, NamedDeclaration>
sourceFiles: Map<string, ts.SourceFile>
}
// --- Helpers ---
function categorize(name: string): string {
if (/camera/i.test(name)) return 'camera'
if (/viewport|screenToPage|pageToScreen|pageToViewport|viewportToPage/i.test(name))
return 'viewport'
if (/^(get|set|create|update|delete|reorder|reparent).*shape/i.test(name)) return 'shapes'
if (/^(get|has)Shape/i.test(name)) return 'shapes'
if (/shapeUtil/i.test(name)) return 'shapes'
if (/^(select|deselect|getSelect|setSelect|clearSelect|getSelectedShape)/i.test(name))
return 'selection'
if (/selected/i.test(name)) return 'selection'
if (/^(get|set|create|delete|move|reorder|duplicate).*page/i.test(name)) return 'pages'
if (/^(undo|redo|mark|bail|squash|run$|history|batch)/i.test(name)) return 'history'
if (/^(zoom|pan|stopFollowing|startFollowing|slideCamera|resetZoom|zoomTo)/i.test(name))
return 'zoom'
if (/binding/i.test(name)) return 'bindings'
if (/^(group|ungroup)/i.test(name)) return 'grouping'
if (
/^(nudge|align|distribute|stack|stretch|pack|flip|rotate|resize|moveShapes|translate)/i.test(
name
)
)
return 'transform'
if (/^(isIn|getPath|setCurrentTool|getCurrentTool)/i.test(name)) return 'tools'
if (/asset/i.test(name)) return 'assets'
if (/style|opacity|color|font/i.test(name)) return 'styles'
if (/^(get|set).*Hinting/i.test(name)) return 'hinting'
if (/^(get|set).*Erasing/i.test(name)) return 'erasing'
if (/^(get|set).*Cropping/i.test(name)) return 'cropping'
if (/^(get|set).*Editing/i.test(name)) return 'editing'
if (/^(get|set).*Hovering/i.test(name)) return 'hovering'
if (/^(get|set).*Focus/i.test(name)) return 'focus'
if (/^(get|set).*Dragging/i.test(name)) return 'dragging'
if (/snap/i.test(name)) return 'snapping'
if (/export|toImage|toSvg|toBlobPromise/i.test(name)) return 'export'
if (/cursor/i.test(name)) return 'cursor'
if (/instance/i.test(name)) return 'instance'
if (/store/i.test(name)) return 'store'
if (/^(dispose|isDisposed)/i.test(name)) return 'lifecycle'
return 'other'
}
function extractJsDoc(
member: ts.Node,
sourceFile: ts.SourceFile
): { description: string; params: ExtractedParam[]; examples: string[] } {
const empty = { description: '', params: [], examples: [] }
const ranges = ts.getLeadingCommentRanges(sourceFile.text, member.getFullStart())
if (!ranges) return empty
const jsdocRanges = ranges.filter((r) => sourceFile.text.slice(r.pos, r.pos + 3) === '/**')
const jsdocRange = jsdocRanges[jsdocRanges.length - 1]
if (!jsdocRange) return empty
const raw = sourceFile.text.slice(jsdocRange.pos, jsdocRange.end)
if (raw.includes('Excluded from this release type')) {
return { description: '__EXCLUDED__', params: [], examples: [] }
}
const lines = raw
.replace(/^\/\*\*\s*/, '')
.replace(/\s*\*\/$/, '')
.split('\n')
.map((l) => l.replace(/^\s*\*\s?/, ''))
const descLines: string[] = []
const tags: Array<{ tag: string; text: string }> = []
for (const line of lines) {
const tagMatch = line.match(/^@(\w+)\s*(.*)/)
if (tagMatch) {
tags.push({ tag: tagMatch[1], text: tagMatch[2] })
} else if (tags.length > 0) {
tags[tags.length - 1].text += '\n' + line
} else {
descLines.push(line)
}
}
const description = descLines.join('\n').trim()
const params = tags
.filter((t) => t.tag === 'param')
.map((t) => {
const m = t.text.match(/^(\w+)\s*-\s*(.*)/)
return m ? { name: m[1], description: m[2].trim() } : null
})
.filter((p): p is ExtractedParam => p !== null)
const examples = tags
.filter((t) => t.tag === 'example')
.map((t) =>
t.text
.replace(/```ts\n?/g, '')
.replace(/```\n?/g, '')
.trim()
)
.filter((e) => e.length > 0)
return { description, params, examples }
}
function getPropertyName(name: ts.PropertyName | ts.BindingName | undefined): string | undefined {
if (!name) return undefined
if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) {
return name.text
}
return undefined
}
function isExcludedComment(member: ts.Node, sourceFile: ts.SourceFile): boolean {
const ranges = ts.getLeadingCommentRanges(sourceFile.text, member.getFullStart())
if (!ranges) return false
const lastRange = ranges[ranges.length - 1]
return sourceFile.text
.slice(lastRange.pos, lastRange.end)
.includes('Excluded from this release type')
}
// --- Declaration context ---
function createDeclarationContext(entryPaths: string[]): DeclarationContext {
const program = ts.createProgram(entryPaths, {
target: ts.ScriptTarget.ES2020,
jsx: ts.JsxEmit.ReactJSX,
moduleResolution: ts.ModuleResolutionKind.Node10,
})
const declarations = new Map<string, NamedDeclaration>()
const sourceFiles = new Map<string, ts.SourceFile>()
const indexedSourceFiles = new Set(entryPaths.map((entryPath) => path.resolve(entryPath)))
for (const sourceFile of program.getSourceFiles()) {
sourceFiles.set(sourceFile.fileName, sourceFile)
const shouldIndex =
indexedSourceFiles.has(path.resolve(sourceFile.fileName)) ||
sourceFile.fileName.includes('/packages/') ||
sourceFile.fileName.includes('/node_modules/@tldraw/') ||
sourceFile.fileName.includes('/node_modules/tldraw/')
if (!shouldIndex) continue
ts.forEachChild(sourceFile, (node) => {
if (
(ts.isClassDeclaration(node) ||
ts.isInterfaceDeclaration(node) ||
ts.isTypeAliasDeclaration(node) ||
ts.isFunctionDeclaration(node)) &&
node.name
) {
declarations.set(node.name.text, node)
return
}
if (ts.isVariableStatement(node)) {
for (const declaration of node.declarationList.declarations) {
if (ts.isIdentifier(declaration.name)) {
declarations.set(declaration.name.text, declaration)
}
}
}
})
}
return {
program,
checker: program.getTypeChecker(),
declarations,
sourceFiles,
}
}
function getDeclarationSourceFile(declaration: NamedDeclaration): ts.SourceFile {
return declaration.getSourceFile()
}
function getDeclarationKind(declaration: NamedDeclaration): ExtractedNamedType['kind'] {
if (ts.isClassDeclaration(declaration)) return 'class'
if (ts.isInterfaceDeclaration(declaration)) return 'interface'
if (ts.isFunctionDeclaration(declaration)) return 'function'
if (ts.isVariableDeclaration(declaration)) return 'const'
return 'type'
}
function getDeclarationSignature(
declaration: NamedDeclaration,
sourceFile: ts.SourceFile,
checker: ts.TypeChecker
): string {
if (ts.isTypeAliasDeclaration(declaration)) {
return declaration.type.getText(sourceFile)
}
if (ts.isFunctionDeclaration(declaration)) {
try {
const signature = checker.getSignatureFromDeclaration(declaration)
if (signature) {
return checker.signatureToString(
signature,
declaration,
ts.TypeFormatFlags.NoTruncation | ts.TypeFormatFlags.WriteArrowStyleSignature
)
}
return checker.typeToString(
checker.getTypeAtLocation(declaration),
declaration,
ts.TypeFormatFlags.NoTruncation | ts.TypeFormatFlags.WriteArrowStyleSignature
)
} catch {
return '(unknown)'
}
}
if (ts.isVariableDeclaration(declaration)) {
try {
if (declaration.type) return declaration.type.getText(sourceFile)
return checker.typeToString(
checker.getTypeAtLocation(declaration),
declaration,
ts.TypeFormatFlags.NoTruncation | ts.TypeFormatFlags.WriteArrowStyleSignature
)
} catch {
return '(unknown)'
}
}
const heritage = declaration.heritageClauses
?.map((clause) => clause.getText(sourceFile))
.filter(Boolean)
.join(' ')
if (ts.isClassDeclaration(declaration)) {
const abstractPrefix = declaration.modifiers?.some(
(modifier) => modifier.kind === ts.SyntaxKind.AbstractKeyword
)
? 'abstract '
: ''
return `${abstractPrefix}class ${declaration.name?.text ?? '(anonymous)'}${
heritage ? ` ${heritage}` : ''
}`
}
return `interface ${declaration.name.text}${heritage ? ` ${heritage}` : ''}`
}
function getMemberSignature(
member: ts.ClassElement | ts.TypeElement,
sourceFile: ts.SourceFile,
checker: ts.TypeChecker
): string {
let signature = '(unknown)'
try {
if (
(ts.isPropertyDeclaration(member) ||
ts.isPropertySignature(member) ||
ts.isMethodDeclaration(member) ||
ts.isMethodSignature(member)) &&
member.type
) {
signature = member.type.getText(sourceFile)
} else {
signature = checker.typeToString(
checker.getTypeAtLocation(member),
member,
ts.TypeFormatFlags.NoTruncation | ts.TypeFormatFlags.WriteArrowStyleSignature
)
}
} catch {
// keep fallback
}
return signature
}
function extractTypeMembers(
declaration: ts.ClassDeclaration | ts.InterfaceDeclaration,
context: DeclarationContext
): ExtractedTypeMember[] {
const sourceFile = getDeclarationSourceFile(declaration)
const members: ExtractedTypeMember[] = []
for (const member of declaration.members) {
if (isExcludedComment(member, sourceFile)) continue
if (ts.isClassDeclaration(declaration)) {
const modifiers = ts.canHaveModifiers(member) ? ts.getModifiers(member) : undefined
if (
modifiers?.some(
(modifier) =>
modifier.kind === ts.SyntaxKind.PrivateKeyword ||
modifier.kind === ts.SyntaxKind.ProtectedKeyword
)
) {
continue
}
}
if (ts.isConstructorDeclaration(member)) continue
const name = 'name' in member ? getPropertyName(member.name) : undefined
if (!name || name.startsWith('_')) continue
let kind: ExtractedTypeMember['kind']
if (ts.isMethodDeclaration(member) || ts.isMethodSignature(member)) {
kind = 'method'
} else if (ts.isGetAccessorDeclaration(member) || ts.isGetAccessor(member)) {
kind = 'getter'
} else if (ts.isPropertyDeclaration(member) || ts.isPropertySignature(member)) {
kind = 'property'
} else {
continue
}
const jsdoc = extractJsDoc(member, sourceFile)
if (jsdoc.description === '__EXCLUDED__') continue
members.push({
name,
kind,
signature: getMemberSignature(member, sourceFile, context.checker),
description: jsdoc.description,
params: jsdoc.params,
examples: jsdoc.examples,
optional: 'questionToken' in member && !!member.questionToken,
static:
ts.isClassDeclaration(declaration) &&
(ts.canHaveModifiers(member) ? ts.getModifiers(member) : undefined)?.some(
(modifier) => modifier.kind === ts.SyntaxKind.StaticKeyword
) === true,
})
}
return members
}
function extractNamedType(
name: string,
context: DeclarationContext,
visited = new Set<string>()
): ExtractedNamedType | undefined {
if (visited.has(name)) return undefined
const declaration = context.declarations.get(name)
if (!declaration) return undefined
visited.add(name)
const sourceFile = getDeclarationSourceFile(declaration)
const result: ExtractedNamedType = {
name,
kind: getDeclarationKind(declaration),
signature: getDeclarationSignature(declaration, sourceFile, context.checker),
description: extractJsDoc(declaration, sourceFile).description,
params: extractJsDoc(declaration, sourceFile).params,
examples: extractJsDoc(declaration, sourceFile).examples,
}
if (ts.isClassDeclaration(declaration) || ts.isInterfaceDeclaration(declaration)) {
result.members = extractTypeMembers(declaration, context)
return result
}
if (ts.isFunctionDeclaration(declaration) || ts.isVariableDeclaration(declaration)) {
return result
}
result.aliasedTo = declaration.type.getText(sourceFile)
if (ts.isTypeReferenceNode(declaration.type)) {
const baseTypeName = declaration.type.typeName.getText(sourceFile)
if (baseTypeName !== name) {
result.resolvedType = extractNamedType(baseTypeName, context, visited)
}
const relatedTypeNames = (declaration.type.typeArguments ?? [])
.filter((arg): arg is ts.TypeReferenceNode => ts.isTypeReferenceNode(arg))
.map((arg) => arg.getText(sourceFile))
.filter((typeName) => {
if (typeName === baseTypeName) return false
const relatedDeclaration = context.declarations.get(typeName)
return !!relatedDeclaration && ts.isInterfaceDeclaration(relatedDeclaration)
})
const relatedTypes = relatedTypeNames
.map((typeName) => extractNamedType(typeName, context, visited))
.filter((type): type is ExtractedNamedType => type !== undefined)
if (relatedTypes.length > 0) {
result.relatedTypes = relatedTypes
}
}
return result
}
function getSymbolDeclaration(
symbol: ts.Symbol | undefined,
checker: ts.TypeChecker
): NamedDeclaration | undefined {
if (!symbol) return undefined
const resolvedSymbol =
symbol.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol
const declaration = resolvedSymbol.declarations?.find(
(declaration): declaration is NamedDeclaration =>
ts.isClassDeclaration(declaration) ||
ts.isInterfaceDeclaration(declaration) ||
ts.isTypeAliasDeclaration(declaration) ||
ts.isFunctionDeclaration(declaration) ||
ts.isVariableDeclaration(declaration)
)
return declaration
}
function extractNamedTypeFromDeclaration(
declaration: NamedDeclaration,
context: DeclarationContext
): ExtractedNamedType | undefined {
const name = ts.isVariableDeclaration(declaration)
? ts.isIdentifier(declaration.name)
? declaration.name.text
: undefined
: declaration.name?.text
if (!name) return undefined
if (!context.declarations.has(name)) {
context.declarations.set(name, declaration)
}
return extractNamedType(name, context)
}
function findNode<T extends ts.Node>(
root: ts.Node,
predicate: (node: ts.Node) => node is T
): T | undefined {
let result: T | undefined
const visit = (node: ts.Node) => {
if (result) return
if (predicate(node)) {
result = node
return
}
ts.forEachChild(node, visit)
}
visit(root)
return result
}
// --- Extract exec helpers from exec-helpers.ts ---
function extractExecHelpers(): ExtractedExecSection {
const context = createDeclarationContext([
execHelpersPath,
editorDtsPath,
storeDtsPath,
tlschemaDtsPath,
])
const sourceFile = context.sourceFiles.get(execHelpersPath)
if (!sourceFile) {
throw new Error(`Could not load source file: ${execHelpersPath}`)
}
// Find the helpers object inside createExecHelpers function
const helpersDeclaration = findNode(
sourceFile,
(node): node is ts.VariableDeclaration =>
ts.isVariableDeclaration(node) &&
ts.isIdentifier(node.name) &&
node.name.text === 'helpers' &&
!!node.initializer &&
ts.isObjectLiteralExpression(node.initializer)
)
if (
!helpersDeclaration ||
!helpersDeclaration.initializer ||
!ts.isObjectLiteralExpression(helpersDeclaration.initializer)
) {
throw new Error('Could not find helpers object in exec-helpers.ts')
}
// Collect tldraw imports
const tldrawImports = new Map<string, string>()
for (const statement of sourceFile.statements) {
if (!ts.isImportDeclaration(statement)) continue
if (
!ts.isStringLiteral(statement.moduleSpecifier) ||
statement.moduleSpecifier.text !== 'tldraw'
)
continue
if (
!statement.importClause?.namedBindings ||
!ts.isNamedImports(statement.importClause.namedBindings)
) {
continue
}
for (const element of statement.importClause.namedBindings.elements) {
const localName = element.name.text
const importedName = element.propertyName?.text ?? localName
tldrawImports.set(localName, importedName)
}
}
const helpers: ExtractedExecHelper[] = []
for (const property of helpersDeclaration.initializer.properties) {
if (!ts.isPropertyAssignment(property) && !ts.isShorthandPropertyAssignment(property)) continue
const helperName = getPropertyName(property.name)
if (!helperName) continue
const initializer = ts.isPropertyAssignment(property) ? property.initializer : property.name
let typeInfo: ExtractedNamedType | undefined
let source: ExtractedExecHelper['source'] = 'local'
let origin = helperName
if (ts.isIdentifier(initializer)) {
const importedName = tldrawImports.get(initializer.text)
if (importedName) {
source = 'tldraw'
origin = importedName
typeInfo = extractNamedType(importedName, context)
}
if (!typeInfo) {
const symbol = context.checker.getSymbolAtLocation(initializer)
const declaration = getSymbolDeclaration(symbol, context.checker)
typeInfo = declaration ? extractNamedTypeFromDeclaration(declaration, context) : undefined
if (declaration && declaration.getSourceFile().fileName.includes('/packages/')) {
source = 'tldraw'
origin = ts.isVariableDeclaration(declaration)
? ts.isIdentifier(declaration.name)
? declaration.name.text
: 'tldraw'
: (declaration.name?.text ?? 'tldraw')
}
}
} else if (ts.isCallExpression(initializer) && ts.isIdentifier(initializer.expression)) {
// Factory function pattern: someFn(editor) — resolve the inner return type
const factoryName = initializer.expression.text
const declaration = context.declarations.get(factoryName)
if (declaration && ts.isFunctionDeclaration(declaration)) {
const returnStatement = findNode(
declaration,
(node): node is ts.ReturnStatement =>
ts.isReturnStatement(node) && !!node.expression && ts.isArrowFunction(node.expression)
)
if (returnStatement?.expression && ts.isArrowFunction(returnStatement.expression)) {
const returnJsDoc = extractJsDoc(returnStatement, sourceFile)
const declarationJsDoc = extractJsDoc(declaration, sourceFile)
typeInfo = {
name: helperName,
kind: 'function',
signature: context.checker.typeToString(
context.checker.getTypeAtLocation(returnStatement.expression),
returnStatement.expression,
ts.TypeFormatFlags.NoTruncation | ts.TypeFormatFlags.WriteArrowStyleSignature
),
description: returnJsDoc.description || declarationJsDoc.description,
params: returnJsDoc.params.length > 0 ? returnJsDoc.params : declarationJsDoc.params,
examples:
returnJsDoc.examples.length > 0 ? returnJsDoc.examples : declarationJsDoc.examples,
}
}
}
source = 'local'
origin = helperName
}
helpers.push({
name: helperName,
source,
origin,
signature: typeInfo?.signature ?? '(unknown)',
description: typeInfo?.description ?? '',
params: typeInfo?.params ?? [],
examples: typeInfo?.examples ?? [],
typeInfo,
})
}
return {
helperCount: helpers.length,
helpers,
}
}
// --- Extract Editor members ---
function extract(): ExtractedMember[] {
const program = ts.createProgram([editorDtsPath, storeDtsPath, tlschemaDtsPath], {
target: ts.ScriptTarget.ES2020,
moduleResolution: ts.ModuleResolutionKind.Node10,
})
const checker = program.getTypeChecker()
const sourceFile = program.getSourceFile(editorDtsPath)
if (!sourceFile) {
throw new Error(`Could not load source file: ${editorDtsPath}`)
}
let editorClass: ts.ClassDeclaration | undefined
ts.forEachChild(sourceFile, (node) => {
if (ts.isClassDeclaration(node) && node.name?.text === 'Editor') {
editorClass = node
}
})
if (!editorClass) {
throw new Error('Could not find Editor class in .d.ts file')
}
const members: ExtractedMember[] = []
for (const member of editorClass.members) {
const modifiers = ts.canHaveModifiers(member) ? ts.getModifiers(member) : undefined
if (
modifiers?.some(
(m) => m.kind === ts.SyntaxKind.PrivateKeyword || m.kind === ts.SyntaxKind.ProtectedKeyword
)
) {
continue
}
if (isExcludedComment(member, sourceFile)) continue
const name = member.name && ts.isIdentifier(member.name) ? member.name.text : undefined
if (!name) continue
if (name.startsWith('_')) continue
if (ts.isConstructorDeclaration(member)) continue
let kind: 'method' | 'property' | 'getter'
if (ts.isMethodDeclaration(member) || ts.isMethodSignature(member)) {
kind = 'method'
} else if (ts.isGetAccessorDeclaration(member) || ts.isGetAccessor(member)) {
kind = 'getter'
} else if (ts.isPropertyDeclaration(member) || ts.isPropertySignature(member)) {
kind = 'property'
} else {
continue
}
const type = checker.getTypeAtLocation(member)
let signature: string
try {
signature = checker.typeToString(
type,
member,
ts.TypeFormatFlags.NoTruncation | ts.TypeFormatFlags.WriteArrowStyleSignature
)
} catch {
signature = '(unknown)'
}
const jsdoc = extractJsDoc(member, sourceFile)
if (jsdoc.description === '__EXCLUDED__') continue
members.push({
name,
kind,
signature,
description: jsdoc.description,
params: jsdoc.params,
examples: jsdoc.examples,
category: categorize(name),
})
}
return members
}
// --- Extract focused shape types from format.ts ---
const FOCUSED_SHAPE_INTERFACES = [
'FocusedGeoShape',
'FocusedTextShape',
'FocusedArrowShape',
'FocusedLineShape',
'FocusedNoteShape',
'FocusedDrawShape',
]
function toPascalCase(value: string) {
return value
.split(/[^a-zA-Z0-9]+/)
.filter(Boolean)
.map((part) => part[0].toUpperCase() + part.slice(1))
.join('')
}
function extractFocusedShapeTypes(): ExtractedTypesSection {
const context = createDeclarationContext([formatTsPath, editorDtsPath, tlschemaDtsPath])
const sourceFile = context.sourceFiles.get(formatTsPath)
if (!sourceFile) {
throw new Error(`Could not load source file: ${formatTsPath}`)
}
const allShapeTypes: string[] = []
const shapes: ExtractedShapeType[] = []
for (const ifaceName of FOCUSED_SHAPE_INTERFACES) {
const declaration = context.declarations.get(ifaceName)
if (!declaration || !ts.isInterfaceDeclaration(declaration)) {
console.error(`Warning: could not find interface ${ifaceName} in format.ts`)
continue
}
const ifaceSourceFile = declaration.getSourceFile()
const jsdoc = extractJsDoc(declaration, ifaceSourceFile)
const props: ExtractedTypeProperty[] = []
let shapeType = ''
const unionShapeTypes: string[] = []
for (const member of declaration.members) {
if (!ts.isPropertySignature(member) && !ts.isPropertyDeclaration(member)) continue
const propName = getPropertyName(member.name)
if (!propName) continue
let signature = '(unknown)'
try {
const memberType = context.checker.getTypeAtLocation(member)
signature = context.checker.typeToString(
memberType,
member,
ts.TypeFormatFlags.NoTruncation | ts.TypeFormatFlags.InTypeAlias
)
} catch {
if (member.type) signature = member.type.getText(ifaceSourceFile)
}
const propJsdoc = extractJsDoc(member, ifaceSourceFile)
if (propName === '_type') {
const memberType = context.checker.getTypeAtLocation(member)
if (memberType.isStringLiteral()) {
shapeType = memberType.value
} else if (memberType.isUnion()) {
for (const t of memberType.types) {
if (t.isStringLiteral()) {
allShapeTypes.push(t.value)
unionShapeTypes.push(t.value)
}
}
}
}
props.push({
name: propName,
signature,
description: propJsdoc.description,
optional: !!member.questionToken,
})
}
if (shapeType && shapeType !== 'geo') {
allShapeTypes.push(shapeType)
}
const displayName = ifaceName.replace(/^Focused/, '')
const propNames = props.map((p) => p.name).join(', ')
const signature = `{ ${propNames} }`
if (unionShapeTypes.length > 0) {
for (const concreteShapeType of unionShapeTypes) {
const concreteName = `${toPascalCase(concreteShapeType)}Shape`
shapes.push({
name: concreteName,
shapeType: concreteShapeType,
signature,
description: jsdoc.description,
propsType: `${concreteName}Props`,
propsDescription: jsdoc.description,
props: props.map((prop) =>
prop.name === '_type'
? {
...prop,
signature: `"${concreteShapeType}"`,
}
: prop
),
})
}
continue
}
shapes.push({
name: displayName,
shapeType,
signature,
description: jsdoc.description,
propsType: `${displayName}Props`,
propsDescription: jsdoc.description,
props,
})
}
return {
shapeTypes: allShapeTypes,
shapes,
}
}
// --- Generate METHOD_MAP ---
type ArgKind =
| 'id'
| 'id-or-shape'
| 'ids-or-shapes'
| 'spread-ids'
| 'shape-partial'
| 'shape-partials'
| 'update-partial'
| 'update-partials'
type RetKind =
| 'this'
| 'shape'
| 'shape-or-null'
| 'shapes'
| 'id'
| 'id-or-null'
| 'ids'
| 'id-set'
interface MethodMapEntry {
args: ArgKind[]
ret: RetKind
}
function generateMethodMap(
editorClass: ts.ClassDeclaration,
context: DeclarationContext
): Record<string, MethodMapEntry> {
const map: Record<string, MethodMapEntry> = {}
for (const member of editorClass.members) {
const modifiers = ts.canHaveModifiers(member) ? ts.getModifiers(member) : undefined
if (
modifiers?.some(
(m) => m.kind === ts.SyntaxKind.PrivateKeyword || m.kind === ts.SyntaxKind.ProtectedKeyword
)
)
continue
const name = member.name && ts.isIdentifier(member.name) ? member.name.text : undefined
if (!name || name.startsWith('_')) continue
const memberType = context.checker.getTypeAtLocation(member)
const signatures = memberType.getCallSignatures()
if (signatures.length === 0) continue
const args: ArgKind[] = []
let ret: RetKind | null = null
for (const sig of signatures) {
for (let i = 0; i < sig.parameters.length; i++) {
const param = sig.parameters[i]
const paramType = context.checker.getTypeOfSymbolAtLocation(param, member)
const paramStr = context.checker.typeToString(
paramType,
member,
ts.TypeFormatFlags.NoTruncation
)
const isRest = !!(
param.declarations?.[0] &&
ts.isParameter(param.declarations[0]) &&
param.declarations[0].dotDotDotToken
)
if (args[i]) continue
const argKind = classifyParamType(paramStr, isRest)
if (argKind) {
while (args.length < i) args.push('id')
args[i] = argKind
}
}
if (!ret) {
const retType = sig.getReturnType()
const retStr = context.checker.typeToString(
retType,
member,
ts.TypeFormatFlags.NoTruncation
)
ret = classifyReturnType(retType, retStr, context.checker, member)
}
}
if (args.length > 0 || ret) {
map[name] = { args, ret: ret ?? 'this' }
}
}
return map
}
function classifyParamType(typeStr: string, isRest: boolean): ArgKind | null {
if (typeStr.includes('TLCreateShapePartial')) {
return typeStr.includes('[]') ? 'shape-partials' : 'shape-partial'
}
if (typeStr.includes('TLShapePartial')) {
if (typeStr.includes('[]') || typeStr.includes('Array')) return 'update-partials'
return 'update-partial'
}
if (isRest && (typeStr.includes('TLShapeId') || typeStr.includes('TLShape'))) {
return 'spread-ids'
}
if (
(typeStr.includes('TLShape[]') || typeStr.includes('TLShapeId[]')) &&
typeStr.includes('[]')
) {
return 'ids-or-shapes'
}
if (
typeStr.includes('TLShape') ||
typeStr.includes('TLShapeId') ||
typeStr.includes('TLParentId')
) {
return 'id-or-shape'
}
return null
}
function classifyReturnType(
retType: ts.Type,
typeStr: string,
checker: ts.TypeChecker,
member: ts.ClassElement
): RetKind | null {
if (typeStr === 'this') return 'this'
if (typeStr.includes('Set<') && typeStr.includes('TLShapeId')) return 'id-set'
let resolvedStr = typeStr
if (retType.isTypeParameter()) {
const constraint = retType.getConstraint()
if (constraint) {
resolvedStr = checker.typeToString(constraint, member, ts.TypeFormatFlags.NoTruncation)
}
}
if (retType.isUnion()) {
let hasShapeType = false
let hasShapeIdType = false
let hasNullish = false
for (const t of retType.types) {
let resolved = t
if (t.isTypeParameter()) {
const c = t.getConstraint()
if (c) resolved = c
}
const s = checker.typeToString(resolved)
if (s === 'undefined' || s === 'null') hasNullish = true
else if (s.includes('TLShapeId')) hasShapeIdType = true
else if (s.includes('Shape')) hasShapeType = true
}
if (hasShapeType && !hasShapeIdType) return 'shape-or-null'
if (hasShapeIdType && hasNullish) return 'id-or-null'
}
if (
resolvedStr.includes('TLShape') &&
!resolvedStr.includes('TLShapeId') &&
(resolvedStr.includes('[]') || typeStr.includes('[]'))
)
return 'shapes'
if (resolvedStr.includes('TLShapeId') && resolvedStr.includes('[]')) return 'ids'
if (resolvedStr.includes('TLShape') && !resolvedStr.includes('TLShapeId')) {
if (/\bTLShape\b/.test(resolvedStr)) return 'shape-or-null'
}
if (
resolvedStr.includes('TLShapeId') &&
(resolvedStr.includes('null') || resolvedStr.includes('undefined'))
) {
return 'id-or-null'
}
return null
}
function writeMethodMap(map: Record<string, MethodMapEntry>) {
fs.writeFileSync(methodMapOutPath, JSON.stringify(map, null, 2))
}
// --- Post-processing: signature rewrites + example conversion ---
/**
* Read a Record<string, string> from an object literal in a source file,
* unwrapping `as const` if present.
*/
function readStringRecord(sourceFile: ts.SourceFile, varName: string): Record<string, string> {
const entries: Record<string, string> = {}
ts.forEachChild(sourceFile, (node) => {
if (!ts.isVariableStatement(node)) return
for (const decl of node.declarationList.declarations) {
if (!ts.isIdentifier(decl.name) || decl.name.text !== varName) continue
let init = decl.initializer
if (init && ts.isAsExpression(init)) init = init.expression
if (!init || !ts.isObjectLiteralExpression(init)) continue
for (const prop of init.properties) {
if (!ts.isPropertyAssignment(prop)) continue
const key = getPropertyName(prop.name)
if (!key) continue
entries[key] = prop.initializer.getText(sourceFile).replace(/['"]/g, '')
}
}
})
return entries
}
let GEO_TO_FOCUSED: Record<string, string> = {}
let TLDRAW_TO_FOCUSED_FILL: Record<string, string> = {}
const INTERNAL_PROPS = new Set([
'typeName',
'rotation',
'index',
'parentId',
'opacity',
'isLocked',
'meta',
'dash',
'size',
'font',
'scale',
'growY',
'labelColor',
'url',
'verticalAlign',
'autoSize',
'fontSizeAdjustment',
'elbowMidPoint',
'labelPosition',
'arrowheadEnd',
'arrowheadStart',
'spline',
])
function convertOldFormatExample(example: string): string {
if (!example.includes('props:') && !(example.includes('type:') && !example.includes('_type'))) {
return example
}
try {
const wrapped = `const __ex = ${example.includes(';') ? `(() => { ${example} })()` : example}`
const sf = ts.createSourceFile(
'example.ts',
wrapped,
ts.ScriptTarget.Latest,
false,
ts.ScriptKind.TS
)
let result = example
const replacements: Array<{ start: number; end: number; text: string }> = []
function visitNode(node: ts.Node) {
if (ts.isObjectLiteralExpression(node)) {
const converted = tryConvertShapeObject(node, sf)
if (converted) {
const prefixLen = wrapped.indexOf(example)
const start = node.getStart(sf) - prefixLen
const end = node.getEnd() - prefixLen
if (start >= 0 && end <= example.length) {
replacements.push({ start, end, text: converted })
}
}
}
ts.forEachChild(node, visitNode)
}
ts.forEachChild(sf, visitNode)
replacements.sort((a, b) => b.start - a.start)
for (const rep of replacements) {
result = result.slice(0, rep.start) + rep.text + result.slice(rep.end)
}
return result
} catch {
return example
}
}
function tryConvertShapeObject(node: ts.ObjectLiteralExpression, sf: ts.SourceFile): string | null {
const props = new Map<string, string>()
let nestedProps: Map<string, string> | null = null
let hasSpread = false
for (const prop of node.properties) {
if (ts.isSpreadAssignment(prop)) {
hasSpread = true
continue
}
if (!ts.isPropertyAssignment(prop)) continue
const name = getPropertyName(prop.name)
if (!name) continue
if (name === 'props' && ts.isObjectLiteralExpression(prop.initializer)) {
nestedProps = new Map()
for (const inner of prop.initializer.properties) {
if (!ts.isPropertyAssignment(inner)) continue
const innerName = getPropertyName(inner.name)
if (innerName) nestedProps.set(innerName, inner.initializer.getText(sf))
}
} else {
props.set(name, prop.initializer.getText(sf))
}
}
const typeVal = props.get('type')
if (!typeVal) return null
const typeStr = typeVal.replace(/['"]/g, '')
const shapeTypes = new Set(['geo', 'text', 'arrow', 'line', 'note', 'draw'])
if (!shapeTypes.has(typeStr)) return null
if (hasSpread) return null
const out: Array<[string, string]> = []
if (typeStr === 'geo' && nestedProps?.has('geo')) {
const geoVal = nestedProps.get('geo')!.replace(/['"]/g, '')
out.push(['_type', `'${GEO_TO_FOCUSED[geoVal] ?? geoVal}'`])
nestedProps.delete('geo')
} else {
out.push(['_type', `'${typeStr}'`])
}
if (props.has('id')) {
let idVal = props.get('id')!
const match = idVal.match(/createShapeId\(\s*['"]([^'"]*)['"]\s*\)/)
if (match) idVal = `'${match[1]}'`
out.push(['shapeId', idVal])
}
for (const key of ['x', 'y']) {
if (props.has(key)) out.push([key, props.get(key)!])
}
if (nestedProps) {
for (const [key, val] of nestedProps) {
if (INTERNAL_PROPS.has(key)) continue
if (key === 'richText') {
const rtMatch = val.match(/toRichText\(\s*(['"].*?['"])\s*\)/)
if (rtMatch) out.push(['text', rtMatch[1]])
continue
}
if (key === 'fill') {
const fillStr = val.replace(/['"]/g, '')
out.push(['fill', `'${TLDRAW_TO_FOCUSED_FILL[fillStr] ?? fillStr}'`])
continue
}
if (key === 'color') {
out.push(['color', val])
continue
}
out.push([key, val])
}
}
const handled = new Set(['type', 'id', 'x', 'y', 'props'])
for (const [key, val] of props) {
if (handled.has(key) || INTERNAL_PROPS.has(key)) continue
out.push([key, val])
}
return '{ ' + out.map(([k, v]) => `${k}: ${v}`).join(', ') + ' }'
}
function rewriteSignature(sig: string): string {
return sig
.replace(/TLCreateShapePartial(<[^>]*>)?/g, 'TLShape')
.replace(/TLShapePartial(<[^>]*>)?/g, 'Partial<TLShape>')
.replace(/TLShapeId/g, 'string')
.replace(/TLParentId/g, 'string')
}
function postProcessMembers(members: ExtractedMember[]): ExtractedMember[] {
return members.map((m) => ({
...m,
signature: rewriteSignature(m.signature),
examples: m.examples.map(convertOldFormatExample),
}))
}
// --- Main ---
function main() {
console.error(
`Extracting Editor API from:\n ${editorDtsPath}\n ${storeDtsPath}\n ${tlschemaDtsPath}\n ${formatTsPath}\n ${execHelpersPath}`
)
fs.mkdirSync(distDir, { recursive: true })
// Read conversion maps from format.ts via AST
const formatSf = ts.createSourceFile(
formatTsPath,
fs.readFileSync(formatTsPath, 'utf-8'),
ts.ScriptTarget.Latest
)
GEO_TO_FOCUSED = readStringRecord(formatSf, 'GEO_TO_FOCUSED_TYPES')
TLDRAW_TO_FOCUSED_FILL = readStringRecord(formatSf, 'SHAPE_TO_FOCUSED_FILLS')
const members = extract()
const types = extractFocusedShapeTypes()
const exec = extractExecHelpers()
const categories = [...new Set(members.map((m) => m.category))].sort()
// Generate METHOD_MAP
const editorContext = createDeclarationContext([editorDtsPath, storeDtsPath, tlschemaDtsPath])
const editorSourceFile = editorContext.sourceFiles.get(editorDtsPath)
let editorClass: ts.ClassDeclaration | undefined
if (editorSourceFile) {
ts.forEachChild(editorSourceFile, (node) => {
if (ts.isClassDeclaration(node) && node.name?.text === 'Editor') {
editorClass = node
}
})
}
if (editorClass) {
const methodMap = generateMethodMap(editorClass, editorContext)
writeMethodMap(methodMap)
console.error(
`Wrote ${Object.keys(methodMap).length} method map entries to dist/method-map.json`
)
}
const output = {
extractedAt: new Date().toISOString(),
memberCount: members.length,
categories,
members: postProcessMembers(members),
types,
helperCount: exec.helperCount,
helpers: exec.helpers,
}
fs.writeFileSync(outPath, JSON.stringify(output, null, 2))
console.error(
`Wrote ${members.length} members (${categories.length} categories), ${types.shapes.length} shape types, and ${exec.helperCount} exec helpers to dist/editor-api.json`
)
}
main()