Files
tool-evaluator/scripts/src/generate-docs.mjs
T
opencode e66a332270
Build & Push Docker Image / build (push) Successful in 3m1s
fix(docs): release file without releases/ prefix to avoid double path
index.json stored file='releases/vX.Y.Z.de.md' but ReleaseNoteView
prepends 'releases/' again, producing releases/releases/... (404).
Store the bare filename so the view resolves docs/releases/vX.Y.Z.de.md.
2026-08-05 00:32:56 +02:00

488 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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/<v>/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/<v>/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);
});