import { readFileSync, writeFileSync } from 'fs' import { join, dirname, resolve } from 'path' import { fileURLToPath } from 'url' const __dirname = dirname(fileURLToPath(import.meta.url)) type ContentItem = { kind: string; text: string } type BlockTag = { tag: string; name?: string; content?: ContentItem[] } function contentToMd(items: ContentItem[] = []): string { const raw = items .map((c) => c.text) .join('') .trim() // Normalize line breaks: \n\n (paragraph break) → \n, lone \n → nothing. // TypeDoc summaries often contain single newlines as soft wraps with no // semantic meaning, and double newlines as intended paragraph separators. // Protect paragraph breaks first so the lone-newline pass doesn't touch them. return raw .replace(/\n\n+/g, '\x00') // stash paragraph breaks .replace(/\n/g, ' ') // collapse lone soft-wrap newlines into a space .replace(/\x00/g, '\n') // restore paragraph breaks as a single \n .trim() } function extractFence(text: string): string { const m = text.match(/^```[^\n]*\n([\s\S]*?)\n?```$/) return m ? m[1].trim() : text.trim() } // node kinds from typedoc const KIND_INTERFACE = 256 const KIND_CLASS = 128 const KIND_TYPE_ALIAS = 2097152 /** Walk the full tree and index every node by its numeric id. */ function buildTargetMap(root: any, map: Map = new Map()): Map { if (root?.id) map.set(root.id, root) for (const child of root?.children ?? []) buildTargetMap(child, map) return map } function serializeType( t: any, targetMap: Map, seen = new Set(), typeParamMap = new Map() ): any { if (!t) return null const st = (x: any) => serializeType(x, targetMap, seen, typeParamMap) switch (t.type) { case 'intrinsic': return t.name case 'literal': return { kind: 'literal', value: t.value } case 'union': return { kind: 'union', types: t.types.map(st) } case 'intersection': return { kind: 'intersection', types: t.types.map(st) } case 'array': return { kind: 'array', elementType: st(t.elementType) } case 'tuple': return { kind: 'tuple', elements: (t.elements ?? []).map(st) } case 'reference': { // Substitute generic type parameters with their concrete arguments if (t.refersToTypeParameter) { if (typeof t.target === 'number' && typeParamMap.has(t.target)) { return serializeType(typeParamMap.get(t.target), targetMap, seen, typeParamMap) } // Unresolved type parameter (e.g. invoke — T is unknown at doc time) return { kind: 'typeParam', name: t.name } } const targetId: number | undefined = t.target ?? t.id if (targetId && !seen.has(targetId)) { const node = targetMap.get(targetId) if (node) { const nextSeen = new Set(seen).add(targetId) // Interface or class: inline as object with named properties if (node.kind === KIND_INTERFACE || node.kind === KIND_CLASS) { const serialize = (x: any) => serializeType(x, targetMap, nextSeen, typeParamMap) return { kind: 'object', name: node.name, properties: (node.children ?? []).map((child: any) => ({ name: child.name, optional: child.flags?.isOptional ?? false, description: child.comment?.summary?.length ? contentToMd(child.comment.summary) : undefined, type: serialize(child.type), })), } } // Type alias: build a child typeParamMap by mapping the alias's type parameters // to the concrete arguments supplied at the call site, then inline. if (node.kind === KIND_TYPE_ALIAS && node.type) { let childMap = typeParamMap if (t.typeArguments?.length && node.typeParameters?.length) { childMap = new Map(typeParamMap) for (let i = 0; i < node.typeParameters.length; i++) { childMap.set(node.typeParameters[i].id, t.typeArguments[i]) } } const result = serializeType(node.type, targetMap, nextSeen, childMap) // Preserve the alias name on inlined objects so the display can use it if (result && typeof result === 'object' && result.kind === 'object' && !result.name) { result.name = node.name } return result } } } // Fallback: store as named reference const r: any = { kind: 'reference', name: t.name } if (t.typeArguments?.length) r.typeArguments = t.typeArguments.map(st) return r } case 'reflection': { return { kind: 'object', properties: (t.declaration?.children ?? []).map((child: any) => ({ name: child.name, optional: child.flags?.isOptional ?? false, ...(child.comment?.summary?.length ? { description: contentToMd(child.comment.summary) } : {}), type: st(child.type), })), } } case 'templateLiteral': return { kind: 'templateLiteral' } case 'indexedAccess': return { kind: 'indexedAccess', objectType: st(t.objectType), indexType: st(t.indexIndex) } default: return { kind: t.type ?? 'unknown' } } } function serializeParam(p: any, targetMap: Map): any { const out: any = { name: p.name } if (p.flags?.isOptional) out.optional = true if (p.comment?.summary?.length) out.description = contentToMd(p.comment.summary) out.type = serializeType(p.type, targetMap) return out } function parseExamples(blockTags: BlockTag[]): any[] { const examples = blockTags.filter((t) => t.tag === '@example') const sqls = blockTags.filter((t) => t.tag === '@exampleSql') const responses = blockTags.filter((t) => t.tag === '@exampleResponse') const descs = blockTags.filter((t) => t.tag === '@exampleDescription') function findByName(tags: BlockTag[], title: string): BlockTag | undefined { return tags.find((t) => { const first = t.content?.find((c) => c.kind === 'text')?.text?.trim() return first && (first === title || first.startsWith(title)) }) } return examples.map((ex, i) => { const title = ex.name && ex.name !== 'undefined' ? ex.name : `Example ${i + 1}` const codeBlock = ex.content?.find((c) => c.kind === 'code') const code = codeBlock ? extractFence(codeBlock.text) : '' const sqlTag = findByName(sqls, title) ?? sqls[i] const responseTag = findByName(responses, title) ?? responses[i] const descTag = findByName(descs, title) ?? descs[i] const sqlBlock = sqlTag?.content?.findLast((c) => c.kind === 'code') const responseBlock = responseTag?.content?.findLast((c) => c.kind === 'code') const notes = descTag?.content ?.filter((c) => c.kind === 'text' || c.kind === 'code') .map((c) => { if (c.kind === 'text' && c.text.trimStart().startsWith(title)) { // Strip the title line prefix, keep anything that follows it return c.text.slice(c.text.indexOf(title) + title.length).replace(/^\s*\n/, '') } return c.text }) .filter((t) => t.trim()) .join('') .trim() const result: any = { title, code } if (sqlBlock) result.sql = extractFence(sqlBlock.text) if (responseBlock) result.response = extractFence(responseBlock.text) if (notes) result.notes = notes return result }) } // Collect all variant:declaration nodes that have signatures AND a comment (on the // declaration itself or on the first signature — storage methods store tags on sigs). function collectDeclarations(node: any): any[] { const out: any[] = [] if (node.variant === 'declaration' && node.signatures?.length) { const comment = node.comment ?? node.signatures[0]?.comment if (comment) { // Normalise: always expose comment at declaration level out.push({ ...node, comment }) } } for (const child of node.children ?? []) out.push(...collectDeclarations(child)) return out } const SOURCE_FILES = [ 'functions.json', 'gotrue.json', 'postgrest.json', 'realtime.json', 'storage.json', 'supabase.json', ] export function processSpec() { const specDir = join(__dirname, '../spec/enrichments/tsdoc_v2') const config: { ignoreDefinitions?: string[]; categoryOrder?: string[] } = (() => { try { return JSON.parse(readFileSync(join(specDir, 'config.json'), 'utf-8')) } catch { return {} } })() const ignoredNames = new Set(config.ignoreDefinitions ?? []) const categoryOrder = config.categoryOrder ?? [] const roots = SOURCE_FILES.map((f) => JSON.parse(readFileSync(join(specDir, f), 'utf-8'))) // Build a per-file targetMap so IDs from different packages don't collide const fileMaps = roots.map((root) => buildTargetMap(root)) // Pair every declaration with the targetMap of the file it came from const declarations = roots.flatMap((root, i) => collectDeclarations(root).map((decl) => ({ decl, targetMap: fileMaps[i] })) ) const categoryMap = new Map() for (const { decl, targetMap } of declarations) { const blockTags: BlockTag[] = decl.comment?.blockTags ?? [] const categoryTag = blockTags.find((t) => t.tag === '@category') if (!categoryTag) continue // skip internal declarations with no category const category = (categoryTag.content?.[0]?.text ?? '').split('\n')[0].trim() if (!categoryMap.has(category)) categoryMap.set(category, []) const sig = decl.signatures[0] const remarkTags = blockTags.filter((t) => t.tag === '@remarks') const examples = parseExamples(blockTags) if (ignoredNames.has(decl.name)) continue const definition: any = { name: decl.name, description: contentToMd(decl.comment?.summary ?? []), ...(remarkTags.length ? { remarks: remarkTags.map((t) => contentToMd(t.content ?? [])) } : {}), parameters: (sig.parameters ?? []).map((p: any) => serializeParam(p, targetMap)), returnType: serializeType(sig.type, targetMap), ...(examples.length ? { examples } : {}), } categoryMap.get(category)!.push(definition) } const result = Array.from(categoryMap.entries()) .map(([category, definitions]) => ({ category, definitions })) .sort((a, b) => { const ai = categoryOrder.indexOf(a.category) const bi = categoryOrder.indexOf(b.category) // Both in order list: sort by position if (ai !== -1 && bi !== -1) return ai - bi // Only a is in list: a goes first if (ai !== -1) return -1 // Only b is in list: b goes first if (bi !== -1) return 1 // Neither in list: preserve insertion order return 0 }) console.log(result) return result } // Only write to disk when run directly const isMain = fileURLToPath(import.meta.url) === resolve(process.argv[1]) if (isMain) { const output = processSpec() writeFileSync( join(__dirname, '../spec/enrichments/tsdoc_v2/processed.json'), JSON.stringify(output, null, 2) ) console.log( `Done: ${output.length} categories, ${output.flatMap((c) => c.definitions).length} declarations` ) }