feat(docs): mkdocs-style documentation site served at /docs

Full documentation hub replacing the release-notes-only view:
- Handbook pages (docs/handbook) for all features and admin/betrieb
- API reference generated from lib/api-spec/openapi.yaml via
  scripts/src/generate-docs.mjs (replaces sync-release-docs.mjs):
  endpoints, schemas/fields, search index, per-release snapshots
- mkdocs layout: sidebar nav, right TOC with scrollspy, search overlay,
  version dropdown, repo link
- FieldHelp (?) buttons in forms linking to reference field docs
- v0.7.0 release notes backfilled, v0.8.0 release notes added
This commit is contained in:
opencode
2026-08-03 22:15:54 +02:00
parent 520f917723
commit 6c92b6358d
29 changed files with 7545 additions and 264 deletions
+2 -1
View File
@@ -9,6 +9,7 @@
},
"devDependencies": {
"@types/node": "catalog:",
"tsx": "catalog:"
"tsx": "catalog:",
"yaml": "catalog:"
}
}
+385
View File
@@ -0,0 +1,385 @@
// 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+\.md$/.test(name);
const isHandbookFile = (name) => /\.md$/.test(name);
// ---------------------------------------------------------------------------
// Version helpers
// ---------------------------------------------------------------------------
function parseVersion(name) {
return name.replace(/\.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(/\.md$/, "").toLowerCase().replace(/[^a-z0-9-]+/g, "-");
}
async function buildHandbookIndex() {
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);
pages.push({
slug: slugify(basename(file)),
file,
title: frontmatter.title ?? extractTitle(body) ?? basename(file),
order: typeof frontmatter.order === "number" ? frontmatter.order : 999,
});
await writeFile(join(targetDir, "handbook", file), body);
}
pages.sort((a, b) => a.order - b.order || a.title.localeCompare(b.title));
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 = [];
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),
});
}
for (const tag of reference.tags) {
for (const ep of tag.endpoints) {
entries.push({
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(),
});
}
}
for (const schema of reference.schemas) {
for (const field of schema.fields) {
entries.push({
title: `${schema.name}.${field.name}`,
href: `/docs/reference/schemas/${schema.name}#${field.name}`,
kind: "field",
text: `${field.description} ${field.constraints} ${field.type.value ?? ""}`.trim(),
});
}
}
for (const version of releaseVersions) {
const file = join(releasesDir, `${version}.md`);
const content = await readFile(file, "utf8");
entries.push({
title: version,
href: `/docs/releases/${version}`,
kind: "release",
text: stripMarkdown(content),
});
}
return entries;
}
// ---------------------------------------------------------------------------
// 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));
console.log(`[generate-docs] snapshot ${version} -> ${join(dir, "reference.json")}`);
}
// ---------------------------------------------------------------------------
// 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, null, 2),
);
// 3. Release notes + versioned reference snapshots
const versions = [];
for (const name of releaseNames) {
const version = parseVersion(name);
const content = await readFile(join(releasesDir, name), "utf8");
await copyFile(join(releasesDir, name), join(targetDir, "releases", name));
const hasSnapshot = await stat(join(releasesDir, version, "reference.json"))
.then(() => true)
.catch(() => false);
if (hasSnapshot) {
await copyFile(
join(releasesDir, version, "reference.json"),
join(targetDir, "versions", `${version}.json`),
);
}
versions.push({
version,
file: `releases/${name}`,
title: extractTitle(content) ?? version,
date: extractDate(content) ?? null,
hasReference: hasSnapshot,
});
}
await writeFile(join(targetDir, "index.json"), JSON.stringify(versions, null, 2));
// 4. Search index
const searchIndex = await buildSearchIndex(reference, handbookPages, versions.map((v) => v.version));
await writeFile(join(targetDir, "search.json"), JSON.stringify(searchIndex, null, 2));
console.log(
`[generate-docs] synced ${versions.length} release(s), ${handbookPages.length} handbook page(s), ` +
`${reference.schemas.length} schema(s), ${searchIndex.length} search entries`,
);
}
main().catch((err) => {
console.error("[generate-docs] failed:", err);
process.exit(1);
});
-87
View File
@@ -1,87 +0,0 @@
// Copies docs/releases/*.md into the toolrate frontend's static public dir
// and generates index.json (version list) so the app can render /docs.
//
// Run before `vite build` (Vite copies public/ -> dist/public verbatim), and
// before `vite dev` so the docs are available locally too.
//
// Usage: node scripts/src/sync-release-docs.mjs
import { readdir, readFile, copyFile, mkdir, rm, writeFile } from "node:fs/promises";
import { resolve, join } from "node:path";
import { fileURLToPath } from "node:url";
const root = resolve(fileURLToPath(new URL("../..", import.meta.url)));
const sourceDir = resolve(root, "docs/releases");
const targetDir = resolve(root, "artifacts/toolrate/public/docs");
const isReleaseFile = (name) => /^v\d+\.\d+\.\d+\.md$/.test(name);
function parseVersion(name) {
return name.replace(/\.md$/, "");
}
// Semantic-ish comparison: v0.6.0 > v0.5.0 > v0.4.2
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) {
// e.g. "**Datum:** 2026-08-03" or "**Date:** 2026-08-03" in the header block.
// Match the first ISO date that follows a Datum/Date label, tolerating
// bold markers/colons around it.
const m = content.match(/(?:Datum|Date)[^\d\n]{0,20}(\d{4}-\d{2}-\d{2})/);
return m ? m[1] : null;
}
async function main() {
let names;
try {
names = (await readdir(sourceDir)).filter(isReleaseFile);
} catch (err) {
if (err.code === "ENOENT") {
console.warn(`[sync-release-docs] ${sourceDir} not found; nothing to sync`);
return;
}
throw err;
}
if (names.length === 0) {
console.warn("[sync-release-docs] no release docs found; clearing target dir");
}
names.sort(cmp).reverse();
await rm(targetDir, { recursive: true, force: true });
await mkdir(targetDir, { recursive: true });
const index = [];
for (const name of names) {
const content = await readFile(join(sourceDir, name), "utf8");
await copyFile(join(sourceDir, name), join(targetDir, name));
index.push({
version: parseVersion(name),
file: name,
title: extractTitle(content) ?? parseVersion(name),
date: extractDate(content) ?? null,
});
}
await writeFile(join(targetDir, "index.json"), JSON.stringify(index, null, 2));
console.log(`[sync-release-docs] synced ${names.length} release doc(s) -> ${targetDir}`);
}
main().catch((err) => {
console.error("[sync-release-docs] failed:", err);
process.exit(1);
});