mirror of
https://github.com/supabase/supabase.git
synced 2026-10-10 11:55:05 +03:00
301 lines
11 KiB
TypeScript
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`
|
|
)
|
|
}
|