Files
supabase/apps/docs/scripts/build-reference-content.ts
Jeremias Menichelli f20cd22dc3 docs: improve SDK automation build step on docs (#46163)
# Second try of making a new better process for SDK automation

Instead of building a new pipeline. We will take the lessons learned
form round 1, plus the good design and improvement on DX quality for
drop-in file as a single step required from SDK team and produce almost
identical set of files as used right now to render using the current
pipeline.



<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* New reference-content pipeline producing per-library reference
artifacts and integrating into prebuilds, search ingestion, and
rendering (type-aware examples).

* **Documentation**
* Added comprehensive JavaScript SDK v2 reference content and partials
(Auth MFA, passkeys, admin, TypeScript support, filters, modifiers,
Installing, Initializing, Buckets, etc.).

* **Tests & CI**
* Added regression snapshot test and updated workflows to refresh
reference snapshots and ensure spec downloads.

* **Chores**
* Updated ignore rules, build scripts, Makefile targets, and package
lifecycle hooks.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Katerina Skroumpelou <mandarini@users.noreply.github.com>
Co-authored-by: Katerina Skroumpelou <sk.katherine@gmail.com>
2026-06-03 11:46:02 +03:00

697 lines
26 KiB
TypeScript

/**
* Build reference content files (bySlug.json, etc.) from TypeDoc spec output.
*
* Scans `spec/reference/[library]/[version]/*.json` (TypeDoc output) and writes
* `content/reference/[library]/[version]/{bySlug,flat,sections,functions,typeSpec}.json`.
*
* Library and version names are inferred from the directory layout. An optional
* `config.json` in each version directory may declare `excludeCategories` and
* `categoryOrder` to filter and order the output.
*
* Usage: pnpm tsx scripts/build-reference-content.ts
*/
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, extname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import matter from 'gray-matter'
import {
buildMap,
KIND_VARIABLE,
normalizeComment,
parseSignature,
parseType,
type MethodTypes,
type VariableTypes,
} from '../features/docs/Reference.typeSpec'
const __dirname = dirname(fileURLToPath(import.meta.url))
const DOCS_DIR = join(__dirname, '..')
const SPEC_DIR = join(DOCS_DIR, 'spec/reference')
const OUTPUT_DIR = join(DOCS_DIR, 'content/reference')
const REF_DOCS_DIR = join(DOCS_DIR, 'docs/ref')
type ContentPart = { kind: string; text?: string }
interface BlockTag {
tag: string
name?: string
content: ContentPart[]
}
interface Comment {
summary?: ContentPart[]
blockTags?: BlockTag[]
}
interface Param {
name: string
type: unknown
flags?: { isOptional?: boolean }
}
interface Declaration {
id?: number
name?: string
variant?: string
kind?: number
comment?: Comment
signatures?: Declaration[]
children?: Declaration[]
// signature-only / variable-only fields:
parameters?: Param[]
type?: unknown
flags?: { isOptional?: boolean; isConst?: boolean }
}
interface FunctionEntry {
name: string
category: string
subcategory: string | null
$ref: string
}
interface FunctionsEntry {
id: string
// Either a `$ref` (points at a typeSpec entry) OR rich inline content
// (description, examples, …) shovelled in from a .json partial body.
$ref?: string
[key: string]: unknown
}
/**
* Per-lib typeSpec.json shape. Keys are normalised `$ref`s; values mirror the
* legacy `MethodTypes` / `VariableTypes` so the renderer in
* `Reference.sections.tsx` can consume the new file without changes.
*/
interface TypeSpec {
methods: Record<string, MethodTypes>
variables: Record<string, VariableTypes>
}
interface BySlugFunction {
id: string
title: string
slug: string
product?: string
type: 'function'
isFunc?: false
}
interface BySlugCategory {
type: 'category'
title: string
}
interface BySlugMarkdown {
id: string
title: string
slug: string
type: 'markdown'
}
type BySlugEntry = BySlugFunction | BySlugCategory | BySlugMarkdown
/** A bySlug entry that may additionally carry nested children — used by sections.json. */
type SectionEntry = BySlugEntry & { items?: SectionEntry[] }
interface VersionConfig {
excludeCategories?: string[]
excludeDefinitions?: string[]
categoryOrder?: string[]
partialsOrder?: string[]
/**
* Customizes the navigation slug used as a prefix for a category or
* subcategory (matched on the literal `@category` / `@subcategory` text).
* - `string` → use that value as the prefix (e.g. `"Edge Functions": "functions"`
* turns `edge-functions-invoke` into `functions-invoke`).
* - `false` → drop the prefix entirely for child function slugs (e.g.
* `"Using modifiers": false` turns `using-modifiers-explain` into
* `explain`). The category/subcategory header itself still needs a slug,
* so its entry slug falls back to the slugified title.
* - missing → default to the slugified title.
*/
navigationPrefixes?: Record<string, string | false>
}
interface PartialEntry {
name: string
title: string
ref?: string
/**
* `kind` distinguishes how the partial contributes to the build:
* - `markdown`: an `.md`/`.mdx` partial. Rendered as a `type: 'markdown'`
* section (or `type: 'function'` when a frontmatter `ref` is present).
* - `function`: a `.json` partial whose body (description, examples, …)
* is appended to functions.json so the renderer can display it.
*/
kind: 'markdown' | 'function'
/** Raw parsed JSON body for `kind === 'function'` partials. */
body?: Record<string, unknown>
/** Raw frontmatter + body for `kind === 'markdown'` partials. */
mdxRaw?: string
}
/** Lowercases a string and collapses internal whitespace to hyphens for use as a URL slug. */
function slugifyTag(value: string): string {
return value.toLowerCase().trim().replace(/\s+/g, '-')
}
type NavigationPrefixes = Record<string, string | false> | undefined
/**
* The slug used for a category or subcategory **entry** (the header that
* appears in the navigation). `navigationPrefixes[title] = string` overrides
* the default; `false` and `undefined` both fall back to the slugified title
* because the entry still needs a stable, navigable slug.
*/
function entrySlug(title: string, navigationPrefixes: NavigationPrefixes): string {
const override = navigationPrefixes?.[title]
return typeof override === 'string' ? override : slugifyTag(title)
}
/**
* The prefix segment used in front of a function's name. `false` means "no
* prefix" — the function slug becomes just its lowercased name. `string`
* overrides the default; `undefined` falls back to the slugified title.
*/
function functionPrefix(title: string, navigationPrefixes: NavigationPrefixes): string | null {
const override = navigationPrefixes?.[title]
if (override === false) return null
if (typeof override === 'string') return override
return slugifyTag(title)
}
/**
* Stably reorders `items` so entries whose key appears in `order` come first in
* that order; unranked items keep their original relative order. Returns `items`
* unchanged when `order` is empty or undefined.
*/
function reorder<T>(items: T[], order: string[] | undefined, key: (item: T) => string): T[] {
if (!order?.length) return items
const idx = new Map(order.map((n, i) => [n, i]))
return [...items].sort((a, b) => (idx.get(key(a)) ?? Infinity) - (idx.get(key(b)) ?? Infinity))
}
/**
* Builds a bySlug entry for a partial file:
* - `.json` partials always render as `type: 'function'` (their body feeds
* functions.json).
* - `.md`/`.mdx` partials with a frontmatter `ref` render as `type: 'function'`
* (linked to TypeDoc-derived code via the ref).
* - All other `.md`/`.mdx` partials render as plain `type: 'markdown'`.
*/
const partialEntry = (p: PartialEntry): BySlugMarkdown | BySlugFunction =>
p.kind === 'function' || p.ref
? { id: p.name, title: p.title, slug: p.name, type: 'function' }
: { id: p.name, title: p.title, slug: p.name, type: 'markdown' }
/** Builds a category-type bySlug entry from a category title. */
const categoryEntry = (title: string): BySlugCategory => ({ type: 'category', title })
/** Builds a subcategory bySlug entry — shaped like a function with `isFunc: false`. */
const subcategoryEntry = (slug: string, title: string, product: string): BySlugFunction => ({
id: slug,
isFunc: false,
title,
slug,
product,
type: 'function',
})
/**
* Builds a function bySlug entry. The slug is `${prefix}-${name}` where
* `prefix` comes from the function's nearest container — its `@subcategory`
* if present, otherwise its `@category`. `navigationPrefixes` in `config.json`
* can rename that prefix or drop it entirely (false). `id` always equals
* `slug` so the renderer's `fns.find(f => f.id === section.id)` resolves.
*
* `product` keeps the literal slugified category (independent of any
* navigation prefix) because the renderer uses it for feature filtering
* (e.g. hiding `auth` sections when the SDK Auth flag is disabled).
*/
const functionEntry = (
fn: FunctionEntry,
product: string,
navigationPrefixes: NavigationPrefixes
): BySlugFunction => {
const prefix = functionPrefix(fn.subcategory ?? fn.category, navigationPrefixes)
const nameLower = fn.name.toLowerCase()
const slug = prefix === null ? nameLower : `${prefix}-${nameLower}`
return { id: slug, title: fn.name, slug, product, type: 'function' }
}
/**
* Reads a named TSDoc block tag (e.g. `@category`) from a comment and returns
* its first line trimmed, or `null` if absent.
*/
function readBlockTag(comment: Comment | undefined, tagName: string): string | null {
if (!comment?.blockTags) return null
const tag = comment.blockTags.find((t) => t.tag === tagName)
if (!tag) return null
const text = tag.content
.map((c) => c.text ?? '')
.join('')
.split('\n')[0]
.trim()
return text || null
}
/**
* Reads a block tag from a declaration's comment, falling back to its signature
* comments. TypeDoc emits tags in either place depending on the source style.
*/
function readTagFromDeclOrSignature(decl: Declaration, tagName: string): string | null {
const fromDecl = readBlockTag(decl.comment, tagName)
if (fromDecl) return fromDecl
if (decl.signatures) {
for (const sig of decl.signatures) {
const fromSig = readBlockTag(sig.comment, tagName)
if (fromSig) return fromSig
}
}
return null
}
/** Strips redundant `.index.` segments and collapses consecutive dots in a ref. */
function normalizeRefPath(path: string): string {
return path.replace(/\.index(?=\.|$)/g, '').replace(/\.+/g, '.')
}
/**
* Recursively walks a TypeDoc declaration tree. Builds two outputs in one pass:
* - `functions`: declarations carrying an `@category` tag (with optional
* `@subcategory`) plus a constructed `$ref` of the form
* `<package>.<module…>.<class…>.<name>` (normalized to strip `.index.`).
* - `typeSpec`: separate `methods` and `variables` maps keyed by the same
* `$ref`. Methods carry every signature (first → primary, rest →
* `altSignatures`) with params, return type, and normalised comment
* (shortText, text, tags, examples). Variables (kind 32) carry their
* parsed type and `isConst` flag. Type-tree normalisation and comment
* extraction are delegated to the shared helpers in
* `~/features/docs/Reference.typeSpec` so the renderer can consume the
* output without any shape translation.
*
* Not filtered by category or `excludeDefinitions`, so partials that link
* to "hidden" methods (e.g. a constructor) can still resolve.
*
* Context is threaded down through container nodes:
* - kind 1 (project) sets the package name and resets the path.
* - kinds 2 (module), 4 (namespace), 128 (class), 256 (interface) all
* append their name to the path. Modules are required because some
* packages (storage-js, supabase-js) wrap each source file in a module
* like `packages/StorageFileApi`, and a class named `default` (TypeDoc's
* fallback for default-exported classes) is ambiguous without the module
* segment. Interfaces hold many of the auth admin APIs whose methods
* would otherwise lack a class segment in the ref.
*/
const PATH_CONTAINER_KINDS = new Set([2, 4, 128, 256])
function collectFunctions(
node: Declaration,
out: { functions: FunctionEntry[]; typeSpec: TypeSpec },
idMap: Map<number, any>,
ctx: { pkg: string | null; path: string[] } = { pkg: null, path: [] }
): void {
let nextCtx = ctx
if (node.kind === 1 && node.name) {
nextCtx = { pkg: node.name, path: [] }
} else if (node.kind && PATH_CONTAINER_KINDS.has(node.kind) && node.name) {
nextCtx = { ...ctx, path: [...ctx.path, node.name] }
}
if (ctx.pkg && node.name) {
const ref = normalizeRefPath([ctx.pkg, ...ctx.path, node.name].join('.'))
if (node.signatures?.length) {
const firstSig = node.signatures[0]
const { params, ret, comment: sigComment } = parseSignature(firstSig, idMap)
// Some overloaded methods carry shared JSDoc (e.g. @remarks, @example)
// on the declaration node rather than any individual signature. Merge
// node-level tags as a base so they aren't lost when the first
// signature only has a summary.
let comment = sigComment
if (node.comment) {
const nodeComment = normalizeComment(node.comment as any)
if (nodeComment) {
comment = { ...nodeComment, ...sigComment }
}
}
const methodEntry: MethodTypes = { name: ref, params, ret, comment }
if (node.signatures.length > 1) {
methodEntry.altSignatures = node.signatures.slice(1).map((sig) => {
const { params: altParams, ret: altRet } = parseSignature(sig, idMap)
return { params: altParams, ret: altRet }
}) as MethodTypes['altSignatures']
}
out.typeSpec.methods[ref] = methodEntry
} else if (node.kind === KIND_VARIABLE && node.type) {
const variableEntry: VariableTypes = {
name: ref,
type: parseType(node.type, idMap),
comment: node.comment ? normalizeComment(node.comment as any) : undefined,
isConst: node.flags?.isConst ?? false,
}
out.typeSpec.variables[ref] = variableEntry
}
}
if (ctx.pkg && node.name && node.variant === 'declaration') {
const category = readTagFromDeclOrSignature(node, '@category')
if (category) {
const subcategory = readTagFromDeclOrSignature(node, '@subcategory')
const $ref = normalizeRefPath([ctx.pkg, ...ctx.path, node.name].join('.'))
out.functions.push({ name: node.name, category, subcategory, $ref })
}
}
if (node.children) {
for (const child of node.children) {
collectFunctions(child, out, idMap, nextCtx)
}
}
}
/** Reads `config.json` from a version directory, returning `{}` if the file is missing. */
async function readConfig(versionDir: string): Promise<VersionConfig> {
try {
const raw = await readFile(join(versionDir, 'config.json'), 'utf-8')
return JSON.parse(raw) as VersionConfig
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return {}
throw err
}
}
/**
* Reads every `.mdx` / `.md` / `.json` file from the `partials/` subdirectory.
* Markdown partials (`.md`, `.mdx`) have their frontmatter parsed for `title`
* and `ref`; JSON partials are parsed as objects and their full body kept so
* it can be emitted into functions.json. Returns alphabetically-sorted entries
* (case-insensitive); returns `[]` if `partials/` doesn't exist.
*/
async function readPartials(versionDir: string): Promise<PartialEntry[]> {
const partialsDir = join(versionDir, 'partials')
let files: string[]
try {
files = await readdir(partialsDir)
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return []
throw err
}
const partials: PartialEntry[] = []
for (const file of files) {
const ext = extname(file)
const name = file.slice(0, -ext.length)
const raw = await readFile(join(partialsDir, file), 'utf-8')
if (ext === '.mdx' || ext === '.md') {
// `trimStart()` so frontmatter is parsed even when the file accidentally
// starts with blank lines before `---`.
const trimmed = raw.trimStart()
const { data } = matter(trimmed)
const title = typeof data.title === 'string' ? data.title : name
const ref = typeof data.ref === 'string' ? data.ref : undefined
partials.push({ name, title, ref, kind: 'markdown', mdxRaw: trimmed })
} else if (ext === '.json') {
const body = JSON.parse(raw) as Record<string, unknown>
const title = typeof body.title === 'string' ? body.title : name
partials.push({ name, title, kind: 'function', body })
}
}
// Default to alphabetical so `reorder` can place ranked items first and leave
// the rest in a deterministic order (Array.sort is stable since ES2019).
return partials.sort((a, b) => a.name.localeCompare(b.name))
}
/**
* Builds bySlug (a flat slug→entry map), sections (the same data shaped as a
* nested tree: partials, then each category containing its functions and
* subcategories), and a functions list (id→$ref pairs for functions.json) in
* a single pass. Categories are filtered by `excludeCategories` and ordered by
* `categoryOrder`; individual declarations are filtered by `excludeDefinitions`
* (matched on the source name, case-sensitive). Partials with a `ref` in
* frontmatter are emitted as `type: 'function'` entries and contribute to
* the functions list.
*/
function buildBySlug(
functions: FunctionEntry[],
partials: PartialEntry[],
config: VersionConfig
): {
bySlug: Record<string, BySlugEntry>
sections: SectionEntry[]
functionsList: FunctionsEntry[]
} {
const bySlug: Record<string, BySlugEntry> = {}
const sections: SectionEntry[] = []
const functionsList: FunctionsEntry[] = []
const excludeCats = new Set(config.excludeCategories ?? [])
const excludeDefs = new Set(config.excludeDefinitions ?? [])
const filtered = functions.filter(
({ name, category }) => !excludeCats.has(category) && !excludeDefs.has(name)
)
// Bucket partials by where they belong. A partial filename that matches a
// category title slug (e.g. `database.md`) attaches to that category; one
// that matches a subcategory title slug (e.g. `using-filters.json`) attaches
// to that subcategory; everything else stays at the top level.
const categorySlugs = new Set(filtered.map(({ category }) => slugifyTag(category)))
const subcategorySlugs = new Set(
filtered.filter((f) => f.subcategory).map((f) => slugifyTag(f.subcategory!))
)
const partialsByCategory = new Map<string, PartialEntry>()
const partialsBySubcategory = new Map<string, PartialEntry>()
const topLevelPartials: PartialEntry[] = []
for (const p of partials) {
if (subcategorySlugs.has(p.name)) partialsBySubcategory.set(p.name, p)
else if (categorySlugs.has(p.name)) partialsByCategory.set(p.name, p)
else topLevelPartials.push(p)
}
const writePartial = (p: PartialEntry, items: SectionEntry[]) => {
const entry = partialEntry(p)
bySlug[p.name] = entry
items.push(entry)
if (p.kind === 'function') functionsList.push({ id: p.name, ...p.body })
else if (p.ref) functionsList.push({ id: p.name, $ref: p.ref })
}
for (const p of reorder(topLevelPartials, config.partialsOrder, (x) => x.name)) {
writePartial(p, sections)
}
type CategoryGroup = {
title: string
withoutSub: FunctionEntry[]
bySub: Map<string, FunctionEntry[]>
}
const groups = new Map<string, CategoryGroup>()
for (const fn of filtered) {
let group = groups.get(fn.category)
if (!group) {
group = { title: fn.category, withoutSub: [], bySub: new Map() }
groups.set(fn.category, group)
}
if (fn.subcategory) {
const bucket = group.bySub.get(fn.subcategory) ?? []
bucket.push(fn)
group.bySub.set(fn.subcategory, bucket)
} else {
group.withoutSub.push(fn)
}
}
const orderedCategories = reorder(Array.from(groups.keys()), config.categoryOrder, (c) => c)
const writeFunction = (fn: FunctionEntry, product: string, items: SectionEntry[]) => {
const entry = functionEntry(fn, product, config.navigationPrefixes)
// Spec files can re-declare same-named methods on different classes; the
// slug collides, so only emit each unique slug once (first wins).
if (entry.slug in bySlug) return
bySlug[entry.slug] = entry
items.push(entry)
// functions.json `id` must match the bySlug entry's `id` (not the slug) —
// the renderer in Reference.sections.tsx does `fns.find(f => f.id === section.id)`.
functionsList.push({ id: entry.id, $ref: fn.$ref })
}
// Sort items alphabetically (case-insensitive) within categories and within
// subcategories. Subcategories still appear at the end of each category (the
// order they are pushed below preserves that: withoutSub entries first, then
// each subcategory block).
const byName = (a: { name: string }, b: { name: string }) =>
a.name.toLowerCase().localeCompare(b.name.toLowerCase())
for (const category of orderedCategories) {
const group = groups.get(category)!
// `product` keeps the literal slugified category for feature filtering and
// for partial-filename matching, even when navigationPrefixes renames the
// category's navigation slug.
const product = slugifyTag(category)
const categorySlug = entrySlug(category, config.navigationPrefixes)
const cat = categoryEntry(group.title)
const catItems: SectionEntry[] = []
bySlug[categorySlug] = cat
sections.push({ ...cat, items: catItems })
const categoryPartial = partialsByCategory.get(product)
if (categoryPartial) writePartial(categoryPartial, catItems)
for (const fn of [...group.withoutSub].sort(byName)) writeFunction(fn, product, catItems)
const sortedSubs = [...group.bySub.entries()].sort(([a], [b]) =>
a.toLowerCase().localeCompare(b.toLowerCase())
)
for (const [subcategory, fns] of sortedSubs) {
const subKey = slugifyTag(subcategory)
const subSlug = entrySlug(subcategory, config.navigationPrefixes)
const sub = subcategoryEntry(subSlug, subcategory, product)
const subItems: SectionEntry[] = []
bySlug[subSlug] = sub
catItems.push({ ...sub, items: subItems })
const subcategoryPartial = partialsBySubcategory.get(subKey)
if (subcategoryPartial) writePartial(subcategoryPartial, subItems)
for (const fn of [...fns].sort(byName)) writeFunction(fn, product, subItems)
}
}
return { bySlug, sections, functionsList }
}
/**
* For each markdown-kind partial without a `ref`, writes its raw body
* (frontmatter + content) to `docs/ref/<library>/<name>.mdx` if that file
* does not already exist. The renderer's `getRefMarkdown` loads body text
* from this location, so new partials added under `spec/reference/.../partials/`
* become renderable without manual file shuffling. Existing files are left
* alone to preserve hand-maintained frontmatter (e.g. `hideTitle`).
*/
async function writeNewMarkdownPartials(library: string, partials: PartialEntry[]): Promise<void> {
const markdownPartials = partials.filter((p) => p.kind === 'markdown' && p.mdxRaw && !p.ref)
if (markdownPartials.length === 0) return
const outDir = join(REF_DOCS_DIR, library)
await mkdir(outDir, { recursive: true })
await Promise.all(
markdownPartials.map(async (p) => {
const target = join(outDir, `${p.name}.mdx`)
try {
// `wx` flag fails with EEXIST if the file is already there.
await writeFile(target, p.mdxRaw!, { flag: 'wx' })
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err
}
})
)
}
/**
* In-memory computation for a single `[library]/[version]`: reads spec files,
* partials, and config, walks every TypeDoc declaration, and returns the five
* derived artifacts (`bySlug`, `flat`, `sections`, `functionsList`,
* `typeSpec`) plus the partial list needed for downstream `.mdx` seeding.
*
* Exported so tests can snapshot the output shape without going through the
* filesystem.
*/
export async function collectReferenceContent(library: string, version: string) {
const versionDir = join(SPEC_DIR, library, version)
const files = (await readdir(versionDir)).filter(
(f) => f.endsWith('.json') && f !== 'config.json'
)
const config = await readConfig(versionDir)
const partials = await readPartials(versionDir)
const collected = {
functions: [] as FunctionEntry[],
typeSpec: { methods: {}, variables: {} } as TypeSpec,
}
for (const file of files) {
const raw = await readFile(join(versionDir, file), 'utf-8')
const spec = JSON.parse(raw) as Declaration
// Build a numeric id → node map per package file so `parseType`'s reference
// resolution can walk dereferenced types and aliased declarations.
const idMap = new Map<number, any>()
buildMap(spec, idMap)
collectFunctions(spec, collected, idMap)
}
const { bySlug, sections, functionsList } = buildBySlug(collected.functions, partials, config)
const flat = Object.values(bySlug)
return { bySlug, flat, sections, functionsList, typeSpec: collected.typeSpec, partials }
}
/**
* Processes one `[library]/[version]` directory: reads spec files, partials,
* and config, then writes all five output files (`bySlug.json`, `flat.json`,
* `sections.json`, `functions.json`, `typeSpec.json`) in parallel.
*/
async function processVersion(library: string, version: string): Promise<void> {
const { bySlug, flat, sections, functionsList, typeSpec, partials } =
await collectReferenceContent(library, version)
const counts = { markdown: 0, function: 0, subcategory: 0, category: 0 }
for (const v of flat) {
if (v.type === 'markdown') counts.markdown++
else if (v.type === 'category') counts.category++
else if ('isFunc' in v && v.isFunc === false) counts.subcategory++
else counts.function++
}
const outputDir = join(OUTPUT_DIR, library, version)
await mkdir(outputDir, { recursive: true })
await Promise.all([
writeFile(join(outputDir, 'bySlug.json'), JSON.stringify(bySlug)),
writeFile(join(outputDir, 'flat.json'), JSON.stringify(flat)),
writeFile(join(outputDir, 'sections.json'), JSON.stringify(sections)),
writeFile(join(outputDir, 'functions.json'), JSON.stringify(functionsList)),
writeFile(join(outputDir, 'typeSpec.json'), JSON.stringify(typeSpec)),
])
// The page renderer's `MarkdownSection` loads body text by id from
// `docs/ref/<library>/<id>.mdx`. Seed that file from any markdown partial
// whose runtime counterpart doesn't already exist (we don't overwrite
// hand-maintained legacy partials like introduction.mdx).
await writeNewMarkdownPartials(library, partials)
const typeSpecMethods = Object.keys(typeSpec.methods).length
const typeSpecVariables = Object.keys(typeSpec.variables).length
console.log(
`[${library}/${version}] wrote 5 files — ${counts.markdown} partials, ${counts.function} function slugs, ${counts.subcategory} subcategories, ${counts.category} categories, ${functionsList.length} functions.json entries, ${typeSpecMethods} typeSpec methods, ${typeSpecVariables} typeSpec variables`
)
}
/** Entry point: discovers every `[library]/[version]` pair under `spec/reference` and processes each. */
async function main(): Promise<void> {
const libraries = await readdir(SPEC_DIR, { withFileTypes: true })
for (const lib of libraries) {
if (!lib.isDirectory()) continue
const versions = await readdir(join(SPEC_DIR, lib.name), { withFileTypes: true })
for (const version of versions) {
if (!version.isDirectory()) continue
await processVersion(lib.name, version.name)
}
}
}
// Only run `main()` when invoked as a script (via `tsx`). Importing this
// module from a test should not trigger the side-effecting walk.
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => {
console.error(err)
process.exit(1)
})
}