Compare commits

...

7 Commits

Author SHA1 Message Date
opencode 56f1edd613 docs: link v0.8.1 release notes to commit fdf2d74
Build & Push Docker Image / build (push) Successful in 2m30s
2026-08-03 22:52:23 +02:00
opencode fdf2d741a1 feat(docs): standalone docs page, Hilfe nav entry, rewritten user guide
- docs route renders without the app shell (own header, back-to-app link,
  theme toggle)
- nav entry renamed to Hilfe (de) / Help (en)
- reference links now case-insensitive (schema/tag slugs)
- handbook rewritten as 17-page user guide with links into the API reference
- release notes v0.8.1 added
2026-08-03 22:52:14 +02:00
opencode 9ceb11e7f9 docs: link v0.8.0 release notes to commit 6c92b63
Build & Push Docker Image / build (push) Successful in 3m14s
2026-08-03 22:16:00 +02:00
opencode 6c92b6358d 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
2026-08-03 22:15:54 +02:00
opencode 520f917723 feat(docs): version-bound release documentation served at /docs
Build & Push Docker Image / build (push) Successful in 2m35s
Adds a docs pipeline so each release has a version-bound Markdown
document (docs/releases/vX.Y.Z.md) rendered publicly in the app:

- sync-release-docs.mjs copies docs/releases/*.md into the toolrate
  public dir and generates index.json before every dev/build
- /docs lists all releases; /docs/:version renders the sanitized
  Markdown (marked + DOMPurify, typography styles)
- template + workflow documented in docs/README.md
- current release (v0.6.0) documented as the first entry
2026-08-03 16:41:08 +02:00
opencode d1dd77bc1e chore(deps): bump tsx to 4.23.4 (only remaining outdated package)
Build & Push Docker Image / build (push) Successful in 3m56s
2026-08-03 15:33:17 +02:00
opencode 0be45b6513 ci: drop nightly and sha image tags, deploy only on v* tags
Build & Push Docker Image / build (push) Successful in 2m18s
Branch pushes now build and push only 'latest'; tag pushes add the
v*-tag and update the k8s manifest. Removes the daily nightly-* and
per-commit sha-* tags that accumulated registry storage.
2026-08-03 14:10:03 +02:00
42 changed files with 8311 additions and 58 deletions
+8 -12
View File
@@ -38,33 +38,29 @@ jobs:
FULL_SHA=$(git rev-parse HEAD)
IMAGE="git.kubebase.de/${{ gitea.repository }}"
DATE_STAMP=$(date -u +"%Y%m%d")
VERSION="dev-$(date -u +"%Y%m%d-%H%M")"
TAGS="-t ${IMAGE}:latest"
if [ "${{ gitea.ref_type }}" = "tag" ]; then
VERSION="${{ gitea.ref_name }}"
VERSION_TAG="${{ gitea.ref_name }}"
else
VERSION="dev-$(date -u +"%Y%m%d-%H%M")"
VERSION_TAG="nightly-${DATE_STAMP}"
TAGS="${TAGS} -t ${IMAGE}:${VERSION}"
fi
TAGS="-t ${IMAGE}:sha-${SHA} -t ${IMAGE}:latest -t ${IMAGE}:${VERSION_TAG}"
docker build --no-cache \
--build-arg COMMIT_SHA="$FULL_SHA" \
--build-arg BUILD_DATE="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \
--build-arg VERSION="$VERSION" \
$TAGS .
docker push "${IMAGE}:sha-${SHA}"
docker push "${IMAGE}:latest"
docker push "${IMAGE}:${VERSION_TAG}"
if [ "${{ gitea.ref_type }}" = "tag" ]; then
docker push "${IMAGE}:${VERSION}"
fi
- name: Update k8s manifest in admin/apps
if: gitea.ref_type == 'tag'
env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
SHA=$(git rev-parse --short HEAD)
if [ "${{ gitea.ref_type }}" = "tag" ]; then
NEWTAG="${{ gitea.ref_name }}"
else
NEWTAG="sha-${SHA}"
fi
NEWTAG="${{ gitea.ref_name }}"
git clone "https://admin:${GITEA_TOKEN}@git.kubebase.de/admin/apps.git" /tmp/apps
cd /tmp/apps
cd apps/system/toolrate/overlays/k3s
+3
View File
@@ -47,3 +47,6 @@ Thumbs.db
# Replit
.cache/
.local/
# Generated release docs (produced by scripts/src/sync-release-docs.mjs during build)
/artifacts/toolrate/public/docs/
+4 -2
View File
@@ -4,8 +4,8 @@
"private": true,
"type": "module",
"scripts": {
"dev": "vite --config vite.config.ts --host 0.0.0.0",
"build": "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"
},
@@ -54,11 +54,13 @@
"clsx": "catalog:",
"cmdk": "1.1.1",
"date-fns": "4.4.0",
"dompurify": "catalog:",
"embla-carousel-react": "8.6.0",
"framer-motion": "catalog:",
"i18next": "26.3.6",
"input-otp": "1.4.2",
"lucide-react": "catalog:",
"marked": "catalog:",
"next-themes": "0.4.6",
"react": "catalog:",
"react-day-picker": "10.0.1",
+2
View File
@@ -20,6 +20,7 @@ import Trash from "@/pages/trash";
import Compare from "@/pages/compare";
import Watchlist from "@/pages/watchlist";
import Login from "@/pages/login";
import Docs from "@/pages/docs";
import NotFound from "@/pages/not-found";
const queryClient = new QueryClient({
@@ -46,6 +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 component={NotFound} />
</Switch>
);
@@ -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>
);
}
+2 -1
View File
@@ -1,5 +1,5 @@
import { Link, useLocation } from "wouter";
import { LayoutDashboard, Wrench, BarChart3, LogIn, LogOut, ShieldCheck, AlertTriangle, Search } from "lucide-react";
import { LayoutDashboard, Wrench, BarChart3, FileText, LogIn, LogOut, ShieldCheck, AlertTriangle, Search } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useAuth } from "@/hooks/use-auth";
import { useGetVersion, getGetVersionQueryKey } from "@workspace/api-client-react";
@@ -39,6 +39,7 @@ export function Layout({ children }: { children: React.ReactNode }) {
{ href: "/", label: t("nav.home"), icon: LayoutDashboard },
{ href: "/tools", label: t("nav.browseTools"), icon: Wrench },
{ href: "/analytics", label: t("nav.analytics"), icon: BarChart3 },
{ href: "/docs", label: t("nav.docs"), icon: FileText },
];
const adminLinks = [
@@ -12,6 +12,7 @@
"trash": "Papierkorb",
"admin": "Admin",
"redundancy": "Redundanz",
"docs": "Hilfe",
"search": "Tools suchen…"
},
"auth": {
@@ -181,6 +182,35 @@
"text": "Diese Seite existiert nicht.",
"backHome": "Zurück zur Startseite"
},
"docs": {
"title": "Dokumentation",
"subtitle": "Version-gebundene Dokumentation — Release-Notes, Endpunkte und Datenfelder je Version.",
"backToIndex": "Alle Releases",
"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…",
"backToApp": "Zur App",
"field": "Feld",
"type": "Typ",
"required": "Pflicht",
"description": "Beschreibung",
"status": "Status",
"schema": "Schema",
"parameter": "Parameter",
"noResults": "Keine Treffer",
"fields": "Felder",
"fieldHelpHint": "Hinweis: Formular-Felder verlinken per ?-Icon direkt zu den jeweiligen Zeilen dieser Tabelle."
},
"command": {
"navigate": "Navigation",
"recent": "Zuletzt besucht",
@@ -12,6 +12,7 @@
"trash": "Trash",
"admin": "Admin",
"redundancy": "Redundancy",
"docs": "Help",
"search": "Search tools…"
},
"auth": {
@@ -181,6 +182,35 @@
"text": "This page doesn't exist.",
"backHome": "Back to Home"
},
"docs": {
"title": "Documentation",
"subtitle": "Version-bound documentation — release notes, endpoints and data fields per version.",
"backToIndex": "All releases",
"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…",
"backToApp": "Back to app",
"field": "Field",
"type": "Type",
"required": "Required",
"description": "Description",
"status": "Status",
"schema": "Schema",
"parameter": "Parameter",
"noResults": "No results",
"fields": "Fields",
"fieldHelpHint": "Note: form fields link via the ? icon directly to the respective rows of this table."
},
"command": {
"navigate": "Navigate",
"recent": "Recent",
+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;
+983
View File
@@ -0,0 +1,983 @@
import { useEffect, useMemo, useState } from "react";
import { Link, useLocation } from "wouter";
import { Marked } from "marked";
import DOMPurify from "dompurify";
import { useTranslation } from "react-i18next";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { ThemeToggle } from "@/components/theme-toggle";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useGetVersion, getGetVersionQueryKey } from "@workspace/api-client-react";
import {
ArrowLeft,
BookOpen,
CalendarDays,
ExternalLink,
FileText,
GitBranch,
HelpCircle,
Library,
Search,
Server,
Tag,
Wrench,
type LucideIcon,
} from "lucide-react";
const DOCS_BASE = `${import.meta.env.BASE_URL.replace(/\/$/, "")}/docs`;
const REPO_URL = "https://git.kubebase.de/admin/tool-evaluator";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type FieldType = { kind: "ref" | "type" | "array"; value: string };
type Parameter = {
name: string;
in: string;
required: boolean;
type: FieldType;
description: string;
constraints: string;
};
type Endpoint = {
operationId: string;
method: string;
path: string;
summary: string;
description: string;
parameters: Parameter[];
requestBody: { required: boolean; schema: FieldType } | null;
responses: { status: string; description: string; schema: FieldType }[];
};
type TagGroup = { name: string; description: string; endpoints: Endpoint[] };
type Field = {
name: string;
type: FieldType;
required: boolean;
description: string;
constraints: string;
};
type SchemaModel = { name: string; description: string; fields: Field[] };
type Reference = { tags: TagGroup[]; schemas: SchemaModel[] };
type ReleaseDoc = {
version: string;
file: string;
title: string;
date: string | null;
hasReference: boolean;
};
type HandbookPage = { slug: string; file: string; title: string; order: number };
type SearchEntry = { title: string; href: string; kind: string; text: string };
type Heading = { id: string; text: string; level: number };
// ---------------------------------------------------------------------------
// Data hooks
// ---------------------------------------------------------------------------
function fetchJson<T>(url: string): Promise<T> {
return fetch(url).then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<T>;
});
}
function useJson<T>(url: string | null) {
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState(false);
useEffect(() => {
if (!url) {
setData(null);
setError(false);
return;
}
let cancelled = false;
setData(null);
setError(false);
fetchJson<T>(url)
.then((d) => {
if (!cancelled) setData(d);
})
.catch(() => {
if (!cancelled) setError(true);
});
return () => {
cancelled = true;
};
}, [url]);
return { data, error };
}
function useMarkdown(file: string | null) {
const [state, setState] = useState<{ html: string; headings: Heading[] } | null>(null);
const [error, setError] = useState(false);
useEffect(() => {
if (!file) {
setState(null);
setError(false);
return;
}
let cancelled = false;
setState(null);
setError(false);
fetch(`${DOCS_BASE}/${file}`)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.text();
})
.then((md) => {
const rendered = renderMarkdown(md);
if (!cancelled) setState(rendered);
})
.catch(() => {
if (!cancelled) setError(true);
});
return () => {
cancelled = true;
};
}, [file]);
return { ...state, error };
}
// ---------------------------------------------------------------------------
// Markdown rendering with heading anchors + TOC
// ---------------------------------------------------------------------------
function slugify(text: string): string {
return text
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, "")
.replace(/[\s_]+/g, "-")
.replace(/-+/g, "-");
}
function renderMarkdown(md: string): { html: string; headings: Heading[] } {
const headings: Heading[] = [];
const seen = new Map<string, number>();
const renderer = {
heading({ tokens, depth }: { tokens: { raw: string; text?: string }[]; depth: number }) {
const text = tokens.map((t) => t.text ?? t.raw).join("");
let id = slugify(text);
const count = seen.get(id) ?? 0;
seen.set(id, count + 1);
if (count > 0) id = `${id}-${count}`;
headings.push({ id, text, level: depth });
return `<h${depth} id="${id}"><a href="#${id}" class="docs-anchor" aria-hidden="true"></a>${text}</h${depth}>`;
},
};
const marked = new Marked({ gfm: true, async: false, renderer });
const html = marked.parse(md) as string;
return { html: DOMPurify.sanitize(html), headings };
}
// ---------------------------------------------------------------------------
// Version resolution
// ---------------------------------------------------------------------------
function parseDocsPath(location: string) {
const rest = location.replace(/^\/docs\/?/, "");
const segments = rest.split("/").filter(Boolean);
const versionRe = /^v\d+\.\d+\.\d+$/;
if (segments.length > 0 && versionRe.test(segments[0])) {
return { version: segments[0], path: segments.slice(1) };
}
return { version: null, path: segments };
}
// ---------------------------------------------------------------------------
// Field type helpers
// ---------------------------------------------------------------------------
function fieldTypeLabel(t: FieldType): string {
if (t.kind === "array") return `${t.value}[]`;
return t.value;
}
function isLinkableType(t: FieldType): boolean {
return t.kind === "ref" || (t.kind === "array" && /^[A-Z]/.test(t.value));
}
function resolveTypeHref(t: FieldType): string | null {
if (t.kind === "ref") return `/docs/reference/schemas/${t.value}`;
if (t.kind === "array" && /^[A-Z]/.test(t.value)) return `/docs/reference/schemas/${t.value}`;
return null;
}
const METHOD_STYLES: Record<string, string> = {
GET: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-400",
POST: "bg-blue-500/15 text-blue-700 dark:text-blue-400",
PATCH: "bg-amber-500/15 text-amber-700 dark:text-amber-400",
PUT: "bg-indigo-500/15 text-indigo-700 dark:text-indigo-400",
DELETE: "bg-red-500/15 text-red-700 dark:text-red-400",
};
// ---------------------------------------------------------------------------
// Sub-views
// ---------------------------------------------------------------------------
function DocsHeader({
versions,
activeVersion,
onVersionChange,
onSearchChange,
}: {
versions: ReleaseDoc[];
activeVersion: string | null;
onVersionChange: (v: string | null) => void;
onSearchChange?: (q: string) => void;
}) {
const { t } = useTranslation();
return (
<div className="flex flex-wrap items-center justify-between gap-3 border-b pb-4">
<div className="flex items-center gap-2 min-w-0">
<BookOpen className="h-5 w-5 text-primary shrink-0" />
<h1 className="text-xl font-bold tracking-tight truncate">{t("docs.title")}</h1>
</div>
<div className="flex items-center gap-2">
{onSearchChange && (
<div className="relative hidden md:block">
<Search className="h-4 w-4 absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder={t("docs.searchPlaceholder")}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-8 w-52"
data-testid="input-docs-search"
/>
</div>
)}
{versions.length > 0 && (
<Select
value={activeVersion ?? "latest"}
onValueChange={(v) => onVersionChange(v === "latest" ? null : v)}
>
<SelectTrigger className="w-[140px]" data-testid="select-docs-version">
<SelectValue placeholder={t("docs.version")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="latest">
{activeVersion === null ? `${t("docs.latest")}` : t("docs.latest")}
</SelectItem>
{versions.map((v) => (
<SelectItem key={v.version} value={v.version}>
{activeVersion === v.version ? `${v.version}` : v.version}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<Button variant="ghost" size="icon" asChild data-testid="button-docs-repo" title={t("docs.repo")}>
<a href={REPO_URL} target="_blank" rel="noreferrer">
<GitBranch className="h-4 w-4" />
</a>
</Button>
</div>
</div>
);
}
function DocsNav({
version,
handbook,
reference,
versions,
}: {
version: string | null;
handbook: HandbookPage[] | null;
reference: Reference | null;
versions: ReleaseDoc[];
}) {
const [location] = useLocation();
const { t } = useTranslation();
const navLink = (href: string) => {
const active = location === href || (href !== "/docs" && location.startsWith(href));
return active;
};
const groups: { label: string; icon: LucideIcon; items: { href: string; label: string; active: boolean }[] }[] = [];
if (handbook && handbook.length > 0 && version === null) {
groups.push({
label: t("docs.guides"),
icon: BookOpen,
items: handbook.map((p) => ({
href: `/docs/handbook/${p.slug}`,
label: p.title,
active: navLink(`/docs/handbook/${p.slug}`),
})),
});
}
if (reference) {
groups.push({
label: t("docs.endpoints"),
icon: Server,
items: reference.tags.map((tag) => ({
href: `/docs/reference/endpoints/${tag.name}`,
label: tag.name,
active: navLink(`/docs/reference/endpoints/${tag.name}`),
})),
});
groups.push({
label: t("docs.schemas"),
icon: Library,
items: reference.schemas.map((s) => ({
href: `/docs/reference/schemas/${s.name}`,
label: s.name,
active: navLink(`/docs/reference/schemas/${s.name}`),
})),
});
}
groups.push({
label: t("docs.releases"),
icon: Tag,
items: versions.map((v) => ({
href: v.version === (version ?? versions[0]?.version) && version !== null
? `/docs/releases/${v.version}`
: `/docs/releases/${v.version}`,
label: v.version,
active: navLink(`/docs/releases/${v.version}`),
})),
});
return (
<nav className="space-y-6" aria-label={t("docs.nav")}>
{groups.map((group) => (
<div key={group.label}>
<div className="mb-1.5 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
<group.icon className="h-3.5 w-3.5" />
{group.label}
</div>
<ul className="space-y-0.5">
{group.items.map((item) => (
<li key={item.href}>
<Link
href={item.href}
className={`block rounded-md px-2 py-1.5 text-sm transition-colors ${
item.active
? "bg-accent text-accent-foreground font-medium"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
}`}
>
<span className="truncate block">{item.label}</span>
</Link>
</li>
))}
</ul>
</div>
))}
</nav>
);
}
function Toc({ headings, title }: { headings: Heading[]; title?: string }) {
const [activeId, setActiveId] = useState<string | null>(null);
const { t } = useTranslation();
useEffect(() => {
if (headings.length === 0) return;
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) setActiveId(entry.target.id);
}
},
{ rootMargin: "-80px 0px -70% 0px" },
);
for (const h of headings) {
const el = document.getElementById(h.id);
if (el) observer.observe(el);
}
return () => observer.disconnect();
}, [headings]);
if (headings.length === 0) return null;
return (
<aside className="hidden xl:block" aria-label={t("docs.onThisPage")}>
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{title ?? t("docs.onThisPage")}
</p>
<ul className="space-y-1 border-l">
{headings.map((h) => (
<li key={h.id} style={{ paddingLeft: `${Math.min(h.level - 1, 2)}rem` }}>
<a
href={`#${h.id}`}
className={`block border-l -ml-px px-2 py-0.5 text-xs transition-colors ${
activeId === h.id
? "border-primary text-foreground font-medium"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
{h.text}
</a>
</li>
))}
</ul>
</aside>
);
}
function MarkdownView({ file }: { file: string | null }) {
const { t } = useTranslation();
const { html, headings, error } = useMarkdown(file);
return (
<div className="flex flex-col lg:flex-row gap-8">
<div className="flex-1 min-w-0 max-w-3xl">
{error ? (
<p className="text-sm text-muted-foreground">{t("docs.noDocs")}</p>
) : !html ? (
<div className="space-y-3">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
) : (
<div
className="docs-prose prose dark:prose-invert max-w-none"
dangerouslySetInnerHTML={{ __html: html }}
/>
)}
</div>
<Toc headings={headings ?? []} />
</div>
);
}
function FieldTypeChip({ type }: { type: FieldType }) {
const href = resolveTypeHref(type);
const label = fieldTypeLabel(type);
if (href) {
return (
<Link href={href} className="inline-flex">
<Badge variant="secondary" className="font-mono hover:bg-accent">
{label}
</Badge>
</Link>
);
}
return <Badge variant="secondary" className="font-mono">{label}</Badge>;
}
function FieldTable({ fields }: { fields: Field[] }) {
const { t } = useTranslation();
return (
<div className="overflow-x-auto rounded-lg border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50 text-left text-xs uppercase tracking-wider text-muted-foreground">
<th className="px-3 py-2 font-semibold">{t("docs.field")}</th>
<th className="px-3 py-2 font-semibold">{t("docs.type")}</th>
<th className="px-3 py-2 font-semibold">{t("docs.required")}</th>
<th className="px-3 py-2 font-semibold">{t("docs.description")}</th>
</tr>
</thead>
<tbody>
{fields.map((f) => (
<tr key={f.name} id={f.name} className="border-b last:border-0 align-top">
<td className="px-3 py-2">
<a href={`#${f.name}`} className="font-mono text-primary hover:underline">
{f.name}
</a>
</td>
<td className="px-3 py-2">
<FieldTypeChip type={f.type} />
</td>
<td className="px-3 py-2">
{f.required ? (
<Badge className="bg-primary/10 text-primary border-primary/20">required</Badge>
) : (
<span className="text-muted-foreground"></span>
)}
</td>
<td className="px-3 py-2">
<div className="text-muted-foreground">{f.description}</div>
{f.constraints && (
<div className="mt-0.5 text-xs text-muted-foreground/70 font-mono">{f.constraints}</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function SchemaView({ schema }: { schema: SchemaModel }) {
const { t } = useTranslation();
const headings: Heading[] = schema.fields.map((f) => ({ id: f.name, text: f.name, level: 2 }));
return (
<div className="flex flex-col lg:flex-row gap-8">
<div className="flex-1 min-w-0 max-w-3xl space-y-4">
<div className="space-y-1">
<h1 className="text-2xl font-bold tracking-tight font-mono">{schema.name}</h1>
{schema.description && <p className="text-muted-foreground">{schema.description}</p>}
</div>
<FieldTable fields={schema.fields} />
<p className="text-xs text-muted-foreground">
<HelpCircle className="h-3.5 w-3.5 inline mr-1" />
{t("docs.fieldHelpHint")}
</p>
</div>
<Toc headings={headings} title={t("docs.fields")} />
</div>
);
}
function EndpointTagView({ tag }: { tag: TagGroup }) {
const { t } = useTranslation();
const headings: Heading[] = tag.endpoints.map((e) => ({
id: e.operationId,
text: `${e.method} ${e.path}`,
level: 2,
}));
return (
<div className="flex flex-col lg:flex-row gap-8">
<div className="flex-1 min-w-0 max-w-3xl space-y-8">
<div className="space-y-1">
<h1 className="text-2xl font-bold tracking-tight">{tag.name}</h1>
{tag.description && <p className="text-muted-foreground">{tag.description}</p>}
</div>
{tag.endpoints.map((ep) => (
<section key={ep.operationId} id={ep.operationId} className="scroll-mt-20">
<div className="mb-2 flex items-center gap-2">
<Badge className={`font-mono ${METHOD_STYLES[ep.method] ?? "bg-muted text-muted-foreground"}`}>
{ep.method}
</Badge>
<code className="font-mono text-sm">{ep.path}</code>
<a href={`#${ep.operationId}`} className="ml-auto text-muted-foreground hover:text-foreground">
<ExternalLink className="h-3.5 w-3.5" />
</a>
</div>
<h2 className="mb-1 text-lg font-semibold">{ep.summary}</h2>
{ep.description && <p className="mb-3 text-sm text-muted-foreground">{ep.description}</p>}
{ep.parameters.length > 0 && (
<div className="mb-3">
<p className="mb-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground">{t("docs.parameter")}</p>
<div className="overflow-x-auto rounded-lg border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50 text-left text-xs uppercase tracking-wider text-muted-foreground">
<th className="px-3 py-2 font-semibold">Name</th>
<th className="px-3 py-2 font-semibold">In</th>
<th className="px-3 py-2 font-semibold">{t("docs.type")}</th>
<th className="px-3 py-2 font-semibold">{t("docs.required")}</th>
<th className="px-3 py-2 font-semibold">{t("docs.description")}</th>
</tr>
</thead>
<tbody>
{ep.parameters.map((p) => (
<tr key={`${p.name}-${p.in}`} className="border-b last:border-0">
<td className="px-3 py-1.5 font-mono">{p.name}</td>
<td className="px-3 py-1.5 text-muted-foreground">{p.in}</td>
<td className="px-3 py-1.5"><FieldTypeChip type={p.type} /></td>
<td className="px-3 py-1.5">
{p.required ? <Badge className="bg-primary/10 text-primary border-primary/20">req</Badge> : ""}
</td>
<td className="px-3 py-1.5 text-muted-foreground">
{p.description}
{p.constraints && <span className="block font-mono text-xs">{p.constraints}</span>}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{ep.requestBody && (
<div className="mb-3 rounded-lg border bg-muted/30 p-3">
<p className="mb-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Request Body {ep.requestBody.required && <Badge className="ml-1">required</Badge>}
</p>
<FieldTypeChip type={ep.requestBody.schema} />
</div>
)}
<div className="overflow-x-auto rounded-lg border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50 text-left text-xs uppercase tracking-wider text-muted-foreground">
<th className="px-3 py-2 font-semibold">{t("docs.status")}</th>
<th className="px-3 py-2 font-semibold">{t("docs.description")}</th>
<th className="px-3 py-2 font-semibold">{t("docs.schema")}</th>
</tr>
</thead>
<tbody>
{ep.responses.map((r) => (
<tr key={r.status} className="border-b last:border-0">
<td className="px-3 py-1.5 font-mono">{r.status}</td>
<td className="px-3 py-1.5 text-muted-foreground">{r.description}</td>
<td className="px-3 py-1.5"><FieldTypeChip type={r.schema} /></td>
</tr>
))}
</tbody>
</table>
</div>
</section>
))}
</div>
<Toc headings={headings} title={t("docs.endpoints")} />
</div>
);
}
function ReleasesView({ versions }: { versions: ReleaseDoc[] }) {
const [, setLocation] = useLocation();
const { t } = useTranslation();
return (
<div className="flex flex-col lg:flex-row gap-8">
<div className="flex-1 min-w-0 max-w-3xl space-y-4">
<div className="space-y-1">
<h1 className="text-2xl font-bold tracking-tight">{t("docs.releases")}</h1>
<p className="text-muted-foreground">{t("docs.subtitle")}</p>
</div>
<div className="space-y-2">
{versions.map((v) => (
<button
key={v.version}
type="button"
onClick={() => setLocation(`/docs/releases/${v.version}`)}
className="flex w-full items-center gap-3 rounded-lg border p-3 text-left transition-colors hover:bg-accent/50"
>
<FileText className="h-4 w-4 shrink-0 text-primary" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-semibold">{v.title}</span>
<Badge variant="secondary">{v.version}</Badge>
</div>
{v.date && (
<span className="mt-0.5 flex items-center gap-1.5 text-xs text-muted-foreground">
<CalendarDays className="h-3 w-3" />
{new Date(`${v.date}T00:00:00`).toLocaleDateString()}
</span>
)}
</div>
{v.hasReference && <Badge className="bg-primary/10 text-primary">API-Referenz</Badge>}
</button>
))}
</div>
</div>
<Toc headings={[]} />
</div>
);
}
function ReleaseNoteView({ version }: { version: string }) {
return (
<div className="flex flex-col lg:flex-row gap-8">
<div className="flex-1 min-w-0 max-w-3xl">
<div className="mb-4 flex items-center gap-2 text-sm text-muted-foreground">
<Tag className="h-4 w-4" />
<span className="font-mono">{version}</span>
<a
href={`${REPO_URL}/tags/${version}`}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 hover:text-foreground"
>
<ExternalLink className="h-3.5 w-3.5" />
</a>
</div>
<MarkdownView file={`releases/${version}.md`} />
</div>
</div>
);
}
function HandbookView({ slug }: { slug: string }) {
const handbookFile = `${slug}.md`;
return <MarkdownView file={`handbook/${handbookFile}`} />;
}
// ---------------------------------------------------------------------------
// Search overlay
// ---------------------------------------------------------------------------
function useDocsSearch(query: string) {
const { data, error } = useJson<SearchEntry[]>(query ? `${DOCS_BASE}/search.json` : null);
const results = useMemo(() => {
if (!query.trim() || !data) return [];
const q = query.trim().toLowerCase();
return data
.filter(
(e) =>
e.title.toLowerCase().includes(q) ||
e.text.toLowerCase().includes(q),
)
.slice(0, 25);
}, [query, data]);
return { results, error };
}
function SearchOverlay({ query, onClose }: { query: string; onClose: () => void }) {
const { t } = useTranslation();
const { results } = useDocsSearch(query);
if (!query.trim()) return null;
return (
<div className="mt-3 rounded-lg border bg-card p-2 shadow-md max-h-96 overflow-auto">
{results.length === 0 ? (
<p className="px-3 py-2 text-sm text-muted-foreground">{t("docs.noResults")}</p>
) : (
results.map((r) => (
<Link
key={r.href}
href={r.href}
onClick={onClose}
className="flex items-start gap-2 rounded-md px-3 py-2 text-sm hover:bg-accent"
>
<span className="shrink-0">
{r.kind === "endpoint" && <Server className="h-4 w-4 text-emerald-500" />}
{r.kind === "field" && <Library className="h-4 w-4 text-blue-500" />}
{r.kind === "guide" && <BookOpen className="h-4 w-4 text-amber-500" />}
{r.kind === "release" && <Tag className="h-4 w-4 text-primary" />}
</span>
<span className="min-w-0">
<span className="block font-medium truncate">{r.title}</span>
<span className="block text-xs text-muted-foreground truncate">{r.text.slice(0, 80)}</span>
</span>
</Link>
))
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export default function Docs() {
const [location, setLocation] = useLocation();
const { t } = useTranslation();
const [searchQuery, setSearchQuery] = useState("");
const { version, path } = useMemo(() => parseDocsPath(location), [location]);
const { data: versionInfo } = useGetVersion({
query: { queryKey: getGetVersionQueryKey(), staleTime: Infinity, retry: false },
});
const { data: releases, error: releasesError } = useJson<ReleaseDoc[]>(`${DOCS_BASE}/index.json`);
const { data: handbook, error: handbookError } = useJson<HandbookPage[]>(
version === null ? `${DOCS_BASE}/handbook/index.json` : null,
);
const isCurrentVersion =
version === null ||
(versionInfo?.version && versionInfo.version !== "dev" && version === versionInfo.version) ||
(version !== null && (!versionInfo?.version || versionInfo.version === "dev"));
const refUrl = version === null
? `${DOCS_BASE}/reference.json`
: releases?.find((r) => r.version === version)?.hasReference
? `${DOCS_BASE}/versions/${version}.json`
: null;
const { data: reference, error: refError } = useJson<Reference>(refUrl);
// Current version detection: prefer running version, fallback newest documented
const currentVersion = useMemo(() => {
if (releases && releases.length > 0) {
if (versionInfo?.version && versionInfo.version !== "dev") {
const match = releases.find((r) => r.version === versionInfo.version);
if (match) return match.version;
}
return releases[0].version;
}
return null;
}, [releases, versionInfo]);
// Redirect old-style /docs/vX.Y.Z to /docs/releases/vX.Y.Z
useEffect(() => {
if (version !== null && path.length === 0) {
setLocation(`/docs/releases/${version}`, { replace: true });
}
}, [version, path, setLocation]);
const handleVersionChange = (v: string | null) => {
setSearchQuery("");
if (v === null || v === currentVersion) {
setLocation("/docs");
return;
}
setLocation(`/docs/releases/${v}`);
};
// ---- route resolution ----
const section = path[0] ?? "home";
const param = path[1];
let content: React.ReactNode = null;
if (version !== null && path.length === 0) {
content = <ReleaseNoteView version={version} />;
} else if (section === "home") {
content =
handbook && handbook.length > 0 ? (
<HandbookView slug={handbook[0].slug} />
) : releases && releases.length > 0 ? (
<ReleasesView versions={releases} />
) : (
<p className="text-sm text-muted-foreground">{t("docs.noDocs")}</p>
);
} else if (section === "handbook" && param) {
content = <HandbookView slug={param} />;
} else if (section === "reference" && param === "endpoints" && path[2]) {
const tag = reference?.tags.find(
(tg) => tg.name.toLowerCase() === path[2].toLowerCase(),
);
content = tag ? (
<EndpointTagView tag={tag} />
) : refError || (reference && !tag) ? (
<p className="text-sm text-muted-foreground">{t("docs.noDocs")}</p>
) : (
<Skeleton className="h-64 w-full" />
);
} else if (section === "reference" && param === "schemas" && path[2]) {
const schema = reference?.schemas.find(
(s) => s.name.toLowerCase() === path[2].toLowerCase(),
);
content = schema ? (
<SchemaView schema={schema} />
) : refError || (reference && !schema) ? (
<p className="text-sm text-muted-foreground">{t("docs.noDocs")}</p>
) : (
<Skeleton className="h-64 w-full" />
);
} else if (section === "reference") {
content = (
<div className="flex flex-col lg:flex-row gap-8">
<div className="flex-1 min-w-0 max-w-3xl space-y-4">
<h1 className="text-2xl font-bold tracking-tight">{t("docs.reference")}</h1>
<p className="text-muted-foreground">{t("docs.referenceIntro")}</p>
{!reference && !refError && <Skeleton className="h-64 w-full" />}
{reference && (
<>
<div>
<h2 className="mb-1 text-lg font-semibold">{t("docs.endpoints")}</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{reference.tags.map((tg) => (
<Link
key={tg.name}
href={`/docs/reference/endpoints/${tg.name}`}
className="rounded-lg border p-3 text-sm hover:bg-accent/50"
>
<span className="font-medium">{tg.name}</span>
<span className="block text-xs text-muted-foreground">
{tg.endpoints.length} {t("docs.endpoints")}
</span>
</Link>
))}
</div>
</div>
<div>
<h2 className="mb-1 text-lg font-semibold">{t("docs.schemas")}</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{reference.schemas.map((s) => (
<Link
key={s.name}
href={`/docs/reference/schemas/${s.name}`}
className="rounded-lg border p-3 text-sm font-mono hover:bg-accent/50"
>
{s.name}
</Link>
))}
</div>
</div>
</>
)}
</div>
<Toc headings={[]} />
</div>
);
} else if (section === "releases" && param) {
content = <ReleaseNoteView version={param} />;
} else if (section === "releases") {
content = releases ? <ReleasesView versions={releases} /> : <Skeleton className="h-64 w-full" />;
} else {
content = <p className="text-sm text-muted-foreground">{t("docs.noDocs")}</p>;
}
const showSearch = version === null && section !== "releases";
return (
<div className="min-h-screen bg-background flex flex-col">
<header className="border-b bg-card shrink-0">
<div className="mx-auto max-w-6xl px-4 md:px-6 h-14 flex items-center justify-between gap-3">
<Link
href="/"
className="inline-flex items-center gap-2 text-primary font-bold text-lg min-w-0"
data-testid="link-docs-back"
>
<Wrench className="w-5 h-5 shrink-0" />
<span className="truncate">toolr</span>
<span className="hidden md:inline-flex items-center gap-1 text-xs font-normal text-muted-foreground border-l pl-2 ml-1">
<ArrowLeft className="w-3.5 h-3.5" />
{t("docs.backToApp")}
</span>
</Link>
<div className="flex items-center gap-1.5 shrink-0">
<ThemeToggle />
</div>
</div>
</header>
<div className="mx-auto max-w-6xl w-full flex-1 space-y-6 px-4 md:px-6 py-6 pb-12">
<DocsHeader
versions={releases ?? []}
activeVersion={version}
onVersionChange={handleVersionChange}
onSearchChange={showSearch ? setSearchQuery : undefined}
/>
{showSearch && <SearchOverlay query={searchQuery} onClose={() => setSearchQuery("")} />}
<div className="grid grid-cols-1 lg:grid-cols-[240px_minmax(0,1fr)] gap-8">
<aside className="hidden lg:block">
<div className="sticky top-20 max-h-[calc(100vh-6rem)] overflow-auto">
<DocsNav
version={version}
handbook={handbook}
reference={reference}
versions={releases ?? []}
/>
</div>
</aside>
<div className="min-w-0">{content}</div>
</div>
</div>
</div>
);
}
+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
+51
View File
@@ -0,0 +1,51 @@
# Dokumentation (Handbuch, API-Referenz, Release-Notes)
Die App zeigt unter `/docs` eine MkDocs-artige Doku-Seite mit drei Bereichen:
- **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
- `docs/handbook/` — Handbuch-Seiten mit Frontmatter (`title`, `order`)
- `docs/releases/TEMPLATE.md` — Vorlage für neue Releases
- `docs/releases/vX.Y.Z.md` — Notes pro Release
- `docs/releases/vX.Y.Z/reference.json` — API-Snapshot der jeweiligen Version
## Generator
`scripts/src/generate-docs.mjs` wird beim Frontend-Build (und `dev`) automatisch
ausgeführt und schreibt die Artefakte nach `artifacts/toolrate/public/docs/`:
- `reference.json` (aktuelle API), `search.json` (Suchindex),
`index.json` (Releases), `handbook/*.md` + `handbook/index.json`
- `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
1. **Version taggen** wie bisher (`git tag vX.Y.Z`, CI baut und deployed).
2. **`docs/releases/vX.Y.Z.md` anlegen** — Vorlage aus `TEMPLATE.md` kopieren,
Entwurf aus der Git-Historie ableiten:
```sh
git log --oneline vX.Y.Z-1..vX.Y.Z
```
(API-Delta anhand `lib/api-spec/openapi.yaml` prüfen.)
3. **API-Snapshot erzeugen:** `node scripts/src/generate-docs.mjs --snapshot vX.Y.Z`
erzeugt `docs/releases/vX.Y.Z/reference.json`.
4. **Committen & pushen.** Der Build kopiert die Dokumentation automatisch nach
`artifacts/toolrate/public/docs/` und generiert `index.json`.
> Hinweis: Alle Dateien unter `artifacts/toolrate/public/docs/` sind
> Build-Artefakte und werden bei jedem Build neu generiert — nicht von Hand
> bearbeiten. Einzige Quellen sind `docs/handbook/`, `docs/releases/` und
> `lib/api-spec/openapi.yaml`.
+65
View File
@@ -0,0 +1,65 @@
---
title: Administration
order: 13
---
# Administration
Der Bereich **Admin** (`/admin`) ist ausschließlich für Admins zugänglich.
Ohne Admin-Rolle erscheint eine Zugriffsverweigerung.
> Oben rechts führt die Schaltfläche **Redundanz-Dashboard** zur automatischen
> Doppelungs-Erkennung (siehe [Redundanz](/docs/handbook/redundanz)).
## Tab „Nutzer"
Verwaltung der lokalen Konten.
- **Nutzer hinzufügen:** Benutzername (Pflicht), Passwort (mind. 6 Zeichen),
E-Mail (optional), **Rolle** (User/Admin), **Tarif** (Free/Premium/Enterprise).
- **Nutzer bearbeiten:** Rolle, Tarif und (für lokale Konten) ein neues Passwort
setzen. Für OIDC-Konten wird die Passwortverwaltung im Identitätsanbieter
(z. B. Keycloak) angeboten.
- **Nutzer löschen:** Entfernt das Konto endgültig (nicht für das eigene Konto).
API-Referenz:
[`POST /users`](/docs/reference/endpoints/users#createUser),
[`PATCH /users/{id}`](/docs/reference/endpoints/users#updateUser),
[`DELETE /users/{id}`](/docs/reference/endpoints/users#deleteUser).
## Tab „Tools"
Zentraler Zugriff auf den Tool-Katalog.
- **Suchen** nach Tools.
- Tools einzeln ansehen, bearbeiten oder in den Papierkorb verschieben.
- **Massenaktion:** mehrere Tools auswählen und in den Papierkorb verschieben
(Bestätigungsdialog; soft gelöschte Tools sind aus allen öffentlichen Ansichten
entfernt und können wiederhergestellt oder endgültig gelöscht werden).
## Tab „Audit-Log"
Chronologisches Protokoll aller Anlage-, Änderungs- und Löschvorgänge
(max. 100 Einträge): Aktion, Entität + ID, Zeitstempel, ausführende Person und
geänderte Felder.
API-Referenz: [`GET /audit-logs`](/docs/reference/endpoints/audit#listAuditLogs).
## Tab „System"
Versionsinformationen der laufenden Instanz:
- **Version** (z. B. `v0.8.1`),
- **Commit** (7-stelliger SHA, verlinkt zum Repository),
- **Build-Datum**,
- **Papierkorb-Aufbewahrung** („N Tage" oder „Für immer").
## Tool-Verknüpfungen (Admin)
Auf der Detailseite eines Tools kannst du als Admin **Verknüpfungen**
(eigene/„manual" sowie automatisch erkannte) verwalten:
- **Tool verknüpfen:** Dialog mit Tool-ID, **Beziehungstyp**
(Ähnlich / Ersetzt / Abgelöst durch) und optionalen Notizen.
- Beziehungstypen werden als Badges auf der Detailseite angezeigt.
- Manuelle Verknüpfungen lassen sich per Papierkorb-Icon wieder entfernen.
+33
View File
@@ -0,0 +1,33 @@
---
title: Analytics
order: 10
---
# Analytics
Der Bereich **Analytics** (`/analytics`) ist ein öffentliches Dashboard mit
Kennzahlen und Diagrammen auf Basis aller Tools und Bewertungen.
## Kennzahlen (KPI-Karten)
- **Anzahl Tools** — wie viele Tools sind im Katalog erfasst.
- **Anzahl Bewertungen** — wie viele Bewertungen wurden insgesamt abgegeben.
- **Aktive Kategorien** — wie viele Kategorien existieren.
- **Durchschnittliche Bewertung** — globaler kombinierter Wert.
## Diagramme
| Diagramm | Inhalt |
| --- | --- |
| **Top 8 Tools** | Balkendiagramm der Tools mit der höchsten kombinierten Punktzahl (05) |
| **Tools je Kategorie** | Radar-Diagramm der Tool-Anzahl pro Kategorie |
| **Punkteverteilung** | Zwei horizontale Balken-Diagramme (Nützlichkeit & Bedienbarkeit) pro Stern |
Die Diagramme sind interaktiv (Tooltips beim Überfahren).
## API
- [`GET /analytics/summary`](/docs/reference/endpoints/analytics#getAnalyticsSummary)
- [`GET /analytics/top-tools`](/docs/reference/endpoints/analytics#getTopTools)
- [`GET /analytics/by-category`](/docs/reference/endpoints/analytics#getAnalyticsByCategory)
- [`GET /analytics/rating-distribution`](/docs/reference/endpoints/analytics#getRatingDistribution)
+43
View File
@@ -0,0 +1,43 @@
---
title: Bewerten
order: 7
---
# Bewerten
Auf der Detailseite eines Tools kannst du deine Erfahrung teilen. Klicke auf
**Bewertung abgeben** (erfordert ein Konto).
## Formularfelder
| Feld | Pflicht | Hinweise |
| --- | --- | --- |
| **Nützlichkeit** | Ja | 15 Sterne |
| **Bedienbarkeit** | Ja | 15 Sterne |
| **Kommentar** | Nein | Freitext |
| **Name** | Nein | Standard „Anonym" |
Neben den Feldern führt das **?Icon** direkt zur zugehörigen Feldbeschreibung
in der [Datenmodell-Referenz](/docs/reference/schemas/ratinginput).
## Was passiert nach dem Abgeben?
- Deine Bewertung wird sofort gespeichert und erscheint in der
**Bewertungsliste** der Detailseite.
- Die **Durchschnittswerte** (Nützlichkeit, Bedienbarkeit, Kombiniert) und die
**Punkteverteilung** werden aktualisiert.
- Die **Statistiken** im Bereich [Analytics](/docs/handbook/analytics) werden
neu berechnet.
## Statistik-Bereiche auf der Detailseite
- **Bewertungsübersicht:** Nützlichkeit & Bedienbarkeit als Durchschnitt mit
Fortschrittsbalken.
- **Punkteverteilung:** Anzahl der Bewertungen pro Stern (1★–5★).
- **Verlauf:** Linienchart der kombinierten/Teilwerte über die Zeit
(erst ab mehreren Bewertungen sichtbar).
## API
- [`POST /tools/{id}/ratings`](/docs/reference/endpoints/ratings#createRating) — Bewertung abgeben
- [`GET /tools/{id}/ratings`](/docs/reference/endpoints/ratings#listToolRatings) — Bewertungen eines Tools
+74
View File
@@ -0,0 +1,74 @@
---
title: Datenmodell
order: 17
---
# Datenmodell
Dieses Kapitel erklärt die zentralen Datenobjekte von toolr auf Ebene der
Anwendung. Die vollständige, automatisch generierte Referenz aller Felder,
Typen und Constraints findest du in der
[API-Referenz](/docs/reference/schemas/tool).
## Tool
Das Herzstück: ein im Katalog erfasstes Werkzeug.
| Eigenschaft | Beschreibung |
| --- | --- |
| `id` | Eindeutige Kennung |
| `name` | Anzeigename |
| `description` | Beschreibung (Was macht das Tool?) |
| `category` | Kategorie-Zuordnung |
| `websiteUrl` | Offizielle Website (optional) |
| `iconUrl` | Logo-/Icon-URL (optional) |
| `features` | Liste von Fähigkeiten |
| `tags` | Liste von Schlagwörtern |
| `createdAt` / `updatedAt` | Zeitstempel |
| `createdBy` | Erstellende Person |
| `deletedAt` / `deletedBy` | Soft-Löschung (Papierkorb) |
Eingabe-Formulare verwenden die abgeleiteten Schemas
[`ToolInput`](/docs/reference/schemas/toolinput) und
[`ToolUpdate`](/docs/reference/schemas/toolupdate).
Aggregierte Ansichten liefert [`ToolWithStats`](/docs/reference/schemas/toolwithstats)
(z. B. mit Durchschnittsbewertung).
## Rating (Bewertung)
Eine einzelne Bewertung zu einem Tool:
- `usefulness` (Nützlichkeit, 15) und `usability` (Bedienbarkeit, 15)
- optional `comment` und ein Anzeigename (`reviewerName`)
- Zeitstempel
Eingabe-Schema: [`RatingInput`](/docs/reference/schemas/ratinginput).
## User & Auth
- [`User`](/docs/reference/schemas/user) — Benutzerkonto mit Rolle (User/Admin)
und Tarif (Free/Premium/Enterprise).
- [`AuthUser`](/docs/reference/schemas/authuser) — das angemeldete Profil
inklusive `entitlements` (verfügbare Features).
- [`UserPreferences`](/docs/reference/schemas/userpreferences) — Ansichts- und
Dichte-Präferenzen sowie die `watchlist` (Liste von Tool-IDs).
## Analytics
Die Statistik-Endpunkte liefern aggregierte Daten:
- [`AnalyticsSummary`](/docs/reference/schemas/analyticssummary) — globale
Kennzahlen (Anzahl Tools/Bewertungen, Kategorien, Durchschnitt).
- [`TopToolEntry`](/docs/reference/schemas/TopToolEntry) — ein Eintrag der
Top-Tools.- [`CategoryStats`](/docs/reference/schemas/categorystats) — Tool-Anzahl je
Kategorie.
- [`RatingDistribution`](/docs/reference/schemas/ratingdistribution) —
Punkteverteilung (Nützlichkeit & Bedienbarkeit).
- [`ScoreBucket`](/docs/reference/schemas/scorebucket) — ein Werte-Bucket.
## Weitere
- [`VersionInfo`](/docs/reference/schemas/versioninfo) — Version, Commit-SHA,
Build-Datum und Papierkorb-Aufbewahrung der laufenden Instanz.
- [`AuditLog`](/docs/reference/schemas/auditlog) — ein Protokolleintrag
(Aktion, Entität, Zeitstempel, Akteur, Änderungen).
+57
View File
@@ -0,0 +1,57 @@
---
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, Vergleichen) erfordern
ein Konto. Klicke unten links 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 (z. B.
Keycloak).
Welcher Modus aktiv ist, steht im Endpunkt
[`GET /auth/mode`](/docs/reference/endpoints/auth#getAuthMode). Details findest
du im Abschnitt [Anmelden & Konto](/docs/handbook/konto).
## 2. Tools finden
Öffne den Bereich **Tools durchsuchen**:
- **Suchen** — Volltextsuche über Name & Beschreibung (Tastenkürzel `/`).
- **Filtern** — nach Kategorie, Tags, Features und Mindestbewertung
(`minRating`).
- **Sortieren** — nach Aktualität, Top-Bewertung, meistbewertet, Name
(auf-/absteigend) oder letztem Update.
Alle Optionen im Detail: [Tools finden & durchsuchen](/docs/handbook/tools-finden).
## 3. Tool anlegen
Gehe auf **Tool hinzufügen** und fülle das Formular aus. Details zu jedem Feld
findest du im Abschnitt [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.
Siehe [Bewerten](/docs/handbook/bewerten).
## 5. Weiterführend
- [Tools vergleichen](/docs/handbook/vergleichen)
- [Watchlist](/docs/handbook/watchlist)
- [Analytics](/docs/handbook/analytics)
- [Pläne & Berechtigungen](/docs/handbook/plaene)
- [Administration](/docs/handbook/administration)
+56
View File
@@ -0,0 +1,56 @@
---
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 |
| **Tool bearbeiten/löschen** | Eigene Tools pflegen (Ersteller:in oder Admin) | Angemeldet |
| **Bewerten** | Nützlichkeit & Bedienbarkeit (15) plus Kommentar vergeben | Angemeldet |
| **Watchlist** | Tools als Favoriten speichern | Premium |
| **Vergleichen** | Tools nebeneinander gegenüberstellen | Premium |
| **Kosten erfassen** | Lizenz- und Kostenmodelle je Tool eintragen | Premium |
| **Analytics** | Statistiken, Top-Tools, Verteilungen | Alle |
| **Papierkorb** | Soft-gelöschte Tools wiederherstellen oder endgültig löschen | Premium |
| **Admin** | Nutzerverwaltung, Audit-Log, Systeminformationen | Admin |
| **Redundanz** | Automatische Doppelungs-Erkennung | Admin |
## Wie diese Doku aufgebaut ist
- **User Guide** (diese Seiten): Schritt-für-Schritt-Anleitungen für alle
Funktionen — von den [Ersten Schritten](/docs/handbook/getting-started) bis
zur [Administration](/docs/handbook/administration).
- **API-Referenz**: automatisch aus der OpenAPI-Spezifikation generiert — alle
[Endpunkte](/docs/reference/endpoints/tools) und
[Datenfelder](/docs/reference/schemas/toolinput) der aktuellen Version.
- **Release-Notes**: Was ist in welcher [Version](/docs/releases/v0.8.1) neu.
## Der Einstieg
Der schnellste Weg:
1. **Anmelden** — ohne Konto kannst du nur stöbern
(siehe [Erste Schritte](/docs/handbook/getting-started#1-anmelden)).
2. **Tools finden** — Suche, Filter und Sortierung im Bereich
[Tools durchsuchen](/docs/handbook/tools-finden).
3. **Tool anlegen** — über „Tool hinzufügen"
([Anleitung](/docs/handbook/tool-anlegen)).
4. **Bewerten** — auf der Detailseite eines Tools
([Anleitung](/docs/handbook/bewerten)).
## Kontakt & Quellcode
Der Quellcode liegt unter
[git.kubebase.de/admin/tool-evaluator](https://git.kubebase.de/admin/tool-evaluator) —
über das Repository-Icon oben rechts erreichst du ihn jederzeit.
+57
View File
@@ -0,0 +1,57 @@
---
title: Anmelden & Konto
order: 3
---
# Anmelden & Konto
## Anmelden
Klicke unten links in der Seitenleiste auf **Anmelden**. Je nach Konfiguration
der Instanz:
- **Lokale Konten:** Benutzername und Passwort eingeben. Die Konten werden von
einem Admin angelegt (siehe [Administration](/docs/handbook/administration)).
- **OIDC (SSO):** Du wirst an den konfigurierten Identitätsanbieter
weitergeleitet und meldest dich dort an.
Der aktive Modus steht im Endpunkt
[`GET /auth/mode`](/docs/reference/endpoints/auth#getAuthMode).
> Die Login-Seite erreichst du direkt unter `/login`. Nach erfolgreicher
> Anmeldung wirst du zur ursprünglich aufgerufenen Seite zurückgeleitet.
## Benutzerprofil
Dein Profil (Avatar, Name, E-Mail, Tarif) siehst du unten links im
Benutzermenü. Dort stehen dir folgende Aktionen zur Verfügung:
- **Watchlist** — deine gespeicherten Tools (nur mit dem entsprechenden Tarif).
- **Papierkorb** — wiederherstellbare, gelöschte Tools (Premium/Enterprise).
- **Passwort ändern** — für lokale Konten direkt in toolr; für OIDC-Konten wird
die Passwortverwaltung im Identitätsanbieter angeboten.
- **Abmelden** — beendet deine Sitzung.
## Passwort ändern (lokales Konto)
1. Öffne das Benutzermenü unten links.
2. Wähle **Passwort ändern**.
3. Gib das **aktuelle** sowie ein **neues** Passwort ein (mind. 6 Zeichen) und
bestätige es.
4. Speichern — das Passwort wird sofort übernommen.
API-Referenz: [`POST /auth/me/password`](/docs/reference/endpoints/auth#changeMyPassword).
## Anzeigeeinstellungen
Über die Schaltflächen oben rechts kannst du:
- **Sprache** wechseln (Deutsch / Englisch),
- **Theme** umschalten (Hell / Dunkel / System),
- die **Listenansicht** und **Dichte** im Bereich Tools durchsuchen anpassen
(siehe [Tools finden & durchsuchen](/docs/handbook/tools-finden)).
Deine Präferenzen (inkl. Watchlist) werden im Endpunkt
[`GET /auth/me/preferences`](/docs/reference/endpoints/auth#getMePreferences)
gespeichert und über [`PUT /auth/me/preferences`](/docs/reference/endpoints/auth#updateMePreferences)
aktualisiert.
+39
View File
@@ -0,0 +1,39 @@
---
title: Kosten erfassen
order: 12
---
# Kosten erfassen
Auf der Detailseite eines Tools kannst du Kosten- und Lizenzmodelle eintragen,
damit die Gesamtkosten je Tool transparent werden.
> Kosten ist ein **Premium-Feature** (`costs`, Premium/Enterprise). Admins
> haben immer Zugriff.
## Kosten hinzufügen
Klicke auf **Kosten hinzufügen** im Kosten-Bereich der Detailseite und fülle
das Formular aus:
| Feld | Hinweise |
| --- | --- |
| **Lizenztyp** | Free / Subscription / One-Time / Usage-Based |
| **Abrechnungszeitraum** | Nur für „Subscription": Monatlich / Quartalsweise / Jährlich |
| **Kosten** | Betrag als Zahl |
| **Währung** | EUR / USD / GBP / CHF |
| **Notizen** | Optionaler Freitext |
Speichern legt den Eintrag an. Jeder Kosten-Eintrag wird als Karte mit
Lizenz-Badge, Abrechnungszeitraum, Betrag (`Betrag Währung` bzw. „Free") und
Notizen angezeigt.
## Kosten bearbeiten & löschen
Beim Überfahren einer Kosten-Karte erscheinen die Aktionen **Bearbeiten**
(Bleistift) und **Löschen** (Papierkorb).
## API
Die Kosten-Daten werden über die Tool-Endpunkte verwaltet
(siehe [API-Referenz](/docs/reference/endpoints/tools)).
+45
View File
@@ -0,0 +1,45 @@
---
title: Papierkorb
order: 15
---
# Papierkorb
Der **Papierkorb** (`/trash`) enthält soft gelöschte Tools. Mit Papierkorb-Zugang
können sie wiederhergestellt werden; endgültiges Löschen ist Admins vorbehalten.
> Der Papierkorb ist ein **Premium-Feature** (`trash`, Premium/Enterprise).
> Admins haben immer Zugriff.
## Zugang
Der Papierkorb ist über das Benutzermenü oder die Seitenleiste erreichbar.
Ohne `trash`-Berechtigung erscheint ein Hinweis auf den Tarifwechsel.
## Wiederherstellen
- Markiere ein oder mehrere Tools (Checkboxen).
- Klicke auf **Wiederherstellen (N)** — die Tools erscheinen wieder in allen
öffentlichen Ansichten.
> Wiederherstellen steht jeder Person mit Papierkorb-Zugang zur Verfügung.
## Endgültig löschen (nur Admin)
- **Löschen (N)** entfernt die ausgewählten Tools **endgültig** — inklusive
aller Bewertungen, Kosten und Verknüpfungen. Das kann nicht rückgängig
gemacht werden.
- **Papierkorb leeren** entfernt alle soft gelöschten Tools endgültig.
## Tabelle
Der Papierkorb listet: Name, Kategorie, **Gelöscht am** (`tt.MM.jjjj HH:mm`),
**Gelöscht von** sowie Aktionen (Wiederherstellen; Löschen nur Admin). Die Suche
filtert nach Namen.
## API
- [`GET /tools/trash`](/docs/reference/endpoints/tools#listTrashedTools) — Liste
- [`POST /tools/trash/restore`](/docs/reference/endpoints/tools#restoreTools) — Wiederherstellen
- [`DELETE /tools/trash`](/docs/reference/endpoints/tools#deleteTrashedTools) — Endgültig löschen (Admin)
- [`POST /tools/trash/empty`](/docs/reference/endpoints/tools#emptyTrash) — Papierkorb leeren (Admin)
+45
View File
@@ -0,0 +1,45 @@
---
title: Pläne & Berechtigungen
order: 11
---
# Pläne & Berechtigungen
toolr unterscheidet **Tarife** (Tier) und **Rollen**. Admins umgehen alle
Feature-Beschränkungen.
## Tarife
| Tarif | Beschreibung |
| --- | --- |
| **Free** | Grundfunktionen: suchen, filtern, ansehen, Analytics |
| **Premium** | Zusätzlich Watchlist, Vergleichen, Papierkorb, Kosten |
| **Enterprise** | Alle Premium-Features + erweiterter Support |
### Feature-Berechtigungen
Premium/Enterprise schalten folgende Features frei:
| Feature | Funktion | Mehr erfahren |
| --- | --- | --- |
| `compare` | Tools vergleichen | [Vergleichen](/docs/handbook/vergleichen) |
| `watchlist` | Favoritenliste | [Watchlist](/docs/handbook/watchlist) |
| `trash` | Papierkorb (soft gelöschte Tools) | [Papierkorb](/docs/handbook/papierkorb) |
| `costs` | Kosten-/Lizenzmodelle erfassen | [Kosten erfassen](/docs/handbook/kosten) |
Fehlt dir ein Feature, zeigt die App einen **Upgrade-Hinweis** mit Link zur
Tarifverwaltung.
## Rollen
| Rolle | Berechtigungen |
| --- | --- |
| **User** | Standard-Konto: Tools anlegen/bewerten, eigene Tools bearbeiten |
| **Admin** | Alle User-Rechte + Verwaltung, Audit-Log, Redundanz, Papierkorb leeren, Tool-Verknüpfungen |
Admins passieren **alle** Feature-Checks — auch ohne Premium-Tarif.
## Tarif-/Rollenverwaltung
Die Zuordnung von Rolle und Tarif wird durch Admins im Bereich
[Administration](/docs/handbook/administration) (Tab „Nutzer") verwaltet.
+42
View File
@@ -0,0 +1,42 @@
---
title: Redundanz-Dashboard
order: 14
---
# Redundanz-Dashboard
Das **Redundanz-Dashboard** (`/admin/redundancy`) ist ein Admin-Werkzeug zur
automatischen Erkennung doppelter oder stark überlappender Tools — jeweils
pro Kategorie — inklusive Kosten- und Bewertungsvergleich.
> Der Zugriff ist ausschließlich Admins vorbehalten (die API ist
> admin-geschützt).
## Aufbau
- **Pro Kategorie** wird eine Gruppe angezeigt: Name der Kategorie,
Anzahl Tools und Vergleiche sowie ggf. die **gesamten monatlichen Kosten**
(z. B. `€X.XX/mo gesamt`).
- Jedes Tool wird als Karte dargestellt: Name, monatliche Kosten, Anzahl der
Bewertungen, kombinierte Bewertung, Lizenz-Badges und Feature-Anzahl.
## Vergleiche & Empfehlungen
Für jedes Tool-Paar erscheint:
- Tool A vs. Tool B, jeweils mit Bewertung (`X.X ★`) und monatlichen Kosten.
- **Überlappung** in Prozent (Fortschrittsbalken in der Mitte).
- Eine **Empfehlung** mit Konfidenz-Farbe:
- **hoch** (grün), **mittel** (gelb), **niedrig** (grau)
- Das empfohlene, bessere Tool wird mit „Daumen hoch" markiert und begründet.
## Manuelle Bewertung
Du kannst ein Paar manuell bewerten: Klicke auf Tool A oder Tool B, um
festzuhalten, welches besser ist. Die Auswahl wird gespeichert und die
Darstellung aktualisiert.
## API
- [`GET /api/admin/redundancy`](#) — Daten laden (admin-geschützt)
- [`POST /api/admin/redundancy/evaluate`](#) — manuelle Bewertung speichern
+40
View File
@@ -0,0 +1,40 @@
---
title: Tastenkürzel & Kommandopalette
order: 16
---
# Tastenkürzel & Kommandopalette
## Kommandopalette
Die Kommandopalette ist die zentrale Schnellnavigation:
- Öffnen mit **`⌘K`** (macOS) bzw. **`Ctrl+K`** (Windows/Linux).
- Alternativ über die Suchleiste oben rechts („Tools suchen… ⌘K") oder das
Such-Icon auf Mobilgeräten.
### Leerer Zustand
Ohne Eingabe zeigt die Palette:
- **Zuletzt angesehen** — die letzten 5 Tools, die du besucht hast.
- **Navigation** — Tools durchsuchen, Tool hinzufügen, Analytics sowie
(abhängig von Berechtigungen) Watchlist, Papierkorb und Admin.
### Suche
Tippe, um live nach Tools zu suchen (max. 10 Ergebnisse, inkl. Bewertung
`X.X★`).
## Tastenkürzel im Überblick
| Kürzel | Aktion |
| --- | --- |
| `⌘K` / `Ctrl+K` | Kommandopalette öffnen |
| `/` | Suche im Bereich „Tools durchsuchen" fokussieren |
## Weitere Hinweise
- **Zuletzt angesehen** wird lokal im Browser gespeichert (max. 5 Einträge).
- Die Seitenleiste (linke Navigation) ist auf Desktop einklappbar; der
Breadcrumb oben zeigt deinen aktuellen Ort.
+50
View File
@@ -0,0 +1,50 @@
---
title: Tool anlegen
order: 5
---
# Tool anlegen
Um ein neues Tool zum Katalog hinzuzufügen, klicke auf **Tool hinzufügen**
(`/tools/new`). Das Anlegen erfordert ein Konto — ohne Anmeldung erscheint ein
Hinweis mit Login-Button.
## Formularfelder
| Feld | Pflicht | Hinweise |
| --- | --- | --- |
| **Name** | Ja | Mind. 2 Zeichen |
| **Kategorie** | Ja | Auswahlliste; neue Kategorien lassen sich direkt anlegen |
| **Website URL** | Nein | Gültige URL (z. B. `https://...`) |
| **Icon / Logo URL** | Nein | Gültige URL; Vorschau wird live angezeigt |
| **Beschreibung** | Ja | Mind. 10 Zeichen; beschreibe, was das Tool tut |
| **Features** | Nein | Dynamische Liste mit Autovervollständigung (max. 6) |
| **Tags** | Nein | Dynamische Liste mit Autovervollständigung |
Neben jedem Feld führt das **?Icon** direkt zur zugehörigen Feldbeschreibung
in der [Datenmodell-Referenz](/docs/reference/schemas/toolinput).
### Kategorie
- Tippe, um nach bestehenden Kategorien zu suchen.
- Wähle **+ Erstelle „..."**, um eine neue Kategorie anzulegen.
### Features & Tags
- **Feature hinzufügen** / **Tag hinzufügen** hängt eine neue Zeile an.
- Die Eingabefelder schlagen bestehende Features/Tags vor
(Autovervollständigung, max. 6 Vorschläge).
- Mit dem **×**Button entfernst du einzelne Zeilen.
- Features und Tags helfen beim Filtern und Wiederfinden.
## Speichern
Klicke auf **Tool hinzufügen**. Nach erfolgreicher Anlage wirst du auf die
Detailseite des neuen Tools weitergeleitet.
## API
- [`POST /tools`](/docs/reference/endpoints/tools#createTool) — Tool anlegen
- [`GET /categories`](/docs/reference/endpoints/tools#listCategories) — Kategorien
- [`GET /features/all`](/docs/reference/endpoints/tools#listAllFeatures) — Features
- [`GET /tags/all`](/docs/reference/endpoints/tools#listAllTags) — Tags
+37
View File
@@ -0,0 +1,37 @@
---
title: Tool bearbeiten & löschen
order: 6
---
# Tool bearbeiten & löschen
## Bearbeiten
Auf der Detailseite eines Tools findest du die Schaltfläche **Bearbeiten**
(nur für die Person, die das Tool angelegt hat, sowie für Admins).
Die Bearbeitungsseite (`/tools/:id/edit`) enthält dieselben Felder wie beim
Anlegen (Name, Kategorie, Website/Icon-URL, Beschreibung, Features, Tags) —
bereits mit den aktuellen Werten befüllt.
- **Speichern** übernimmt die Änderungen.
- **Abbrechen** führt zurück zur Detailseite.
API-Referenz: [`PATCH /tools/{id}`](/docs/reference/endpoints/tools#updateTool).
## Löschen
Über **Löschen** auf der Detailseite wird das Tool entfernt. Das Verhalten
hängt von deinem Tarif ab:
- **Mit Papierkorb-Zugang** (Premium/Enterprise oder Admin): Das Tool wird
**soft gelöscht** — es verschwindet aus allen öffentlichen Ansichten, kann
aber im [Papierkorb](/docs/handbook/papierkorb) wiederhergestellt oder
endgültig gelöscht werden.
- **Ohne Papierkorb-Zugang:** Das Tool wird **endgültig** gelöscht und kann
nicht wiederhergestellt werden.
Die Löschung ist nur für die Person, die das Tool angelegt hat, sowie für
Admins möglich.
API-Referenz: [`DELETE /tools/{id}`](/docs/reference/endpoints/tools#deleteTool).
+71
View File
@@ -0,0 +1,71 @@
---
title: Tools finden & durchsuchen
order: 4
---
# Tools finden & durchsuchen
Der Bereich **Tools durchsuchen** (`/tools`) ist der Einstieg in den Katalog.
Hier kombinierst du Suche, Filter und Sortierung, um genau die Tools zu finden,
die dich interessieren.
## Suche
- Die **Suchleiste** durchsucht Name und Beschreibung (Volltext).
- Tastenkürzel: Drücke **`/`**, um die Suche zu fokussieren.
- Die Eingabe ist deaktiviert (Debounce), damit bei jedem Tastendruck sofort
nachgefiltert wird.
## Filtern
Über die Schaltfläche **Filter** (mit Badge für die Anzahl aktiver Filter)
öffnest du den Filter-Popover mit:
- **Tags** — Auswahl über Checkboxen (scrollbare Liste).
- **Features** — Auswahl über Checkboxen.
- **Mindestbewertung** — Schieberegler von 0 bis 5 (Schritte von 0,5); zeigt
z. B. „3.0+" an.
Aktive Filter erscheinen als **entfernbare Chips** über der Ergebnisliste.
Mit **Filter zurücksetzen** bzw. **Alle entfernen** räumst du sie wieder auf.
## Sortieren
Über das Dropdown **Sortieren** stehen folgende Optionen zur Verfügung:
| Sortierung | Beschreibung |
| --- | --- |
| Neueste | Neue Tools zuerst |
| Top bewertet | Nach kombinierter Bewertung |
| Meistbewertet | Nach Anzahl der Bewertungen |
| Name (AZ) | Alphabetisch aufsteigend |
| Name (ZA) | Alphabetisch absteigend |
| Zuletzt aktualisiert | Nach letztem Update |
## Ansicht & Dichte
- **Ansicht wechseln:** Raster / Tabelle / Zeilen.
- **Dichte:** gemütlich / kompakt (Schieberegler).
Deine Auswahl wird gespeichert — lokal im Browser und für angemeldete Nutzer:innen
zusätzlich serverseitig in den Präferenzen. Ansicht, Dichte, Suche, Filter und
Sortierung werden dabei in die URL übernommen, sodass du Ergebnisse teilen
kannst.
## Tabellenansicht
In der Tabellenansicht sind die Spalten **Tool**, **Bewertung** und **Anzahl
Bewertungen** sortierbar. Beim Überfahren einer Zeile erscheint eine Vorschau
mit Bewertungsdetails, Tags und Mini-Balken.
## Auswählen für Vergleich & Watchlist
- Auf jeder Karte/Zeile findest du ein **Vergleichs-Icon**, mit dem du Tools zur
[Vergleichsleiste](/docs/handbook/vergleichen) hinzufügst.
- Das **Lesezeichen-Icon** speichert Tools in deiner
[Watchlist](/docs/handbook/watchlist) (nur mit dem entsprechenden Tarif).
## API
Alle Such-, Filter- und Sortierparameter entsprechen den Query-Parametern von
[`GET /tools`](/docs/reference/endpoints/tools#listTools).
+46
View File
@@ -0,0 +1,46 @@
---
title: Vergleichen
order: 9
---
# Vergleichen
Mit der Vergleichsfunktion stellst du mehrere Tools **nebeneinander** gegenüber —
ideal, um eine fundierte Entscheidung zu treffen.
> Vergleichen ist ein **Premium-Feature** (Premium/Enterprise) und steht Admins
> immer zur Verfügung.
## Tools auswählen
1. Im Bereich **Tools durchsuchen** klickst du auf jeder Karte/Zeile auf das
**Vergleichs-Icon** (Waage).
2. Unten erscheint die **Vergleichsleiste** mit den ausgewählten Tools als
Chips. Du kannst einzelne Tools entfernen (×) oder die Auswahl leeren.
3. Klicke auf **Vergleichen (N)**, um zur Vergleichsansicht zu gelangen.
> Ohne Premium-Tarif ist der Button gesperrt (Schloss-Icon). Über den
> Dialog gelangst du zum Tarifwechsel
> (siehe [Pläne & Berechtigungen](/docs/handbook/plaene)).
## Die Vergleichsansicht
Die Ansicht zeigt eine Tabelle mit einer Spalte pro Tool. Zeilen:
| Zeile | Inhalt |
| --- | --- |
| **Bewertung** | Sterne + Wert (z. B. `4.2/5`) |
| **Nützlichkeit** | Wert (X.X/5) |
| **Bedienbarkeit** | Wert (X.X/5) |
| **Anzahl Bewertungen** | Anzahl |
| **Beschreibung** | Text |
| **Features** | Badges |
| **Tags** | Badges |
| **Zuletzt aktualisiert** | Datum |
Der **beste Wert** pro Zeile wird hervorgehoben (mit Trophäen-Icon).
## API
Die Vergleichsansicht liest die Daten über
[`GET /compare`](/docs/reference/endpoints/tools#listCompareTools).
+38
View File
@@ -0,0 +1,38 @@
---
title: Watchlist
order: 8
---
# Watchlist
Die **Watchlist** ist eine persönliche Favoritenliste. Tools darin kannst du
jederzeit per Klick wieder aufrufen und vergleichen.
> Die Watchlist ist ein **Premium-Feature** (Premium/Enterprise) und steht
> Admins immer zur Verfügung.
## Voraussetzung
Du benötigst einen Tarif mit `watchlist`-Berechtigung. Fehlt diese, erscheint
beim Lesezeichen ein Hinweis auf den Tarifwechsel
(siehe [Pläne & Berechtigungen](/docs/handbook/plaene)).
## Tool speichern
- Auf jeder Karte/Zeile im Bereich **Tools durchsuchen** findest du das
**Lesezeichen-Icon**.
- Ein Klick speichert das Tool in deiner Watchlist — das Icon wird gefüllt.
- Ein erneuter Klick entfernt es wieder.
## Watchlist ansehen
Öffne die Watchlist über das Benutzermenü oder die Seitenleiste. Sie zeigt alle
gespeicherten Tools als Karten. Das gefüllte Lesezeichen auf einer Karte
entfernt das Tool aus der Liste.
## Wo wird die Watchlist gespeichert?
Die Watchlist ist eine Liste von Tool-IDs in deinen **Benutzerpräferenzen**.
Damit ist sie geräteübergreifend mit deinem Konto verbunden.
API-Referenz: [`GET /auth/me/watchlist`](/docs/reference/endpoints/auth#getMeWatchlist).
+35
View File
@@ -0,0 +1,35 @@
# vX.Y.Z — Release Notes
> Template für neue Release-Dokumentationen. Eine Kopie pro Release unter
> `docs/releases/vX.Y.Z.md` anlegen, Platzhalter ersetzen, Abschnitte die
> nicht zutreffen entfernen. Die Seite wird unter `/docs/vX.Y.Z` in der App
> angezeigt.
**Datum:** YYYY-MM-DD · **Tag:** [`vX.Y.Z`](https://git.kubebase.de/admin/tool-evaluator/tags/vX.Y.Z)
## Neue Features
- ...
## Fixes & Verbesserungen
- ...
## API-Änderungen
- ... (neue/geänderte/entfernte Endpunkte — siehe `lib/api-spec/openapi.yaml`)
## Betrieb / Upgrade
- **Env-Vars:** ... (neu/geändert/entfernt)
- **Migration:** ... (Datenbank-/Schema-Änderungen, Schritte für den Betreiber)
- **Breaking Changes:** ... (nur wenn vorhanden)
## Bekannte Einschränkungen
- ...
## Links
- Commit: [`<short-sha>`](https://git.kubebase.de/admin/tool-evaluator/commit/<short-sha>)
- Tag: [`vX.Y.Z`](https://git.kubebase.de/admin/tool-evaluator/tags/vX.Y.Z)
+37
View File
@@ -0,0 +1,37 @@
# v0.6.0 — Release Notes
**Datum:** 2026-08-03 · **Tag:** [`v0.6.0`](https://git.kubebase.de/admin/tool-evaluator/tags/v0.6.0)
## Neue Features
- Vollständige Modernisierung aller Abhängigkeiten auf die aktuellen Hauptversionen
(TypeScript 7, Vite 8, React 19.2, openid-client 6, zod 4, recharts 3, react-day-picker 10).
## Fixes & Verbesserungen
- CI-Build durch `allowBuilds`-Konfiguration für pnpm 11 repariert
(Build-Scripts für esbuild & Co. werden nicht mehr blockiert).
- Image-Tagging vereinfacht: nur noch `latest` und `v*`-Tags, keine `nightly-*`/`sha-*`-Tags.
- Alle Dependencies exakt gepinnt; automatische Updates via Renovate vorbereitet
(`renovate.json`, `docs/dependency-policy.md`).
## API-Änderungen
- Keine Breaking Changes an der API. openid-client intern auf v6 migriert
(auth-Fluss verhält sich identisch).
## Betrieb / Upgrade
- **Env-Vars:** unverändert. Node-Image auf `node:24.18.1-alpine` gepinnt.
- **Migration:** keine Datenbank-Migration erforderlich.
- **Breaking Changes:** keine.
## Bekannte Einschränkungen
- `typedoc` (indirekte orval-Abhängigkeit) zeigt eine Peer-Dependency-Warnung
(erwartet TypeScript 5.x/6.x, installiert ist 7.x) — harmlos für Build & Laufzeit.
## Links
- Commit: [`2f66fff`](https://git.kubebase.de/admin/tool-evaluator/commit/2f66fff)
- Tag: [`v0.6.0`](https://git.kubebase.de/admin/tool-evaluator/tags/v0.6.0)
+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: [`6c92b63`](https://git.kubebase.de/admin/tool-evaluator/commit/6c92b63)
- 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
+47
View File
@@ -0,0 +1,47 @@
# v0.8.1 — Release Notes
**Datum:** 2026-08-03 · **Tag:** [`v0.8.1`](https://git.kubebase.de/admin/tool-evaluator/tags/v0.8.1)
## Neue Features
- **Standalone-Doku-Seite**: Die Dokumentation steht jetzt als eigene,
mkdocs-artige Seite unter `toolr.kubebase.de/docs` — ohne die App-Shell
(eigene Kopfzeile mit Repo-Link, Versions-Dropdown, Suche, Theme-Umschalter
und Link zurück zur App).
- **Navigation umbenannt**: Der Seitenleisten-Eintrag heißt jetzt **„Hilfe"**
und führt zur Standalone-Doku.
- **User Guide komplett überarbeitet**: 17 Handbuch-Seiten mit
Schritt-für-Schritt-Anleitungen für alle Funktionen (Tools finden, Tool
anlegen, Bewerten, Watchlist, Vergleichen, Kosten, Analytics, Pläne,
Administration, Redundanz, Papierkorb, Tastenkürzel, Datenmodell).
## Fixes & Verbesserungen
- Referenz-Links sind jetzt unabhängig von Groß-/Kleinschreibung
(Schema-/Endpoint-Slugs wie `toolinput` und `ToolInput` funktionieren beide).
- Handbuch-Links auf Endpunkt-Anker korrigiert (PascalCase-OperationIds).
- Veraltete, kaputte Handbuch-Links (`vergleichen`, `watchlist` …) ersetzt.
- Dokumentations-Tabellenkopfzeilen und Hinweistexte in der Doku-Seite über
i18n internationalisiert (de/en).
## API-Änderungen
- Keine Änderungen an der API.
## Betrieb / Upgrade
- **Env-Vars:** unverändert.
- **Migration:** keine.
- **Breaking Changes:** keine. Die Doku-Seite ist unter `/docs` erreichbar wie
bisher; lediglich die Darstellung ist nun eigenständig.
## 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: [`fdf2d74`](https://git.kubebase.de/admin/tool-evaluator/commit/fdf2d74)
- Tag: [`v0.8.1`](https://git.kubebase.de/admin/tool-evaluator/tags/v0.8.1)
+71 -15
View File
@@ -42,6 +42,9 @@ catalogs:
clsx:
specifier: 2.1.1
version: 2.1.1
dompurify:
specifier: 3.4.12
version: 3.4.12
drizzle-orm:
specifier: 0.45.2
version: 0.45.2
@@ -51,6 +54,9 @@ catalogs:
lucide-react:
specifier: 1.28.0
version: 1.28.0
marked:
specifier: 18.0.7
version: 18.0.7
react:
specifier: 19.2.8
version: 19.2.8
@@ -64,14 +70,17 @@ catalogs:
specifier: 4.3.3
version: 4.3.3
tsx:
specifier: 4.23.1
version: 4.23.1
specifier: 4.23.4
version: 4.23.4
vite:
specifier: 8.2.0
version: 8.2.0
wouter:
specifier: 3.10.0
version: 3.10.0
yaml:
specifier: 2.9.0
version: 2.9.0
zod:
specifier: 4.4.3
version: 4.4.3
@@ -359,7 +368,7 @@ importers:
version: 0.0.6
'@tailwindcss/vite':
specifier: 'catalog:'
version: 4.3.3(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.8.4))
version: 4.3.3(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.4)(yaml@2.8.4))
'@types/node':
specifier: 'catalog:'
version: 26.1.2
@@ -371,7 +380,7 @@ importers:
version: 19.2.4(@types/react@19.2.18)
'@vitejs/plugin-react':
specifier: 'catalog:'
version: 6.0.5(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.8.4))
version: 6.0.5(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.4)(yaml@2.8.4))
chokidar:
specifier: 5.0.0
version: 5.0.0
@@ -446,7 +455,7 @@ importers:
version: 1.1.2(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
vite:
specifier: 'catalog:'
version: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.8.4)
version: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.4)(yaml@2.8.4)
zod:
specifier: 'catalog:'
version: 4.4.3
@@ -551,7 +560,7 @@ importers:
version: 0.5.20(tailwindcss@4.3.3)
'@tailwindcss/vite':
specifier: 'catalog:'
version: 4.3.3(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.8.4))
version: 4.3.3(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.4)(yaml@2.8.4))
'@tanstack/react-query':
specifier: 'catalog:'
version: 5.101.4(react@19.2.8)
@@ -569,7 +578,7 @@ importers:
version: 19.2.4(@types/react@19.2.18)
'@vitejs/plugin-react':
specifier: 'catalog:'
version: 6.0.5(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.8.4))
version: 6.0.5(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.4)(yaml@2.8.4))
'@workspace/api-client-react':
specifier: workspace:*
version: link:../../lib/api-client-react
@@ -585,6 +594,9 @@ importers:
date-fns:
specifier: 4.4.0
version: 4.4.0
dompurify:
specifier: 'catalog:'
version: 3.4.12
embla-carousel-react:
specifier: 8.6.0
version: 8.6.0(react@19.2.8)
@@ -600,6 +612,9 @@ importers:
lucide-react:
specifier: 'catalog:'
version: 1.28.0(react@19.2.8)
marked:
specifier: 'catalog:'
version: 18.0.7
next-themes:
specifier: 0.4.6
version: 0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
@@ -647,7 +662,7 @@ importers:
version: 1.1.2(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
vite:
specifier: 'catalog:'
version: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.8.4)
version: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.4)(yaml@2.8.4)
wouter:
specifier: 'catalog:'
version: 3.10.0(react@19.2.8)
@@ -705,7 +720,10 @@ importers:
version: 26.1.2
tsx:
specifier: 'catalog:'
version: 4.23.1
version: 4.23.4
yaml:
specifier: 'catalog:'
version: 2.9.0
packages:
@@ -2101,6 +2119,9 @@ packages:
'@types/serve-static@2.2.0':
resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==}
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
'@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
@@ -2470,6 +2491,9 @@ packages:
detect-node-es@1.1.0:
resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
dompurify@3.4.12:
resolution: {integrity: sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==}
drizzle-kit@0.31.10:
resolution: {integrity: sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==}
hasBin: true
@@ -2959,6 +2983,11 @@ packages:
resolution: {integrity: sha512-Lf8ajvVNdRpzSNB4VegxNy7gjs8gU35l4b4+ET49LrQC5PKYwLZ72u60LeJ9gv3qiaesuYjJWCyVeQmv/QWKQw==}
hasBin: true
marked@18.0.7:
resolution: {integrity: sha512-iDVQ5ldaiKXn6b2JroX5kgRfmwgqolW7NpaEzTl1k/2Zh1njIEN9yniyLV/mOvWwtsE8OGgkjsCYvijuPk1dtA==}
engines: {node: '>= 20'}
hasBin: true
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
@@ -3557,6 +3586,11 @@ packages:
engines: {node: '>=18.0.0'}
hasBin: true
tsx@4.23.4:
resolution: {integrity: sha512-ZiUQ8oT/KzN51mJUWPqARYqwFLFJZtGZipRkw1ynHMr9vy3eU77m5yfF3Gzm6meEg/beW+lUu3fHYgskTN2oVQ==}
engines: {node: '>=18.0.0'}
hasBin: true
tw-animate-css@1.4.0:
resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==}
@@ -3723,6 +3757,11 @@ packages:
engines: {node: '>= 14.6'}
hasBin: true
yaml@2.9.0:
resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==}
engines: {node: '>= 14.6'}
hasBin: true
yocto-queue@1.2.2:
resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==}
engines: {node: '>=12.20'}
@@ -5002,12 +5041,12 @@ snapshots:
postcss-selector-parser: 6.0.10
tailwindcss: 4.3.3
'@tailwindcss/vite@4.3.3(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.8.4))':
'@tailwindcss/vite@4.3.3(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.4)(yaml@2.8.4))':
dependencies:
'@tailwindcss/node': 4.3.3
'@tailwindcss/oxide': 4.3.3
tailwindcss: 4.3.3
vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.8.4)
vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.4)(yaml@2.8.4)
'@tanstack/query-core@5.101.4': {}
@@ -5140,6 +5179,9 @@ snapshots:
'@types/http-errors': 2.0.5
'@types/node': 25.6.2
'@types/trusted-types@2.0.7':
optional: true
'@types/unist@3.0.3': {}
'@types/use-sync-external-store@0.0.6': {}
@@ -5204,10 +5246,10 @@ snapshots:
'@typescript/typescript-win32-x64@7.0.2':
optional: true
'@vitejs/plugin-react@6.0.5(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.8.4))':
'@vitejs/plugin-react@6.0.5(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.4)(yaml@2.8.4))':
dependencies:
'@rolldown/pluginutils': 1.0.1
vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.8.4)
vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.4)(yaml@2.8.4)
accepts@2.0.0:
dependencies:
@@ -5407,6 +5449,10 @@ snapshots:
detect-node-es@1.1.0: {}
dompurify@3.4.12:
optionalDependencies:
'@types/trusted-types': 2.0.7
drizzle-kit@0.31.10:
dependencies:
'@drizzle-team/brocli': 0.10.2
@@ -5802,6 +5848,8 @@ snapshots:
punycode.js: 2.3.1
uc.micro: 3.0.0
marked@18.0.7: {}
math-intrinsics@1.1.0: {}
mdurl@2.1.0: {}
@@ -6402,6 +6450,12 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
tsx@4.23.4:
dependencies:
esbuild: 0.28.1
optionalDependencies:
fsevents: 2.3.3
tw-animate-css@1.4.0: {}
type-is@2.0.1:
@@ -6521,7 +6575,7 @@ snapshots:
d3-time: 3.1.0
d3-timer: 3.0.1
vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.8.4):
vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.4)(yaml@2.8.4):
dependencies:
lightningcss: 1.33.0
picomatch: 4.0.5
@@ -6533,7 +6587,7 @@ snapshots:
esbuild: 0.28.1
fsevents: 2.3.3
jiti: 2.7.0
tsx: 4.23.1
tsx: 4.23.4
yaml: 2.8.4
which@2.0.2:
@@ -6553,6 +6607,8 @@ snapshots:
yaml@2.8.4: {}
yaml@2.9.0: {}
yocto-queue@1.2.2: {}
yoctocolors@2.1.2: {}
+4 -1
View File
@@ -61,16 +61,19 @@ catalog:
'@vitejs/plugin-react': 6.0.5
class-variance-authority: 0.7.1
clsx: 2.1.1
dompurify: 3.4.12
drizzle-orm: 0.45.2
framer-motion: 12.43.0
lucide-react: 1.28.0
marked: 18.0.7
react: 19.2.8
react-dom: 19.2.8
tailwind-merge: 3.6.0
tailwindcss: 4.3.3
tsx: 4.23.1
tsx: 4.23.4
vite: 8.2.0
wouter: 3.10.0
yaml: 2.9.0
zod: 4.4.3
autoInstallPeers: false
+2 -1
View File
@@ -9,6 +9,7 @@
},
"devDependencies": {
"@types/node": "catalog:",
"tsx": "catalog:"
"tsx": "catalog:",
"yaml": "catalog:"
}
}
+385
View File
@@ -0,0 +1,385 @@
// Builds the /docs static content for the toolrate frontend.
//
// Sources:
// - lib/api-spec/openapi.yaml -> reference.json (endpoints + schemas)
// - docs/handbook/*.md -> handbook pages (current docs)
// - docs/releases/*.md -> version-bound release notes
// - docs/releases/<v>/reference.json -> per-version reference snapshots
//
// Usage:
// node scripts/src/generate-docs.mjs # build mode (run before vite build/dev)
// node scripts/src/generate-docs.mjs --snapshot v0.8.0 # write docs/releases/<v>/reference.json
//
// Build mode copies committed snapshots and regenerates the CURRENT reference
// from the live openapi.yaml. The --snapshot mode is run manually when
// preparing a release so that older versions keep their own field reference.
import { readFile, readdir, copyFile, mkdir, rm, writeFile, stat } from "node:fs/promises";
import { resolve, join, dirname, basename } from "node:path";
import { fileURLToPath } from "node:url";
import { parse as parseYaml } from "yaml";
const root = resolve(fileURLToPath(new URL("../..", import.meta.url)));
const openapiPath = resolve(root, "lib/api-spec/openapi.yaml");
const handbookDir = resolve(root, "docs/handbook");
const releasesDir = resolve(root, "docs/releases");
const targetDir = resolve(root, "artifacts/toolrate/public/docs");
const isReleaseFile = (name) => /^v\d+\.\d+\.\d+\.md$/.test(name);
const isHandbookFile = (name) => /\.md$/.test(name);
// ---------------------------------------------------------------------------
// Version helpers
// ---------------------------------------------------------------------------
function parseVersion(name) {
return name.replace(/\.md$/, "");
}
function cmp(a, b) {
const pa = parseVersion(a).slice(1).split(".").map(Number);
const pb = parseVersion(b).slice(1).split(".").map(Number);
for (let i = 0; i < 3; i++) {
if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) - (pb[i] ?? 0);
}
return 0;
}
function extractTitle(content) {
const m = content.match(/^#\s+(.+)$/m);
return m ? m[1].trim() : null;
}
function extractDate(content) {
const m = content.match(/(?:Datum|Date)[^\d\n]{0,20}(\d{4}-\d{2}-\d{2})/);
return m ? m[1] : null;
}
// ---------------------------------------------------------------------------
// OpenAPI -> reference model
// ---------------------------------------------------------------------------
function deref(schema) {
return schema && typeof schema === "object" ? schema : {};
}
function fieldType(schema) {
const s = deref(schema);
if (s.$ref) {
return { kind: "ref", value: s.$ref.split("/").pop() };
}
if (Array.isArray(s.type)) {
return { kind: "type", value: s.type.filter(Boolean).join(" | ") };
}
if (s.type === "array") {
const item = deref(s.items);
if (item.$ref) return { kind: "array", value: item.$ref.split("/").pop() };
return { kind: "array", value: String(item.type ?? "any") };
}
return { kind: "type", value: String(s.type ?? "any") };
}
function describeField(schema) {
const s = deref(schema);
const parts = [];
if (s.format) parts.push(s.format);
if (Array.isArray(s.enum) && s.enum.length > 0) parts.push(s.enum.join(", "));
if (s.minLength != null) parts.push(`min ${s.minLength} chars`);
if (s.minItems != null) parts.push(`min ${s.minItems} items`);
if (s.maxItems != null) parts.push(`max ${s.maxItems} items`);
if (s.minimum != null && s.maximum != null) parts.push(`${s.minimum}${s.maximum}`);
else if (s.minimum != null) parts.push(`>= ${s.minimum}`);
else if (s.maximum != null) parts.push(`<= ${s.maximum}`);
return parts.join(", ");
}
function buildSchemaModel(name, schema) {
const s = deref(schema);
const required = new Set(Array.isArray(s.required) ? s.required : []);
const fields = Object.entries(s.properties ?? {})
.filter(([key]) => !key.startsWith("$"))
.map(([key, prop]) => {
const t = fieldType(prop);
return {
name: key,
type: t,
required: required.has(key),
description: deref(prop).description ?? "",
constraints: describeField(prop),
};
});
return {
name,
description: s.description ?? "",
fields,
};
}
function buildEndpointModel(path, pathItem) {
const models = [];
for (const [method, op] of Object.entries(pathItem)) {
if (!["get", "post", "patch", "put", "delete"].includes(method)) continue;
const o = deref(op);
const parameters = (o.parameters ?? []).map((p) => {
const s = deref(p.schema);
const t = fieldType(p.schema);
return {
name: p.name,
in: p.in,
required: !!p.required,
type: t,
description: p.description ?? s.description ?? "",
constraints: describeField(p.schema),
};
});
const requestBody = o.requestBody
? {
required: !!o.requestBody.required,
schema: fieldType(deref(o.requestBody).content?.["application/json"]?.schema),
}
: null;
const responses = Object.entries(o.responses ?? {}).map(([status, r]) => ({
status,
description: deref(r).description ?? "",
schema: fieldType(deref(r).content?.["application/json"]?.schema),
}));
models.push({
operationId: o.operationId ?? `${method} ${path}`,
method: method.toUpperCase(),
path,
summary: o.summary ?? "",
description: o.description ?? "",
parameters,
requestBody,
responses,
});
}
return models;
}
function buildReference(api) {
const tagNames = (api.tags ?? []).map((t) => t.name);
const tags = tagNames.map((name) => {
const meta = (api.tags ?? []).find((t) => t.name === name) ?? {};
const endpoints = [];
for (const [path, pathItem] of Object.entries(api.paths ?? {})) {
for (const model of buildEndpointModel(path, pathItem)) {
const rawOp = pathItem[model.method.toLowerCase()];
if ((rawOp?.tags ?? []).includes(name)) endpoints.push(model);
}
}
return { name, description: meta.description ?? "", endpoints };
});
const schemas = Object.entries(api.components?.schemas ?? {}).map(([name, s]) =>
buildSchemaModel(name, s),
);
return { tags, schemas };
}
// ---------------------------------------------------------------------------
// Handbook parsing (frontmatter: title, order, icon)
// ---------------------------------------------------------------------------
function parseHandbook(content) {
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
if (!match) return { frontmatter: {}, body: content };
let frontmatter = {};
try {
frontmatter = parseYaml(match[1]) ?? {};
} catch {
frontmatter = {};
}
return { frontmatter, body: content.slice(match[0].length) };
}
function slugify(name) {
return name.replace(/\.md$/, "").toLowerCase().replace(/[^a-z0-9-]+/g, "-");
}
async function buildHandbookIndex() {
let files;
try {
files = (await readdir(handbookDir)).filter(isHandbookFile);
} catch (err) {
if (err.code === "ENOENT") return [];
throw err;
}
const pages = [];
for (const file of files) {
const content = await readFile(join(handbookDir, file), "utf8");
const { frontmatter, body } = parseHandbook(content);
pages.push({
slug: slugify(basename(file)),
file,
title: frontmatter.title ?? extractTitle(body) ?? basename(file),
order: typeof frontmatter.order === "number" ? frontmatter.order : 999,
});
await writeFile(join(targetDir, "handbook", file), body);
}
pages.sort((a, b) => a.order - b.order || a.title.localeCompare(b.title));
return pages;
}
// ---------------------------------------------------------------------------
// Search index
// ---------------------------------------------------------------------------
function stripMarkdown(md) {
return md
.replace(/```[\s\S]*?```/g, " ")
.replace(/[#>*`_\-\[\]()!]/g, " ")
.replace(/\s+/g, " ")
.trim();
}
async function buildSearchIndex(reference, handbookPages, releaseVersions) {
const entries = [];
for (const page of handbookPages) {
const file = join(handbookDir, page.file);
const content = await readFile(file, "utf8");
const { body } = parseHandbook(content);
entries.push({
title: page.title,
href: `/docs/handbook/${page.slug}`,
kind: "guide",
text: stripMarkdown(body),
});
}
for (const tag of reference.tags) {
for (const ep of tag.endpoints) {
entries.push({
title: `${ep.method} ${ep.path}`,
href: `/docs/reference/endpoints/${tag.name}#${ep.operationId}`,
kind: "endpoint",
text: `${ep.summary} ${ep.description} ${ep.parameters
.map((p) => `${p.name} ${p.description}`)
.join(" ")}`.trim(),
});
}
}
for (const schema of reference.schemas) {
for (const field of schema.fields) {
entries.push({
title: `${schema.name}.${field.name}`,
href: `/docs/reference/schemas/${schema.name}#${field.name}`,
kind: "field",
text: `${field.description} ${field.constraints} ${field.type.value ?? ""}`.trim(),
});
}
}
for (const version of releaseVersions) {
const file = join(releasesDir, `${version}.md`);
const content = await readFile(file, "utf8");
entries.push({
title: version,
href: `/docs/releases/${version}`,
kind: "release",
text: stripMarkdown(content),
});
}
return entries;
}
// ---------------------------------------------------------------------------
// Snapshot mode
// ---------------------------------------------------------------------------
async function writeSnapshot(version) {
const api = parseYaml(await readFile(openapiPath, "utf8"));
const reference = buildReference(api);
const dir = join(releasesDir, version);
await mkdir(dir, { recursive: true });
await writeFile(join(dir, "reference.json"), JSON.stringify(reference, null, 2));
console.log(`[generate-docs] snapshot ${version} -> ${join(dir, "reference.json")}`);
}
// ---------------------------------------------------------------------------
// Build mode
// ---------------------------------------------------------------------------
async function main() {
const snapshotArg = process.argv.indexOf("--snapshot");
if (snapshotArg >= 0) {
const version = process.argv[snapshotArg + 1];
if (!version) {
console.error("[generate-docs] --snapshot requires a version, e.g. v0.8.0");
process.exit(1);
}
await writeSnapshot(version);
return;
}
let releaseNames;
try {
releaseNames = (await readdir(releasesDir)).filter(isReleaseFile);
} catch (err) {
if (err.code === "ENOENT") {
console.warn("[generate-docs] docs/releases not found; nothing to sync");
return;
}
throw err;
}
releaseNames.sort(cmp).reverse();
await rm(targetDir, { recursive: true, force: true });
await mkdir(join(targetDir, "handbook"), { recursive: true });
await mkdir(join(targetDir, "releases"), { recursive: true });
await mkdir(join(targetDir, "versions"), { recursive: true });
// 1. Current reference from live openapi.yaml
const api = parseYaml(await readFile(openapiPath, "utf8"));
const reference = buildReference(api);
await writeFile(join(targetDir, "reference.json"), JSON.stringify(reference, null, 2));
// 2. Handbook pages (current docs)
const handbookPages = await buildHandbookIndex();
await writeFile(
join(targetDir, "handbook/index.json"),
JSON.stringify(handbookPages, null, 2),
);
// 3. Release notes + versioned reference snapshots
const versions = [];
for (const name of releaseNames) {
const version = parseVersion(name);
const content = await readFile(join(releasesDir, name), "utf8");
await copyFile(join(releasesDir, name), join(targetDir, "releases", name));
const hasSnapshot = await stat(join(releasesDir, version, "reference.json"))
.then(() => true)
.catch(() => false);
if (hasSnapshot) {
await copyFile(
join(releasesDir, version, "reference.json"),
join(targetDir, "versions", `${version}.json`),
);
}
versions.push({
version,
file: `releases/${name}`,
title: extractTitle(content) ?? version,
date: extractDate(content) ?? null,
hasReference: hasSnapshot,
});
}
await writeFile(join(targetDir, "index.json"), JSON.stringify(versions, null, 2));
// 4. Search index
const searchIndex = await buildSearchIndex(reference, handbookPages, versions.map((v) => v.version));
await writeFile(join(targetDir, "search.json"), JSON.stringify(searchIndex, null, 2));
console.log(
`[generate-docs] synced ${versions.length} release(s), ${handbookPages.length} handbook page(s), ` +
`${reference.schemas.length} schema(s), ${searchIndex.length} search entries`,
);
}
main().catch((err) => {
console.error("[generate-docs] failed:", err);
process.exit(1);
});