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,
"type": "module",
"scripts": {
"dev": "node ../../scripts/src/sync-release-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",
"dev": "node ../../scripts/src/generate-docs.mjs && vite --config vite.config.ts --host 0.0.0.0",
"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",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
+1 -2
View File
@@ -47,8 +47,7 @@ function Router() {
<Route path="/admin" component={Admin} />
<Route path="/admin/redundancy" component={Redundancy} />
<Route path="/trash" component={Trash} />
<Route path="/docs" component={Docs} />
<Route path="/docs/:version" component={Docs} />
<Route path="/docs/*?" component={Docs} />
<Route component={NotFound} />
</Switch>
);
@@ -38,9 +38,30 @@ function buildCrumbs(location: string, t: TFunction): Crumb[] {
} else if (location.startsWith("/analytics")) {
crumbs.push({ label: t("nav.analytics") });
} else if (location.startsWith("/docs")) {
crumbs.push({ href: "/docs", label: t("nav.docs") });
const match = location.match(/^\/docs\/(.+)$/);
if (match) crumbs.push({ label: match[1] });
crumbs.push({ href: "/docs", label: t("docs.title") });
const m = location.replace(/^\/docs\/?/, "");
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")) {
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"
},
"docs": {
"title": "Versionsdokumentation",
"subtitle": "Version-gebundene Dokumentation je Release — was ist neu, was hat sich geändert und was beim Upgrade zu beachten ist.",
"title": "Dokumentation",
"subtitle": "Version-gebundene Dokumentation Release-Notes, Endpunkte und Datenfelder je Version.",
"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": {
"navigate": "Navigation",
+15 -3
View File
@@ -183,10 +183,22 @@
"backHome": "Back to Home"
},
"docs": {
"title": "Release Documentation",
"subtitle": "Version-bound documentation for each release — what's new, what changed, and what to know when upgrading.",
"title": "Documentation",
"subtitle": "Version-bound documentation — release notes, endpoints and data fields per version.",
"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": {
"navigate": "Navigate",
+19
View File
@@ -265,6 +265,25 @@
*/
@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 */
input[type="search"]::-webkit-search-cancel-button {
@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";
import { customFetch } from "@workspace/api-client-react";
import { recordRecentTool } from "@/lib/recent-tools";
import { FieldHelp } from "@/components/field-help";
const ratingSchema = z.object({
usefulness: z.number().min(1).max(5),
@@ -776,7 +777,10 @@ export default function ToolDetail() {
name="usefulness"
render={({ field }) => (
<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">
<RatingStars
value={field.value}
@@ -794,7 +798,10 @@ export default function ToolDetail() {
name="usability"
render={({ field }) => (
<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">
<RatingStars
value={field.value}
@@ -814,7 +821,10 @@ export default function ToolDetail() {
name="comment"
render={({ field }) => (
<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>
<Textarea
placeholder="What do you think about this tool?"
@@ -832,7 +842,10 @@ export default function ToolDetail() {
name="reviewerName"
render={({ field }) => (
<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>
<Input placeholder="Anonymous" {...field} />
</FormControl>
+32 -10
View File
@@ -30,6 +30,7 @@ import { CategoryCombobox } from "@/components/category-combobox";
import { FeatureInput } from "@/components/feature-input";
import { TagInput } from "@/components/tag-input";
import { useAuth } from "@/hooks/use-auth";
import { FieldHelp } from "@/components/field-help";
const toolSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
@@ -171,7 +172,10 @@ export default function ToolEdit() {
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormLabel className="inline-flex items-center gap-1.5">
Name
<FieldHelp schema="ToolInput" field="name">Name</FieldHelp>
</FormLabel>
<FormControl>
<Input placeholder="Tool name" {...field} />
</FormControl>
@@ -184,7 +188,10 @@ export default function ToolEdit() {
name="category"
render={({ field }) => (
<FormItem>
<FormLabel>Category</FormLabel>
<FormLabel className="inline-flex items-center gap-1.5">
Category
<FieldHelp schema="ToolInput" field="category">Category</FieldHelp>
</FormLabel>
<FormControl>
<CategoryCombobox value={field.value} onChange={field.onChange} />
</FormControl>
@@ -198,8 +205,11 @@ export default function ToolEdit() {
control={form.control}
name="websiteUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Website URL (Optional)</FormLabel>
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
Website URL (Optional)
<FieldHelp schema="ToolInput" field="websiteUrl">Website URL</FieldHelp>
</FormLabel>
<FormControl>
<Input placeholder="https://..." type="url" {...field} />
</FormControl>
@@ -212,8 +222,11 @@ export default function ToolEdit() {
control={form.control}
name="iconUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Icon / Logo URL (Optional)</FormLabel>
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
Icon / Logo URL (Optional)
<FieldHelp schema="ToolInput" field="iconUrl">Icon / Logo URL</FieldHelp>
</FormLabel>
<FormControl>
<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">
@@ -245,8 +258,11 @@ export default function ToolEdit() {
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
Description
<FieldHelp schema="ToolInput" field="description">Description</FieldHelp>
</FormLabel>
<FormControl>
<Textarea
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="flex justify-between items-center">
<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>
</div>
<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="flex justify-between items-center">
<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>
</div>
<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 { TagInput } from "@/components/tag-input";
import { useAuth } from "@/hooks/use-auth";
import { FieldHelp } from "@/components/field-help";
const toolSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
@@ -134,7 +135,10 @@ export default function ToolNew() {
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormLabel className="inline-flex items-center gap-1.5">
Name
<FieldHelp schema="ToolInput" field="name">Name</FieldHelp>
</FormLabel>
<FormControl>
<Input placeholder="e.g. React, Next.js, Postgres" {...field} data-testid="input-tool-name" />
</FormControl>
@@ -148,7 +152,10 @@ export default function ToolNew() {
name="category"
render={({ field }) => (
<FormItem>
<FormLabel>Category</FormLabel>
<FormLabel className="inline-flex items-center gap-1.5">
Category
<FieldHelp schema="ToolInput" field="category">Category</FieldHelp>
</FormLabel>
<FormControl>
<CategoryCombobox
value={field.value}
@@ -161,12 +168,15 @@ export default function ToolNew() {
/>
</div>
<FormField
control={form.control}
name="websiteUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Website URL (Optional)</FormLabel>
<FormField
control={form.control}
name="websiteUrl"
render={({ field }) => (
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
Website URL (Optional)
<FieldHelp schema="ToolInput" field="websiteUrl">Website URL</FieldHelp>
</FormLabel>
<FormControl>
<Input placeholder="https://..." type="url" {...field} data-testid="input-tool-url" />
</FormControl>
@@ -180,7 +190,10 @@ export default function ToolNew() {
name="iconUrl"
render={({ field }) => (
<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>
<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">
@@ -214,7 +227,10 @@ export default function ToolNew() {
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormLabel className="inline-flex items-center gap-1.5">
Description
<FieldHelp schema="ToolInput" field="description">Description</FieldHelp>
</FormLabel>
<FormControl>
<Textarea
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="flex justify-between items-center">
<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>
</div>
<Button
@@ -284,7 +303,10 @@ export default function ToolNew() {
<div className="space-y-4 pt-4 border-t">
<div className="flex justify-between items-center">
<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>
</div>
<Button