e66a332270
Build & Push Docker Image / build (push) Successful in 3m1s
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.
488 lines
16 KiB
JavaScript
488 lines
16 KiB
JavaScript
// 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);
|
||
});
|