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 -2
View File
@@ -4,8 +4,8 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "node ../../scripts/src/sync-release-docs.mjs && vite --config vite.config.ts --host 0.0.0.0", "dev": "node ../../scripts/src/generate-docs.mjs && vite --config vite.config.ts --host 0.0.0.0",
"build": "node ../../scripts/src/sync-release-docs.mjs && vite build --config vite.config.ts", "build": "node ../../scripts/src/generate-docs.mjs && vite build --config vite.config.ts",
"serve": "vite preview --config vite.config.ts --host 0.0.0.0", "serve": "vite preview --config vite.config.ts --host 0.0.0.0",
"typecheck": "tsc -p tsconfig.json --noEmit" "typecheck": "tsc -p tsconfig.json --noEmit"
}, },
+1 -2
View File
@@ -47,8 +47,7 @@ function Router() {
<Route path="/admin" component={Admin} /> <Route path="/admin" component={Admin} />
<Route path="/admin/redundancy" component={Redundancy} /> <Route path="/admin/redundancy" component={Redundancy} />
<Route path="/trash" component={Trash} /> <Route path="/trash" component={Trash} />
<Route path="/docs" component={Docs} /> <Route path="/docs/*?" component={Docs} />
<Route path="/docs/:version" component={Docs} />
<Route component={NotFound} /> <Route component={NotFound} />
</Switch> </Switch>
); );
@@ -38,9 +38,30 @@ function buildCrumbs(location: string, t: TFunction): Crumb[] {
} else if (location.startsWith("/analytics")) { } else if (location.startsWith("/analytics")) {
crumbs.push({ label: t("nav.analytics") }); crumbs.push({ label: t("nav.analytics") });
} else if (location.startsWith("/docs")) { } else if (location.startsWith("/docs")) {
crumbs.push({ href: "/docs", label: t("nav.docs") }); crumbs.push({ href: "/docs", label: t("docs.title") });
const match = location.match(/^\/docs\/(.+)$/); const m = location.replace(/^\/docs\/?/, "");
if (match) crumbs.push({ label: match[1] }); if (m.startsWith("handbook/")) {
crumbs.push({ href: "/docs/handbook", label: t("docs.guides") });
const slug = m.replace(/^handbook\//, "").split("#")[0];
if (slug) crumbs.push({ label: slug });
} else if (m.startsWith("reference/")) {
crumbs.push({ href: "/docs/reference/endpoints", label: t("docs.reference") });
const rest = m.replace(/^reference\//, "").split("#")[0];
if (rest.startsWith("schemas/")) {
crumbs.push({ label: t("docs.schemas") });
const name = rest.replace(/^schemas\//, "");
if (name) crumbs.push({ label: name });
} else {
const tag = rest.replace(/^endpoints\//, "");
if (tag) crumbs.push({ label: tag });
}
} else if (m.startsWith("releases/")) {
crumbs.push({ href: "/docs/releases", label: t("docs.releases") });
const version = m.replace(/^releases\//, "").split("#")[0];
if (version) crumbs.push({ label: version });
} else if (m) {
crumbs.push({ label: m });
}
} else if (location.startsWith("/login")) { } else if (location.startsWith("/login")) {
crumbs.push({ label: t("auth.signIn") }); crumbs.push({ label: t("auth.signIn") });
} }
@@ -0,0 +1,32 @@
import { HelpCircle } from "lucide-react";
import { Link } from "wouter";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
export function FieldHelp({
schema,
field,
children,
}: {
schema: string;
field: string;
children?: React.ReactNode;
}) {
const label = children ?? field;
return (
<Tooltip>
<TooltipTrigger asChild>
<Link
href={`/docs/reference/schemas/${schema}#${field}`}
target="_blank"
rel="noreferrer"
aria-label={`Help: ${label}`}
data-testid={`help-${schema}-${field}`}
className="inline-flex shrink-0 text-muted-foreground hover:text-foreground transition-colors"
>
<HelpCircle className="h-3.5 w-3.5" />
</Link>
</TooltipTrigger>
<TooltipContent>{label} Details in der Dokumentation</TooltipContent>
</Tooltip>
);
}
+15 -3
View File
@@ -183,10 +183,22 @@
"backHome": "Zurück zur Startseite" "backHome": "Zurück zur Startseite"
}, },
"docs": { "docs": {
"title": "Versionsdokumentation", "title": "Dokumentation",
"subtitle": "Version-gebundene Dokumentation je Release — was ist neu, was hat sich geändert und was beim Upgrade zu beachten ist.", "subtitle": "Version-gebundene Dokumentation Release-Notes, Endpunkte und Datenfelder je Version.",
"backToIndex": "Alle Releases", "backToIndex": "Alle Releases",
"noDocs": "Noch keine Release-Dokumentation verfügbar." "noDocs": "Keine Dokumentation für diesen Pfad verfügbar.",
"version": "Version",
"latest": "Aktuell",
"repo": "Repository",
"nav": "Dokumentation",
"guides": "Handbuch",
"endpoints": "Endpunkte",
"schemas": "Datenmodelle",
"releases": "Release-Notes",
"reference": "API-Referenz",
"referenceIntro": "Automatisch aus der OpenAPI-Spezifikation generiert — alle Endpunkte und Datenfelder der aktuellen Version.",
"onThisPage": "Auf dieser Seite",
"searchPlaceholder": "Doku durchsuchen…"
}, },
"command": { "command": {
"navigate": "Navigation", "navigate": "Navigation",
+15 -3
View File
@@ -183,10 +183,22 @@
"backHome": "Back to Home" "backHome": "Back to Home"
}, },
"docs": { "docs": {
"title": "Release Documentation", "title": "Documentation",
"subtitle": "Version-bound documentation for each release — what's new, what changed, and what to know when upgrading.", "subtitle": "Version-bound documentation — release notes, endpoints and data fields per version.",
"backToIndex": "All releases", "backToIndex": "All releases",
"noDocs": "No release documentation available yet." "noDocs": "No documentation available for this path.",
"version": "Version",
"latest": "Latest",
"repo": "Repository",
"nav": "Documentation",
"guides": "Guide",
"endpoints": "Endpoints",
"schemas": "Data models",
"releases": "Release notes",
"reference": "API reference",
"referenceIntro": "Generated automatically from the OpenAPI spec — all endpoints and data fields of the current version.",
"onThisPage": "On this page",
"searchPlaceholder": "Search docs…"
}, },
"command": { "command": {
"navigate": "Navigate", "navigate": "Navigate",
+19
View File
@@ -265,6 +265,25 @@
*/ */
@layer utilities { @layer utilities {
/* Documentation heading anchors (mkdocs style "¶" links) */
.docs-prose :is(h1, h2, h3, h4) {
scroll-margin-top: 6rem;
position: relative;
}
.docs-prose .docs-anchor::after {
content: "¶";
margin-left: 0.35rem;
font-size: 0.8em;
color: hsl(var(--muted-foreground));
opacity: 0;
transition: opacity 0.15s ease;
}
.docs-prose :is(h1, h2, h3, h4):hover .docs-anchor::after {
opacity: 0.8;
}
/* Hide ugly search cancel button in Chrome until we can style it properly */ /* Hide ugly search cancel button in Chrome until we can style it properly */
input[type="search"]::-webkit-search-cancel-button { input[type="search"]::-webkit-search-cancel-button {
@apply hidden; @apply hidden;
File diff suppressed because it is too large Load Diff
+17 -4
View File
@@ -60,6 +60,7 @@ import {
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { customFetch } from "@workspace/api-client-react"; import { customFetch } from "@workspace/api-client-react";
import { recordRecentTool } from "@/lib/recent-tools"; import { recordRecentTool } from "@/lib/recent-tools";
import { FieldHelp } from "@/components/field-help";
const ratingSchema = z.object({ const ratingSchema = z.object({
usefulness: z.number().min(1).max(5), usefulness: z.number().min(1).max(5),
@@ -776,7 +777,10 @@ export default function ToolDetail() {
name="usefulness" name="usefulness"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t("detail.usefulness")}</FormLabel> <FormLabel className="inline-flex items-center gap-1.5">
{t("detail.usefulness")}
<FieldHelp schema="RatingInput" field="usefulness">{t("detail.usefulness")}</FieldHelp>
</FormLabel>
<div className="py-2"> <div className="py-2">
<RatingStars <RatingStars
value={field.value} value={field.value}
@@ -794,7 +798,10 @@ export default function ToolDetail() {
name="usability" name="usability"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t("detail.usability")}</FormLabel> <FormLabel className="inline-flex items-center gap-1.5">
{t("detail.usability")}
<FieldHelp schema="RatingInput" field="usability">{t("detail.usability")}</FieldHelp>
</FormLabel>
<div className="py-2"> <div className="py-2">
<RatingStars <RatingStars
value={field.value} value={field.value}
@@ -814,7 +821,10 @@ export default function ToolDetail() {
name="comment" name="comment"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Comment (Optional)</FormLabel> <FormLabel className="inline-flex items-center gap-1.5">
Comment (Optional)
<FieldHelp schema="RatingInput" field="comment">Comment</FieldHelp>
</FormLabel>
<FormControl> <FormControl>
<Textarea <Textarea
placeholder="What do you think about this tool?" placeholder="What do you think about this tool?"
@@ -832,7 +842,10 @@ export default function ToolDetail() {
name="reviewerName" name="reviewerName"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Name (Optional)</FormLabel> <FormLabel className="inline-flex items-center gap-1.5">
Name (Optional)
<FieldHelp schema="RatingInput" field="reviewerName">Name</FieldHelp>
</FormLabel>
<FormControl> <FormControl>
<Input placeholder="Anonymous" {...field} /> <Input placeholder="Anonymous" {...field} />
</FormControl> </FormControl>
+32 -10
View File
@@ -30,6 +30,7 @@ import { CategoryCombobox } from "@/components/category-combobox";
import { FeatureInput } from "@/components/feature-input"; import { FeatureInput } from "@/components/feature-input";
import { TagInput } from "@/components/tag-input"; import { TagInput } from "@/components/tag-input";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import { FieldHelp } from "@/components/field-help";
const toolSchema = z.object({ const toolSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"), name: z.string().min(2, "Name must be at least 2 characters"),
@@ -171,7 +172,10 @@ export default function ToolEdit() {
name="name" name="name"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Name</FormLabel> <FormLabel className="inline-flex items-center gap-1.5">
Name
<FieldHelp schema="ToolInput" field="name">Name</FieldHelp>
</FormLabel>
<FormControl> <FormControl>
<Input placeholder="Tool name" {...field} /> <Input placeholder="Tool name" {...field} />
</FormControl> </FormControl>
@@ -184,7 +188,10 @@ export default function ToolEdit() {
name="category" name="category"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Category</FormLabel> <FormLabel className="inline-flex items-center gap-1.5">
Category
<FieldHelp schema="ToolInput" field="category">Category</FieldHelp>
</FormLabel>
<FormControl> <FormControl>
<CategoryCombobox value={field.value} onChange={field.onChange} /> <CategoryCombobox value={field.value} onChange={field.onChange} />
</FormControl> </FormControl>
@@ -198,8 +205,11 @@ export default function ToolEdit() {
control={form.control} control={form.control}
name="websiteUrl" name="websiteUrl"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Website URL (Optional)</FormLabel> <FormLabel className="inline-flex items-center gap-1.5">
Website URL (Optional)
<FieldHelp schema="ToolInput" field="websiteUrl">Website URL</FieldHelp>
</FormLabel>
<FormControl> <FormControl>
<Input placeholder="https://..." type="url" {...field} /> <Input placeholder="https://..." type="url" {...field} />
</FormControl> </FormControl>
@@ -212,8 +222,11 @@ export default function ToolEdit() {
control={form.control} control={form.control}
name="iconUrl" name="iconUrl"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Icon / Logo URL (Optional)</FormLabel> <FormLabel className="inline-flex items-center gap-1.5">
Icon / Logo URL (Optional)
<FieldHelp schema="ToolInput" field="iconUrl">Icon / Logo URL</FieldHelp>
</FormLabel>
<FormControl> <FormControl>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-md border bg-muted flex items-center justify-center overflow-hidden shrink-0"> <div className="w-9 h-9 rounded-md border bg-muted flex items-center justify-center overflow-hidden shrink-0">
@@ -245,8 +258,11 @@ export default function ToolEdit() {
control={form.control} control={form.control}
name="description" name="description"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Description</FormLabel> <FormLabel className="inline-flex items-center gap-1.5">
Description
<FieldHelp schema="ToolInput" field="description">Description</FieldHelp>
</FormLabel>
<FormControl> <FormControl>
<Textarea <Textarea
placeholder="What does this tool do?" placeholder="What does this tool do?"
@@ -262,7 +278,10 @@ export default function ToolEdit() {
<div className="space-y-4 pt-4 border-t"> <div className="space-y-4 pt-4 border-t">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div> <div>
<h3 className="text-lg font-medium">Features</h3> <h3 className="text-lg font-medium inline-flex items-center gap-1.5">
Features
<FieldHelp schema="ToolInput" field="features">Features</FieldHelp>
</h3>
<p className="text-sm text-muted-foreground">Key capabilities of this tool. Existing features from other tools are selectable.</p> <p className="text-sm text-muted-foreground">Key capabilities of this tool. Existing features from other tools are selectable.</p>
</div> </div>
<Button type="button" variant="outline" size="sm" onClick={() => appendFeature({ value: "" })}> <Button type="button" variant="outline" size="sm" onClick={() => appendFeature({ value: "" })}>
@@ -306,7 +325,10 @@ export default function ToolEdit() {
<div className="space-y-4 pt-4 border-t"> <div className="space-y-4 pt-4 border-t">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div> <div>
<h3 className="text-lg font-medium">Tags</h3> <h3 className="text-lg font-medium inline-flex items-center gap-1.5">
Tags
<FieldHelp schema="ToolInput" field="tags">Tags</FieldHelp>
</h3>
<p className="text-sm text-muted-foreground">Keywords for this tool. Existing tags from other tools are selectable.</p> <p className="text-sm text-muted-foreground">Keywords for this tool. Existing tags from other tools are selectable.</p>
</div> </div>
<Button type="button" variant="outline" size="sm" onClick={() => appendTag({ value: "" })}> <Button type="button" variant="outline" size="sm" onClick={() => appendTag({ value: "" })}>
+34 -12
View File
@@ -18,6 +18,7 @@ import { CategoryCombobox } from "@/components/category-combobox";
import { FeatureInput } from "@/components/feature-input"; import { FeatureInput } from "@/components/feature-input";
import { TagInput } from "@/components/tag-input"; import { TagInput } from "@/components/tag-input";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import { FieldHelp } from "@/components/field-help";
const toolSchema = z.object({ const toolSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"), name: z.string().min(2, "Name must be at least 2 characters"),
@@ -134,7 +135,10 @@ export default function ToolNew() {
name="name" name="name"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Name</FormLabel> <FormLabel className="inline-flex items-center gap-1.5">
Name
<FieldHelp schema="ToolInput" field="name">Name</FieldHelp>
</FormLabel>
<FormControl> <FormControl>
<Input placeholder="e.g. React, Next.js, Postgres" {...field} data-testid="input-tool-name" /> <Input placeholder="e.g. React, Next.js, Postgres" {...field} data-testid="input-tool-name" />
</FormControl> </FormControl>
@@ -148,7 +152,10 @@ export default function ToolNew() {
name="category" name="category"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Category</FormLabel> <FormLabel className="inline-flex items-center gap-1.5">
Category
<FieldHelp schema="ToolInput" field="category">Category</FieldHelp>
</FormLabel>
<FormControl> <FormControl>
<CategoryCombobox <CategoryCombobox
value={field.value} value={field.value}
@@ -161,12 +168,15 @@ export default function ToolNew() {
/> />
</div> </div>
<FormField <FormField
control={form.control} control={form.control}
name="websiteUrl" name="websiteUrl"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Website URL (Optional)</FormLabel> <FormLabel className="inline-flex items-center gap-1.5">
Website URL (Optional)
<FieldHelp schema="ToolInput" field="websiteUrl">Website URL</FieldHelp>
</FormLabel>
<FormControl> <FormControl>
<Input placeholder="https://..." type="url" {...field} data-testid="input-tool-url" /> <Input placeholder="https://..." type="url" {...field} data-testid="input-tool-url" />
</FormControl> </FormControl>
@@ -180,7 +190,10 @@ export default function ToolNew() {
name="iconUrl" name="iconUrl"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Icon / Logo URL (Optional)</FormLabel> <FormLabel className="inline-flex items-center gap-1.5">
Icon / Logo URL (Optional)
<FieldHelp schema="ToolInput" field="iconUrl">Icon / Logo URL</FieldHelp>
</FormLabel>
<FormControl> <FormControl>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-md border bg-muted flex items-center justify-center overflow-hidden shrink-0"> <div className="w-9 h-9 rounded-md border bg-muted flex items-center justify-center overflow-hidden shrink-0">
@@ -214,7 +227,10 @@ export default function ToolNew() {
name="description" name="description"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Description</FormLabel> <FormLabel className="inline-flex items-center gap-1.5">
Description
<FieldHelp schema="ToolInput" field="description">Description</FieldHelp>
</FormLabel>
<FormControl> <FormControl>
<Textarea <Textarea
placeholder="What does this tool do? Why do people use it?" placeholder="What does this tool do? Why do people use it?"
@@ -231,7 +247,10 @@ export default function ToolNew() {
<div className="space-y-4 pt-4 border-t"> <div className="space-y-4 pt-4 border-t">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div> <div>
<h3 className="text-lg font-medium">Features</h3> <h3 className="text-lg font-medium inline-flex items-center gap-1.5">
Features
<FieldHelp schema="ToolInput" field="features">Features</FieldHelp>
</h3>
<p className="text-sm text-muted-foreground">List key capabilities. Existing features from other tools are selectable.</p> <p className="text-sm text-muted-foreground">List key capabilities. Existing features from other tools are selectable.</p>
</div> </div>
<Button <Button
@@ -284,7 +303,10 @@ export default function ToolNew() {
<div className="space-y-4 pt-4 border-t"> <div className="space-y-4 pt-4 border-t">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div> <div>
<h3 className="text-lg font-medium">Tags</h3> <h3 className="text-lg font-medium inline-flex items-center gap-1.5">
Tags
<FieldHelp schema="ToolInput" field="tags">Tags</FieldHelp>
</h3>
<p className="text-sm text-muted-foreground">Keywords to help find this tool. Existing tags from other tools are selectable.</p> <p className="text-sm text-muted-foreground">Keywords to help find this tool. Existing tags from other tools are selectable.</p>
</div> </div>
<Button <Button
+35 -22
View File
@@ -1,38 +1,51 @@
# Release-Dokumentation # Dokumentation (Handbuch, API-Referenz, Release-Notes)
Jeder Release hat eine version-gebundene Dokumentation unter Die App zeigt unter `/docs` eine MkDocs-artige Doku-Seite mit drei Bereichen:
`docs/releases/`. Die Dokumentation wird öffentlich in der App unter
`/docs` (Index) und `/docs/<version>` (Detail) angezeigt. - **Handbuch** (`docs/handbook/*.md`) — von Hand gepflegte Anleitungen
- **API-Referenz** (`lib/api-spec/openapi.yaml`) — automatisch generierte
Endpunkte & Datenfelder (Schema-Detailseiten mit Feld-Ankern; die `?`-Icons
in Formularen verlinken auf diese Felder)
- **Release-Notes** (`docs/releases/vX.Y.Z.md`) — pro Release
## Struktur ## Struktur
- `docs/handbook/` — Handbuch-Seiten mit Frontmatter (`title`, `order`)
- `docs/releases/TEMPLATE.md` — Vorlage für neue Releases - `docs/releases/TEMPLATE.md` — Vorlage für neue Releases
- `docs/releases/vX.Y.Z.md`Dokumentation pro Release (eine Datei je Version) - `docs/releases/vX.Y.Z.md`Notes pro Release
- `docs/releases/vX.Y.Z/reference.json` — API-Snapshot der jeweiligen Version
## Inhalt ## Generator
Pro Release wird abgedeckt (kombiniert): `scripts/src/generate-docs.mjs` wird beim Frontend-Build (und `dev`) automatisch
ausgeführt und schreibt die Artefakte nach `artifacts/toolrate/public/docs/`:
- **Changelog:** Neue Features, Fixes & Verbesserungen - `reference.json` (aktuelle API), `search.json` (Suchindex),
- **API-Änderungen:** Neue/geänderte/entfernte Endpunkte (Delta zur Vorversion) `index.json` (Releases), `handbook/*.md` + `handbook/index.json`
- **Betrieb / Upgrade:** Env-Vars, DB-Migrationen, Breaking Changes - `releases/vX.Y.Z.md` und `versions/vX.Y.Z.json` (API-Snapshots alter Versionen)
Manuell aufrufbar:
```sh
node scripts/src/generate-docs.mjs # Build-Modus
node scripts/src/generate-docs.mjs --snapshot v0.9.0 # Snapshot für neue Version
```
## Workflow beim Release ## Workflow beim Release
1. **Version taggen** wie bisher (`git tag vX.Y.Z`, CI baut und deployed). 1. **Version taggen** wie bisher (`git tag vX.Y.Z`, CI baut und deployed).
2. **`docs/releases/vX.Y.Z.md` anlegen** — Vorlage aus 2. **`docs/releases/vX.Y.Z.md` anlegen** — Vorlage aus `TEMPLATE.md` kopieren,
`TEMPLATE.md` kopieren. Entwurf aus der Git-Historie seit dem letzten Tag Entwurf aus der Git-Historie ableiten:
ableiten:
```sh ```sh
git log --oneline vX.Y.Z-1..vX.Y.Z git log --oneline vX.Y.Z-1..vX.Y.Z
``` ```
(Funktions-/Fix-Commits in die passenden Abschnitte übernehmen, API-Delta (API-Delta anhand `lib/api-spec/openapi.yaml` prüfen.)
anhand `lib/api-spec/openapi.yaml` prüfen.) 3. **API-Snapshot erzeugen:** `node scripts/src/generate-docs.mjs --snapshot vX.Y.Z`
3. **Committen & pushen.** Der Sync-Schritt (`scripts/sync-release-docs.mjs`) erzeugt `docs/releases/vX.Y.Z/reference.json`.
kopiert die Markdown-Dateien beim Frontend-Build automatisch nach 4. **Committen & pushen.** Der Build kopiert die Dokumentation automatisch nach
`artifacts/toolrate/public/docs/` und generiert `index.json`. Dadurch sind `artifacts/toolrate/public/docs/` und generiert `index.json`.
die Releases im Deployment als `/docs/...` verfügbar.
> Hinweis: `index.json` und die kopierten Dateien unter > Hinweis: Alle Dateien unter `artifacts/toolrate/public/docs/` sind
> `artifacts/toolrate/public/docs/` sind Build-Artefakte und werden bei jedem > Build-Artefakte und werden bei jedem Build neu generiert — nicht von Hand
> Build neu generiert — nicht von Hand bearbeiten. > bearbeiten. Einzige Quellen sind `docs/handbook/`, `docs/releases/` und
> `lib/api-spec/openapi.yaml`.
+61
View File
@@ -0,0 +1,61 @@
---
title: Administration & Papierkorb
order: 7
---
# Administration & Papierkorb
Diese Bereiche sind nur für **Administrator:innen** sichtbar und nutzbar.
## Nutzerverwaltung
Unter **Admin → Nutzer** kannst du:
- **Lokale Nutzer anlegen** (Benutzername, Passwort, E-Mail, Rolle, Tier).
- **Rollen/Tier ändern** (`admin`/`user`, `free`/`premium`/`enterprise`).
- **Passwörter zurücksetzen** (nur lokale Nutzer).
- **Nutzer löschen**.
Zugehörige Endpunkte (Admin-only):
- [`GET /users`](/docs/reference/endpoints/users#listusers)
- [`POST /users`](/docs/reference/endpoints/users#createuser)
- [`PATCH /users/{id}`](/docs/reference/endpoints/users#updateuser)
- [`DELETE /users/{id}`](/docs/reference/endpoints/users#deleteuser)
- [`PATCH /users/{id}/password`](/docs/reference/endpoints/users#setuserpassword)
## Audit-Log
Das **Audit-Log** protokolliert sicherheitsrelevante Änderungen (wer hat wann
was geändert). Es ist über
[`GET /audit-logs`](/docs/reference/endpoints/audit#listauditlogs) abrufbar und
filterbar nach Entitätstyp, Entitäts-ID und Limit.
## Papierkorb (Trash)
Tools werden nicht sofort gelöscht, sondern zuerst **soft gelöscht** (in den
Papierkorb verschoben):
- **Liste:** [`GET /tools/trash`](/docs/reference/endpoints/tools#listtrashedtools)
- **In den Papierkorb verschieben:** [`POST /tools/trash`](/docs/reference/endpoints/tools#trashtools)
- **Wiederherstellen:** [`POST /tools/trash/restore`](/docs/reference/endpoints/tools#restoretools)
- **Endgültig löschen (einzeln):** [`DELETE /tools/trash`](/docs/reference/endpoints/tools#deletetrashedtools)
- **Papierkorb leeren:** [`POST /tools/trash/empty`](/docs/reference/endpoints/tools#emptytrash)
> Die Aufbewahrungsfrist des Papierkorbs (in Tagen) ist im
> [`VersionInfo`](/docs/reference/schemas/versioninfo)-Schema als
> `trashRetentionDays` verfügbar.
## Felder im Überblick
### User
| Feld | Typ | Bedeutung |
| --- | --- | --- |
| `id` | integer | Eindeutige Nutzer-ID. |
| `username` | string | Anmeldename. |
| `email` | string? | E-Mail-Adresse. |
| `role` | string | `admin` oder `user`. |
| `tier` | string | `free`, `premium` oder `enterprise`. |
| `authProvider` | string | `local` oder `oidc`. |
| `createdAt` | date-time | Erstellungszeitpunkt. |
+37
View File
@@ -0,0 +1,37 @@
---
title: Analytics
order: 6
---
# Analytics
Der Bereich **Analytics** fasst die Plattform-Statistiken zusammen — für alle
Nutzer:innen ohne Einschränkung sichtbar.
## Übersicht
| Widget | Quelle (Endpunkt) | Inhalt |
| --- | --- | --- |
| **Plattform-Kennzahlen** | [`GET /analytics/summary`](/docs/reference/endpoints/analytics#getanalyticssummary) | Gesamtzahl Tools, Bewertungen, Durchschnittswerte, Kategorienzahl. |
| **Top-Tools** | [`GET /analytics/top-tools`](/docs/reference/endpoints/analytics#gettoppertools) | Bestbewertete Tools nach wählbarer Metrik (Nützlichkeit, Bedienbarkeit, kombiniert). |
| **Nach Kategorie** | [`GET /analytics/by-category`](/docs/reference/endpoints/analytics#getanalyticsbycategory) | Kennzahlen je Kategorie. |
| **Verteilung** | [`GET /analytics/rating-distribution`](/docs/reference/endpoints/analytics#getratingdistribution) | Verteilung der Bewertungswerte (optional je Tool). |
## Datenfelder
### AnalyticsSummary
| Feld | Typ | Bedeutung |
| --- | --- | --- |
| `totalTools` | integer | Anzahl aller Tools. |
| `totalRatings` | integer | Anzahl aller Bewertungen. |
| `avgUsefulness` | number? | Durchschnittliche Nützlichkeit. |
| `avgUsability` | number? | Durchschnittliche Bedienbarkeit. |
| `avgCombined` | number? | Durchschnitt kombinierter Wert. |
| `categoriesCount` | integer | Anzahl der Kategorien. |
| `mostRatedTool` | ToolWithStats | Das meistbewertete Tool. |
Vollständige Feldlisten: [`AnalyticsSummary`](/docs/reference/schemas/analyticssummary),
[`TopToolEntry`](/docs/reference/schemas/topptoolentry),
[`CategoryStats`](/docs/reference/schemas/categorystats),
[`RatingDistribution`](/docs/reference/schemas/ratingdistribution).
+45
View File
@@ -0,0 +1,45 @@
---
title: Bewertungen
order: 4
---
# Bewertungen
Bewertungen sind das Herz von toolr: Sie zeigen, wie nützlich und wie gut
bedienbar ein Tool in den Augen der Community ist.
## Wie funktioniert die Bewertung?
Auf der Detailseite eines Tools vergibst du zwei Werte (15 Sterne):
- **Nützlichkeit** — Wie gut löst das Tool sein Kernproblem?
- **Bedienbarkeit** — Wie einfach ist es zu bedienen?
Optional kannst du einen **Kommentar** und deinen **Namen** hinterlassen. Die
Eingabefelder entsprechen dem Schema
[`RatingInput`](/docs/reference/schemas/ratinginput).
## Was passiert mit meiner Bewertung?
- Deine Bewertung wird sofort gespeichert und in den Durchschnittswerten des
Tools berücksichtigt.
- Jede Bewertung ist über [`GET /tools/{id}/ratings`](/docs/reference/endpoints/ratings#listtoolratings)
abrufbar.
- Der **Rating-Verlauf** über die Zeit ist über
[`GET /tools/{id}/rating-history`](/docs/reference/endpoints/tools#gettoolratinghistory)
einsehbar (grafisch auf der Detailseite).
## Datenfelder
Eine Bewertung besteht aus diesen Feldern
([`Rating`](/docs/reference/schemas/rating)):
| Feld | Typ | Bedeutung |
| --- | --- | --- |
| `id` | integer | Eindeutige ID der Bewertung. |
| `toolId` | integer | ID des bewerteten Tools. |
| `usefulness` | integer (15) | Nützlichkeitsbewertung. |
| `usability` | integer (15) | Bedienbarkeitsbewertung. |
| `comment` | string? | Optionaler Kommentar. |
| `reviewerName` | string? | Optionaler Anzeigename des Bewerters. |
| `createdAt` | date-time | Zeitpunkt der Bewertung. |
+60
View File
@@ -0,0 +1,60 @@
---
title: Datenmodell & Felder
order: 8
---
# Datenmodell & Felder
Dieses Handbuch erklärt die zentralen Objekte von toolr auf verständliche
Weise. Die **vollständige, maschinell generierte Feld-Referenz** findest du in
der [Referenz](/docs/reference/schemas/tool) — dort sind alle Typen, Pflicht-
angaben und Constraints der aktuellen Version dokumentiert.
> Die Referenz ist **versionsgebunden**: Über das Versions-Dropdown oben
> kannst du ältere API-Stände einsehen.
## Die wichtigsten Objekte
### Tool
Ein Tool ist der zentrale Eintrag im Katalog
([Feld-Referenz](/docs/reference/schemas/tool)):
| Feld | Bedeutung |
| --- | --- |
| `id` | Eindeutige ID. |
| `name` | Anzeigename. |
| `description` | Kurzbeschreibung. |
| `category` | Kategorie-Zuordnung. |
| `websiteUrl` / `iconUrl` | Offizielle Website bzw. Logo-Link (optional). |
| `createdBy` | Nutzer, der das Tool angelegt hat (optional). |
| `features` / `tags` | Listen von Schlüsselfähigkeiten bzw. Schlagwörtern. |
| `createdAt` / `updatedAt` | Zeitstempel. |
| `deletedAt` / `deletedBy` | Soft-Delete-Informationen (Papierkorb). |
> **ToolWithStats** erweitert `Tool` um die Aggregatwerte `ratingCount`,
> `avgUsefulness`, `avgUsability` und `avgCombined` (siehe
> [Feld-Referenz](/docs/reference/schemas/toolwithstats)).
### Rating
Eine Bewertung (`Rating`) besteht aus `usefulness` und `usability` (jeweils
15) sowie optionalem Kommentar und Bewerternamen. Details unter
[Bewertungen](/docs/handbook/bewertungen).
### User / AuthUser
- **User** (Admin-Sicht): `id`, `username`, `email`, `role`, `tier`,
`authProvider`, `createdAt` — siehe [Administration](/docs/handbook/administration).
- **AuthUser** (Eigenansicht): `sub`, `email`, `name`, `preferredUsername`,
`role`, `tier`, `entitlements`, `isLocal`.
### VersionInfo
`GET /version` liefert `version`, `commitSha`, `buildDate` und
`trashRetentionDays` (siehe [Feld-Referenz](/docs/reference/schemas/versioninfo)).
## Referenz selber durchsuchen
Nutze das **Suchfeld** in der Doku-Seitenleiste: Es durchsucht Handbuch,
Endpunkt- und Feldbeschreibungen und springt direkt zum passenden Anker.
+54
View File
@@ -0,0 +1,54 @@
---
title: Erste Schritte
order: 2
---
# Erste Schritte
Diese Seite führt dich durch die wichtigsten Abläufe in toolr — vom ersten
Besuch bis zum Anlegen und Bewerten eines Tools.
## 1. Anmelden
Die meisten Aktionen (Tool anlegen, bewerten, Watchlist) erfordern ein
Konto. Klicke oben rechts auf **Anmelden**. Je nach Konfiguration der Instanz
hast du zwei Möglichkeiten:
- **Lokale Konten:** Benutzername + Passwort. Der Zugang wird von einem Admin
angelegt (siehe [Administration](/docs/handbook/administration)).
- **OIDC (SSO):** Anmelden mit dem konfigurierten Identitätsanbieter.
Welcher Modus aktiv ist, steht im [Endpunkt
`GET /auth/mode`](/docs/reference/endpoints/auth#getauthmode).
## 2. Tools finden
Öffne den Bereich **Tools durchsuchen**:
- **Suchen** — Volltextsuche über Name & Beschreibung.
- **Filtern** — nach Kategorie, Tags und Features; zusätzlich
Mindestbewertung (`minRating`).
- **Sortieren** — nach Aktualität, Top-Bewertung, meistbewertet, Name
(auf-/absteigend) oder letztem Update.
Die Such-, Filter- und Sortierparameter entsprechen den Query-Parametern von
[`GET /tools`](/docs/reference/endpoints/tools#listtools).
## 3. Tool anlegen
Gehe auf **Tool hinzufügen** und fülle das Formular aus. Details zu jedem Feld
findest du im [Handbuch "Tool anlegen"](/docs/handbook/tool-anlegen) und in der
[Feld-Referenz](/docs/reference/schemas/toolinput).
## 4. Bewerten
Auf der Detailseite eines Tools kannst du **Nützlichkeit** und
**Bedienbarkeit** (jeweils 15) vergeben und optional einen Kommentar
hinterlassen. Deine Bewertung fließt sofort in die Statistiken ein.
## 5. Weiterführend
- [Tools vergleichen](/docs/handbook/vergleichen)
- [Watchlist](/docs/handbook/watchlist)
- [Analytics](/docs/handbook/analytics)
- [Administration & Papierkorb](/docs/handbook/administration)
+50
View File
@@ -0,0 +1,50 @@
---
title: Überblick
order: 1
---
# Willkommen bei toolr
toolr ist eine Plattform zum **Entdecken, Bewerten und Vergleichen von
Entwicklungstools**. Nutzer:innen pflegen einen gemeinsamen Katalog von Tools,
vergeben Bewertungen (Nützlichkeit & Bedienbarkeit) und nutzen Statistiken, um
die richtige Wahl zu treffen.
## Was kannst du mit toolr tun?
| Funktion | Beschreibung | Sichtbarkeit |
| --- | --- | --- |
| **Tools durchsuchen** | Katalog filtern, sortieren und durchsuchen | Alle |
| **Tool anlegen** | Neues Tool mit Beschreibung, Kategorie, Features & Tags eintragen | Angemeldet |
| **Bewerten** | Nützlichkeit & Bedienbarkeit (15) plus Kommentar vergeben | Angemeldet |
| **Vergleichen** | Tools nebeneinander gegenüberstellen | Premium |
| **Watchlist** | Tools als Favoriten speichern | Premium |
| **Analytics** | Statistiken, Top-Tools, Verteilungen | Alle |
| **Admin** | Nutzerverwaltung, Audit-Log, Papierkorb | Admin |
| **Trash** | Soft-gelöschte Tools wiederherstellen oder endgültig löschen | Admin |
## Funktionen & Felder im Detail
Die **Referenz** ist automatisch aus der OpenAPI-Spezifikation generiert und
deckt damit garantiert *alle* Endpunkte und Datenfelder der aktuellen Version
ab:
- [Endpunkte](/docs/reference/endpoints/tools) — jede API-Operation mit
Parametern und Antwort-Schemas.
- [Datenmodell & Felder](/docs/reference/schemas/tool) — jedes Feld mit Typ,
Pflichtstatus und Bedeutung.
> Die Referenz ist **versionsgebunden**: Wähle oben rechts eine ältere Version,
> um den API-Stand dieses Releases zu sehen.
## Erste Schritte
- Neu hier? Starte mit dem [Erste-Schritte-Guide](/docs/handbook/getting-started).
- Möchtest du ein Tool eintragen? Siehe [Tool anlegen](/docs/handbook/tool-anlegen).
- Formulare zeigen neben jedem Feld ein **Hilfe-Icon (?)**, das direkt zur
Erklärung des Felds in der Doku springt.
## Wo ist der Quellcode?
Über das **Repository-Logo oben rechts** gelangst du direkt zum Quellcode auf
GitHub.
+49
View File
@@ -0,0 +1,49 @@
---
title: Tool anlegen & bearbeiten
order: 3
---
# Tool anlegen & bearbeiten
## Neues Tool anlegen
Unter **Tool hinzufügen** legst du ein neues Tool an. Die Felder entsprechen
dem Eingabeschema [`ToolInput`](/docs/reference/schemas/toolinput):
| Feld | Pflicht | Bedeutung |
| --- | --- | --- |
| **Name** | ja | Anzeigename des Tools (min. 2 Zeichen). |
| **Beschreibung** | ja | Was tut das Tool, warum nutzen es Leute? (min. 10 Zeichen) |
| **Kategorie** | ja | Zugeordnete Kategorie (aus bestehenden Kategorien wählbar). |
| **Website-URL** | nein | Offizielle Website (`https://…`). |
| **Icon-/Logo-URL** | nein | Direktlink zu einem Logo-Bild. |
| **Features** | nein | Schlüsselfähigkeiten, z. B. „Echtzeit-Kollaboration“. Bereits bekannte Features sind auswählbar. |
| **Tags** | nein | Schlagwörter zum Auffinden. Bereits bekannte Tags sind auswählbar. |
> Hinter jedem Label findest du ein **Hilfe-Icon (?)** — es verlinkt direkt
> zur Feldbeschreibung in dieser Doku.
### Hinweise
- **Features & Tags** sind Listen. Über **+ Feature / + Tag** fügst du weitere
Einträge hinzu; über das ✕-Symbol entfernst du sie.
- URLs müssen absolut und gültig sein.
- Leere Einträge in Feature-/Tag-Listen werden beim Speichern verworfen.
## Tool bearbeiten
Auf der Detailseite eines Tools öffnet **Bearbeiten** das Formular mit den
aktuellen Werten. Du kannst Name, Beschreibung, Kategorie, URLs, Features und
Tags ändern. Nur angemeldete Nutzer:innen können Tools bearbeiten.
## Tool löschen
Über die Detailseite kannst du ein Tool **in den Papierkorb verschieben**
(Soft-Delete). Es verschwindet aus dem Katalog, bleibt aber im Papierkorb
erhalten. Siehe [Administration & Papierkorb](/docs/handbook/administration).
## Zugehörige Endpunkte
- [`POST /tools`](/docs/reference/endpoints/tools#createtool) — Tool anlegen
- [`PATCH /tools/{id}`](/docs/reference/endpoints/tools#updatetool) — Tool bearbeiten
- [`DELETE /tools/{id}`](/docs/reference/endpoints/tools#deletetool) — Tool löschen
+46
View File
@@ -0,0 +1,46 @@
---
title: Vergleichen & Watchlist
order: 5
---
# Vergleichen & Watchlist
Diese Funktionen sind für **Premium-Nutzer:innen** verfügbar.
## Tools vergleichen
Mit **Vergleichen** stellst du mehrere Tools nebeneinander und siehst deren
Daten auf einen Blick — ideal für die Tool-Auswahl.
1. Füge Tools über die **Vergleichsleiste** (Vergleichs-Icon auf Karten) hinzu.
2. Öffne den Bereich **Vergleichen**. Die Tools erscheinen in der gewählten
Reihenfolge.
3. Die Vergleichsansicht zeigt pro Tool die wichtigsten Felder und
Durchschnittswerte.
Der zugrunde liegende Endpunkt ist
[`GET /compare`](/docs/reference/endpoints/tools#listcomparetools) mit dem
Parameter `ids` (kommagetrennt). Ohne Premium-Berechtigung liefert er `403`.
## Watchlist
Die **Watchlist** ist deine persönliche Favoritenliste:
- **Hinzufügen/Entfernen:** Nutze das Lesezeichen-Symbol auf der
Tool-Karte oder der Detailseite.
- Die gespeicherte Reihenfolge bleibt erhalten.
- Sie wird über [`GET /auth/me/watchlist`](/docs/reference/endpoints/auth#getmewatchlist)
geladen; das Setzen erfolgt über
[`PUT /auth/me/preferences`](/docs/reference/endpoints/auth#updatemepreferences)
(Feld `watchlist`).
### Feld `watchlist`
Das Feld `watchlist` in [`UserPreferences`](/docs/reference/schemas/userpreferences)
ist ein Array von Tool-IDs in gespeicherter Reihenfolge:
| Feld | Typ | Bedeutung |
| --- | --- | --- |
| `view` | string | Ansichtsmodus (`grid`, `table`, `rows`). |
| `density` | string | Dichte (`cozy`, `compact`). |
| `watchlist` | integer[] | Tool-IDs in Favoriten-Reihenfolge. |
+35
View File
@@ -0,0 +1,35 @@
# v0.7.0 — Release Notes
**Datum:** 2026-08-03 · **Tag:** [`v0.7.0`](https://git.kubebase.de/admin/tool-evaluator/tags/v0.7.0)
## Neue Features
- Version-gebundene **Release-Dokumentation** in der App unter `/docs`
(Index + Detailseite je Version, Markdown aus `docs/releases/`).
- Generiertes Release-Vorlage (`docs/releases/TEMPLATE.md`) und
Sync-Schritt für den Frontend-Build.
## Fixes & Verbesserungen
- `tsx` auf 4.23.4 angehoben — letzte veraltete Abhängigkeit im Workspace
(`pnpm outdated -r` ist jetzt leer).
## API-Änderungen
- Keine Breaking Changes an der API.
## Betrieb / Upgrade
- **Env-Vars:** unverändert.
- **Migration:** keine.
- **Breaking Changes:** keine.
## Bekannte Einschränkungen
- Die Doku ist bisher auf Release-Notes beschränkt; eine vollständige
API-/Feld-Referenz folgt in v0.8.0.
## Links
- Commit: [`520f917`](https://git.kubebase.de/admin/tool-evaluator/commit/520f917)
- Tag: [`v0.7.0`](https://git.kubebase.de/admin/tool-evaluator/tags/v0.7.0)
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
# v0.8.0 — Release Notes
**Datum:** 2026-08-03 · **Tag:** [`v0.8.0`](https://git.kubebase.de/admin/tool-evaluator/tags/v0.8.0)
## Neue Features
- **Vollständige Dokumentations-Site** in mkdocs-Optik unter `/docs`:
- **Handbuch** mit verständlichen Erklärungen zu allen Features
(Erste Schritte, Tool anlegen, Bewertungen, Vergleichen, Watchlist,
Analytics, Administration, Datenmodell).
- **Automatisch generierte Referenz** aus `lib/api-spec/openapi.yaml`:
alle Endpunkte und Datenfelder (Typ, Pflichtstatus, Constraints) —
damit ist garantiert, dass *jedes* Feature dokumentiert ist.
- **Suche** über Handbuch, Endpunkte und Felder.
- **Versions-Dropdown**: ältere Releases behalten ihre vollständige
Feld-/Endpunkt-Referenz als Snapshot.
- **Repo-Link** oben rechts zur Quelle.
- **Hilfe-Buttons (?) in Formularen** (NetBox-Stil): neben jedem Feld
springt ein Icon direkt zur Feldbeschreibung in der Doku.
## Fixes & Verbesserungen
- Doku-Generator `scripts/src/generate-docs.mjs` ersetzt den bisherigen
`sync-release-docs.mjs` (OpenAPI-Parsing, Handbuch, Suchindex, Snapshots).
- Dokumentation für v0.7.0 nachgezogen.
## API-Änderungen
- Keine Breaking Changes an der API.
## Betrieb / Upgrade
- **Env-Vars:** unverändert.
- **Migration:** keine.
- **Breaking Changes:** keine.
## Bekannte Einschränkungen
- Handbuch & Referenz gelten für die aktuelle Version; ältere Versionen
zeigen ihre Release-Notes und einen Referenz-Snapshot, sofern beim Release
erzeugt (`node scripts/src/generate-docs.mjs --snapshot vX.Y.Z`).
## Links
- Commit: [`<short-sha>`](https://git.kubebase.de/admin/tool-evaluator/commit/<short-sha>)
- Tag: [`v0.8.0`](https://git.kubebase.de/admin/tool-evaluator/tags/v0.8.0)
File diff suppressed because it is too large Load Diff
+13
View File
@@ -78,6 +78,9 @@ catalogs:
wouter: wouter:
specifier: 3.10.0 specifier: 3.10.0
version: 3.10.0 version: 3.10.0
yaml:
specifier: 2.9.0
version: 2.9.0
zod: zod:
specifier: 4.4.3 specifier: 4.4.3
version: 4.4.3 version: 4.4.3
@@ -718,6 +721,9 @@ importers:
tsx: tsx:
specifier: 'catalog:' specifier: 'catalog:'
version: 4.23.4 version: 4.23.4
yaml:
specifier: 'catalog:'
version: 2.9.0
packages: packages:
@@ -3751,6 +3757,11 @@ packages:
engines: {node: '>= 14.6'} engines: {node: '>= 14.6'}
hasBin: true hasBin: true
yaml@2.9.0:
resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==}
engines: {node: '>= 14.6'}
hasBin: true
yocto-queue@1.2.2: yocto-queue@1.2.2:
resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==}
engines: {node: '>=12.20'} engines: {node: '>=12.20'}
@@ -6596,6 +6607,8 @@ snapshots:
yaml@2.8.4: {} yaml@2.8.4: {}
yaml@2.9.0: {}
yocto-queue@1.2.2: {} yocto-queue@1.2.2: {}
yoctocolors@2.1.2: {} yoctocolors@2.1.2: {}
+1
View File
@@ -73,6 +73,7 @@ catalog:
tsx: 4.23.4 tsx: 4.23.4
vite: 8.2.0 vite: 8.2.0
wouter: 3.10.0 wouter: 3.10.0
yaml: 2.9.0
zod: 4.4.3 zod: 4.4.3
autoInstallPeers: false autoInstallPeers: false
+2 -1
View File
@@ -9,6 +9,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/node": "catalog:", "@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);
});