Files
supabase/apps/docs/scripts/process-tsdoc.ts
T

301 lines
11 KiB
TypeScript

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<number, any> = new Map()): Map<number, any> {
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<number, any>,
seen = new Set<number>(),
typeParamMap = new Map<number, any>()
): 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> — 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<number, any>): 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<string, any[]>()
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`
)
}