// Builds the /docs static content for the toolrate frontend. // // Sources: // - lib/api-spec/openapi.yaml -> reference.json (endpoints + schemas) // - docs/handbook/*.md -> handbook pages (current docs) // - docs/releases/*.md -> version-bound release notes // - docs/releases//reference.json -> per-version reference snapshots // // Usage: // node scripts/src/generate-docs.mjs # build mode (run before vite build/dev) // node scripts/src/generate-docs.mjs --snapshot v0.8.0 # write docs/releases//reference.json // // Build mode copies committed snapshots and regenerates the CURRENT reference // from the live openapi.yaml. The --snapshot mode is run manually when // preparing a release so that older versions keep their own field reference. import { readFile, readdir, copyFile, mkdir, rm, writeFile, stat } from "node:fs/promises"; import { resolve, join, dirname, basename } from "node:path"; import { fileURLToPath } from "node:url"; import { parse as parseYaml } from "yaml"; const root = resolve(fileURLToPath(new URL("../..", import.meta.url))); const openapiPath = resolve(root, "lib/api-spec/openapi.yaml"); const handbookDir = resolve(root, "docs/handbook"); const releasesDir = resolve(root, "docs/releases"); const targetDir = resolve(root, "artifacts/toolrate/public/docs"); const isReleaseFile = (name) => /^v\d+\.\d+\.\d+\.de\.md$/.test(name); const isEnReleaseFile = (name) => /^v\d+\.\d+\.\d+\.en\.md$/.test(name); const isHandbookFile = (name) => /\.de\.md$/.test(name); const isEnHandbookFile = (name) => /\.en\.md$/.test(name); const enName = (file) => (file.endsWith(".de.md") ? `${file.slice(0, -6)}.en.md` : null); const deName = (file) => (file.endsWith(".en.md") ? `${file.slice(0, -6)}.de.md` : null); // --------------------------------------------------------------------------- // Version helpers // --------------------------------------------------------------------------- function parseVersion(name) { return name.replace(/\.(de|en)\.md$/, ""); } function cmp(a, b) { const pa = parseVersion(a).slice(1).split(".").map(Number); const pb = parseVersion(b).slice(1).split(".").map(Number); for (let i = 0; i < 3; i++) { if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) - (pb[i] ?? 0); } return 0; } function extractTitle(content) { const m = content.match(/^#\s+(.+)$/m); return m ? m[1].trim() : null; } function extractDate(content) { const m = content.match(/(?:Datum|Date)[^\d\n]{0,20}(\d{4}-\d{2}-\d{2})/); return m ? m[1] : null; } // --------------------------------------------------------------------------- // OpenAPI -> reference model // --------------------------------------------------------------------------- function deref(schema) { return schema && typeof schema === "object" ? schema : {}; } function fieldType(schema) { const s = deref(schema); if (s.$ref) { return { kind: "ref", value: s.$ref.split("/").pop() }; } if (Array.isArray(s.type)) { return { kind: "type", value: s.type.filter(Boolean).join(" | ") }; } if (s.type === "array") { const item = deref(s.items); if (item.$ref) return { kind: "array", value: item.$ref.split("/").pop() }; return { kind: "array", value: String(item.type ?? "any") }; } return { kind: "type", value: String(s.type ?? "any") }; } function describeField(schema) { const s = deref(schema); const parts = []; if (s.format) parts.push(s.format); if (Array.isArray(s.enum) && s.enum.length > 0) parts.push(s.enum.join(", ")); if (s.minLength != null) parts.push(`min ${s.minLength} chars`); if (s.minItems != null) parts.push(`min ${s.minItems} items`); if (s.maxItems != null) parts.push(`max ${s.maxItems} items`); if (s.minimum != null && s.maximum != null) parts.push(`${s.minimum}–${s.maximum}`); else if (s.minimum != null) parts.push(`>= ${s.minimum}`); else if (s.maximum != null) parts.push(`<= ${s.maximum}`); return parts.join(", "); } function buildSchemaModel(name, schema) { const s = deref(schema); const required = new Set(Array.isArray(s.required) ? s.required : []); const fields = Object.entries(s.properties ?? {}) .filter(([key]) => !key.startsWith("$")) .map(([key, prop]) => { const t = fieldType(prop); return { name: key, type: t, required: required.has(key), description: deref(prop).description ?? "", constraints: describeField(prop), }; }); return { name, description: s.description ?? "", fields, }; } function buildEndpointModel(path, pathItem) { const models = []; for (const [method, op] of Object.entries(pathItem)) { if (!["get", "post", "patch", "put", "delete"].includes(method)) continue; const o = deref(op); const parameters = (o.parameters ?? []).map((p) => { const s = deref(p.schema); const t = fieldType(p.schema); return { name: p.name, in: p.in, required: !!p.required, type: t, description: p.description ?? s.description ?? "", constraints: describeField(p.schema), }; }); const requestBody = o.requestBody ? { required: !!o.requestBody.required, schema: fieldType(deref(o.requestBody).content?.["application/json"]?.schema), } : null; const responses = Object.entries(o.responses ?? {}).map(([status, r]) => ({ status, description: deref(r).description ?? "", schema: fieldType(deref(r).content?.["application/json"]?.schema), })); models.push({ operationId: o.operationId ?? `${method} ${path}`, method: method.toUpperCase(), path, summary: o.summary ?? "", description: o.description ?? "", parameters, requestBody, responses, }); } return models; } function buildReference(api) { const tagNames = (api.tags ?? []).map((t) => t.name); const tags = tagNames.map((name) => { const meta = (api.tags ?? []).find((t) => t.name === name) ?? {}; const endpoints = []; for (const [path, pathItem] of Object.entries(api.paths ?? {})) { for (const model of buildEndpointModel(path, pathItem)) { const rawOp = pathItem[model.method.toLowerCase()]; if ((rawOp?.tags ?? []).includes(name)) endpoints.push(model); } } return { name, description: meta.description ?? "", endpoints }; }); const schemas = Object.entries(api.components?.schemas ?? {}).map(([name, s]) => buildSchemaModel(name, s), ); return { tags, schemas }; } // --------------------------------------------------------------------------- // Handbook parsing (frontmatter: title, order, icon) // --------------------------------------------------------------------------- function parseHandbook(content) { const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/); if (!match) return { frontmatter: {}, body: content }; let frontmatter = {}; try { frontmatter = parseYaml(match[1]) ?? {}; } catch { frontmatter = {}; } return { frontmatter, body: content.slice(match[0].length) }; } function slugify(name) { return name.replace(/\.(de|en)?\.md$/, "").toLowerCase().replace(/[^a-z0-9-]+/g, "-"); } async function readFileOptional(path) { try { return await readFile(path, "utf8"); } catch (err) { if (err.code === "ENOENT") return null; throw err; } } async function readHandbookPages() { let files; try { files = (await readdir(handbookDir)).filter(isHandbookFile); } catch (err) { if (err.code === "ENOENT") return []; throw err; } const pages = []; for (const file of files) { const content = await readFile(join(handbookDir, file), "utf8"); const { frontmatter, body } = parseHandbook(content); const enFile = enName(file); const enContent = enFile ? await readFileOptional(join(handbookDir, enFile)) : null; const en = enContent ? parseHandbook(enContent) : null; pages.push({ slug: slugify(basename(file)), file, title: frontmatter.title ?? extractTitle(body) ?? basename(file), order: typeof frontmatter.order === "number" ? frontmatter.order : 999, body, fileEn: en ? enFile : null, titleEn: en ? en.frontmatter.title ?? extractTitle(en.body) ?? null : null, bodyEn: en ? en.body : null, }); } pages.sort((a, b) => a.order - b.order || a.title.localeCompare(b.title)); return pages; } async function writeHandbookTo(dir) { const pages = await readHandbookPages(); await mkdir(dir, { recursive: true }); for (const page of pages) { await writeFile(join(dir, page.file), page.body); if (page.bodyEn) await writeFile(join(dir, page.fileEn), page.bodyEn); } await writeFile( join(dir, "index.json"), JSON.stringify( pages.map(({ slug, file, title, order, fileEn, titleEn }) => ({ slug, file, title, order, fileEn, titleEn })), null, 2, ), ); return pages; } async function buildHandbookIndex() { const pages = await writeHandbookTo(join(targetDir, "handbook")); return pages; } // --------------------------------------------------------------------------- // Search index // --------------------------------------------------------------------------- function stripMarkdown(md) { return md .replace(/```[\s\S]*?```/g, " ") .replace(/[#>*`_\-\[\]()!]/g, " ") .replace(/\s+/g, " ") .trim(); } async function buildSearchIndex(reference, handbookPages, releaseVersions) { const entries = []; const entriesEn = []; for (const page of handbookPages) { const file = join(handbookDir, page.file); const content = await readFile(file, "utf8"); const { body } = parseHandbook(content); entries.push({ title: page.title, href: `/docs/handbook/${page.slug}`, kind: "guide", text: stripMarkdown(body), }); if (page.bodyEn) { entriesEn.push({ title: page.titleEn ?? page.title, href: `/docs/handbook/${page.slug}`, kind: "guide", text: stripMarkdown(page.bodyEn), }); } } for (const tag of reference.tags) { for (const ep of tag.endpoints) { const entry = { title: `${ep.method} ${ep.path}`, href: `/docs/reference/endpoints/${tag.name}#${ep.operationId}`, kind: "endpoint", text: `${ep.summary} ${ep.description} ${ep.parameters .map((p) => `${p.name} ${p.description}`) .join(" ")}`.trim(), }; entries.push(entry); entriesEn.push(entry); } } for (const schema of reference.schemas) { for (const field of schema.fields) { const entry = { title: `${schema.name}.${field.name}`, href: `/docs/reference/schemas/${schema.name}#${field.name}`, kind: "field", text: `${field.description} ${field.constraints} ${field.type.value ?? ""}`.trim(), }; entries.push(entry); entriesEn.push(entry); } } for (const version of releaseVersions) { const file = join(releasesDir, `${version}.de.md`); const content = await readFile(file, "utf8"); const base = { title: version, href: `/docs/releases/${version}`, kind: "release", }; entries.push({ ...base, text: stripMarkdown(content) }); const enContent = await readFileOptional(join(releasesDir, `${version}.en.md`)); entriesEn.push({ ...base, text: stripMarkdown(enContent ?? content) }); } return { entries, entriesEn }; } // --------------------------------------------------------------------------- // Snapshot mode // --------------------------------------------------------------------------- async function writeSnapshot(version) { const api = parseYaml(await readFile(openapiPath, "utf8")); const reference = buildReference(api); const dir = join(releasesDir, version); await mkdir(dir, { recursive: true }); await writeFile(join(dir, "reference.json"), JSON.stringify(reference, null, 2)); await writeHandbookTo(join(dir, "handbook")); console.log(`[generate-docs] snapshot ${version} -> ${dir}/ (reference.json + handbook)`); } // --------------------------------------------------------------------------- // Build mode // --------------------------------------------------------------------------- async function main() { const snapshotArg = process.argv.indexOf("--snapshot"); if (snapshotArg >= 0) { const version = process.argv[snapshotArg + 1]; if (!version) { console.error("[generate-docs] --snapshot requires a version, e.g. v0.8.0"); process.exit(1); } await writeSnapshot(version); return; } let releaseNames; try { releaseNames = (await readdir(releasesDir)).filter(isReleaseFile); } catch (err) { if (err.code === "ENOENT") { console.warn("[generate-docs] docs/releases not found; nothing to sync"); return; } throw err; } releaseNames.sort(cmp).reverse(); await rm(targetDir, { recursive: true, force: true }); await mkdir(join(targetDir, "handbook"), { recursive: true }); await mkdir(join(targetDir, "releases"), { recursive: true }); await mkdir(join(targetDir, "versions"), { recursive: true }); // 1. Current reference from live openapi.yaml const api = parseYaml(await readFile(openapiPath, "utf8")); const reference = buildReference(api); await writeFile(join(targetDir, "reference.json"), JSON.stringify(reference, null, 2)); // 2. Handbook pages (current docs) const handbookPages = await buildHandbookIndex(); await writeFile( join(targetDir, "handbook/index.json"), JSON.stringify( handbookPages.map(({ slug, file, title, order, fileEn, titleEn }) => ({ slug, file, title, order, fileEn, titleEn, })), null, 2, ), ); // 3. Release notes + versioned docs snapshots (last 7 versions) const versions = []; for (const name of releaseNames.slice(0, 7)) { const version = parseVersion(name); const content = await readFile(join(releasesDir, name), "utf8"); await copyFile(join(releasesDir, name), join(targetDir, "releases", name)); const releaseEnName = enName(name); const enContent = await readFileOptional(join(releasesDir, releaseEnName)); if (enContent) await copyFile(join(releasesDir, releaseEnName), join(targetDir, "releases", releaseEnName)); const snapDir = join(releasesDir, version); const hasReference = await stat(join(snapDir, "reference.json")) .then(() => true) .catch(() => false); const hasHandbook = await stat(join(snapDir, "handbook", "index.json")) .then(() => true) .catch(() => false); if (hasReference) { await copyFile( join(snapDir, "reference.json"), join(targetDir, "versions", `${version}.json`), ); } if (hasHandbook) { await mkdir(join(targetDir, "versions", version, "handbook"), { recursive: true }); await copyFile( join(snapDir, "handbook", "index.json"), join(targetDir, "versions", version, "handbook", "index.json"), ); const pages = JSON.parse( await readFile(join(snapDir, "handbook", "index.json"), "utf8"), ); for (const page of pages) { await copyFile( join(snapDir, "handbook", page.file), join(targetDir, "versions", version, "handbook", page.file), ); if (page.fileEn) { await copyFile( join(snapDir, "handbook", page.fileEn), join(targetDir, "versions", version, "handbook", page.fileEn), ); } } } versions.push({ version, file: name, fileEn: enContent ? releaseEnName : null, title: extractTitle(content) ?? version, titleEn: enContent ? extractTitle(enContent) ?? version : null, date: extractDate(content) ?? null, hasReference, hasHandbook, }); } await writeFile(join(targetDir, "index.json"), JSON.stringify(versions, null, 2)); // 4. Search index const { entries, entriesEn } = await buildSearchIndex(reference, handbookPages, versions.map((v) => v.version)); await writeFile(join(targetDir, "search.json"), JSON.stringify(entries, null, 2)); await writeFile(join(targetDir, "search.en.json"), JSON.stringify(entriesEn, null, 2)); console.log( `[generate-docs] synced ${versions.length} release(s), ${handbookPages.length} handbook page(s), ` + `${reference.schemas.length} schema(s), ${entries.length} search entries`, ); } main().catch((err) => { console.error("[generate-docs] failed:", err); process.exit(1); });