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:
@@ -9,6 +9,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"tsx": "catalog:"
|
||||
"tsx": "catalog:",
|
||||
"yaml": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user