Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ceb11e7f9 | |||
| 6c92b6358d | |||
| 520f917723 | |||
| d1dd77bc1e | |||
| 0be45b6513 |
@@ -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
|
||||
git clone "https://admin:${GITEA_TOKEN}@git.kubebase.de/admin/apps.git" /tmp/apps
|
||||
cd /tmp/apps
|
||||
cd apps/system/toolrate/overlays/k3s
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -37,6 +37,31 @@ function buildCrumbs(location: string, t: TFunction): Crumb[] {
|
||||
crumbs.push({ label: t("compare.title") });
|
||||
} else if (location.startsWith("/analytics")) {
|
||||
crumbs.push({ label: t("nav.analytics") });
|
||||
} else if (location.startsWith("/docs")) {
|
||||
crumbs.push({ href: "/docs", label: t("docs.title") });
|
||||
const m = location.replace(/^\/docs\/?/, "");
|
||||
if (m.startsWith("handbook/")) {
|
||||
crumbs.push({ href: "/docs/handbook", label: t("docs.guides") });
|
||||
const slug = m.replace(/^handbook\//, "").split("#")[0];
|
||||
if (slug) crumbs.push({ label: slug });
|
||||
} else if (m.startsWith("reference/")) {
|
||||
crumbs.push({ href: "/docs/reference/endpoints", label: t("docs.reference") });
|
||||
const rest = m.replace(/^reference\//, "").split("#")[0];
|
||||
if (rest.startsWith("schemas/")) {
|
||||
crumbs.push({ label: t("docs.schemas") });
|
||||
const name = rest.replace(/^schemas\//, "");
|
||||
if (name) crumbs.push({ label: name });
|
||||
} else {
|
||||
const tag = rest.replace(/^endpoints\//, "");
|
||||
if (tag) crumbs.push({ label: tag });
|
||||
}
|
||||
} else if (m.startsWith("releases/")) {
|
||||
crumbs.push({ href: "/docs/releases", label: t("docs.releases") });
|
||||
const version = m.replace(/^releases\//, "").split("#")[0];
|
||||
if (version) crumbs.push({ label: version });
|
||||
} else if (m) {
|
||||
crumbs.push({ label: m });
|
||||
}
|
||||
} else if (location.startsWith("/login")) {
|
||||
crumbs.push({ label: t("auth.signIn") });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { HelpCircle } from "lucide-react";
|
||||
import { Link } from "wouter";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
export function FieldHelp({
|
||||
schema,
|
||||
field,
|
||||
children,
|
||||
}: {
|
||||
schema: string;
|
||||
field: string;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const label = children ?? field;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href={`/docs/reference/schemas/${schema}#${field}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label={`Help: ${label}`}
|
||||
data-testid={`help-${schema}-${field}`}
|
||||
className="inline-flex shrink-0 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<HelpCircle className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{label} — Details in der Dokumentation</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -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": "Doku",
|
||||
"search": "Tools suchen…"
|
||||
},
|
||||
"auth": {
|
||||
@@ -181,6 +182,24 @@
|
||||
"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…"
|
||||
},
|
||||
"command": {
|
||||
"navigate": "Navigation",
|
||||
"recent": "Zuletzt besucht",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"trash": "Trash",
|
||||
"admin": "Admin",
|
||||
"redundancy": "Redundancy",
|
||||
"docs": "Docs",
|
||||
"search": "Search tools…"
|
||||
},
|
||||
"auth": {
|
||||
@@ -181,6 +182,24 @@
|
||||
"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…"
|
||||
},
|
||||
"command": {
|
||||
"navigate": "Navigate",
|
||||
"recent": "Recent",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,953 @@
|
||||
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 { Layout } from "@/components/layout";
|
||||
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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useGetVersion, getGetVersionQueryKey } from "@workspace/api-client-react";
|
||||
import {
|
||||
BookOpen,
|
||||
CalendarDays,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
GitBranch,
|
||||
HelpCircle,
|
||||
Library,
|
||||
Search,
|
||||
Server,
|
||||
Tag,
|
||||
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[] }) {
|
||||
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">Feld</th>
|
||||
<th className="px-3 py-2 font-semibold">Typ</th>
|
||||
<th className="px-3 py-2 font-semibold">Pflicht</th>
|
||||
<th className="px-3 py-2 font-semibold">Beschreibung</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 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" />
|
||||
Hinweis: Formular-Felder verlinken per ?-Icon direkt zu den jeweiligen Zeilen dieser Tabelle.
|
||||
</p>
|
||||
</div>
|
||||
<Toc headings={headings} title="Felder" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EndpointTagView({ tag }: { tag: TagGroup }) {
|
||||
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">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">Typ</th>
|
||||
<th className="px-3 py-2 font-semibold">Pflicht</th>
|
||||
<th className="px-3 py-2 font-semibold">Beschreibung</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">Status</th>
|
||||
<th className="px-3 py-2 font-semibold">Beschreibung</th>
|
||||
<th className="px-3 py-2 font-semibold">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="Endpunkte" />
|
||||
</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 { 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">Keine Treffer</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 === path[2]);
|
||||
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 === path[2]);
|
||||
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 (
|
||||
<Layout>
|
||||
<div className="mx-auto max-w-6xl space-y-6 pb-10">
|
||||
<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>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -199,7 +206,10 @@ export default function ToolEdit() {
|
||||
name="websiteUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Website URL (Optional)</FormLabel>
|
||||
<FormLabel className="inline-flex items-center gap-1.5">
|
||||
Website URL (Optional)
|
||||
<FieldHelp schema="ToolInput" field="websiteUrl">Website URL</FieldHelp>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://..." type="url" {...field} />
|
||||
</FormControl>
|
||||
@@ -213,7 +223,10 @@ export default function ToolEdit() {
|
||||
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">
|
||||
@@ -246,7 +259,10 @@ export default function ToolEdit() {
|
||||
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?"
|
||||
@@ -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: "" })}>
|
||||
|
||||
@@ -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}
|
||||
@@ -166,7 +173,10 @@ export default function ToolNew() {
|
||||
name="websiteUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Website URL (Optional)</FormLabel>
|
||||
<FormLabel className="inline-flex items-center gap-1.5">
|
||||
Website URL (Optional)
|
||||
<FieldHelp schema="ToolInput" field="websiteUrl">Website URL</FieldHelp>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<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
|
||||
|
||||
@@ -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`.
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
title: Administration & Papierkorb
|
||||
order: 7
|
||||
---
|
||||
|
||||
# Administration & Papierkorb
|
||||
|
||||
Diese Bereiche sind nur für **Administrator:innen** sichtbar und nutzbar.
|
||||
|
||||
## Nutzerverwaltung
|
||||
|
||||
Unter **Admin → Nutzer** kannst du:
|
||||
|
||||
- **Lokale Nutzer anlegen** (Benutzername, Passwort, E-Mail, Rolle, Tier).
|
||||
- **Rollen/Tier ändern** (`admin`/`user`, `free`/`premium`/`enterprise`).
|
||||
- **Passwörter zurücksetzen** (nur lokale Nutzer).
|
||||
- **Nutzer löschen**.
|
||||
|
||||
Zugehörige Endpunkte (Admin-only):
|
||||
|
||||
- [`GET /users`](/docs/reference/endpoints/users#listusers)
|
||||
- [`POST /users`](/docs/reference/endpoints/users#createuser)
|
||||
- [`PATCH /users/{id}`](/docs/reference/endpoints/users#updateuser)
|
||||
- [`DELETE /users/{id}`](/docs/reference/endpoints/users#deleteuser)
|
||||
- [`PATCH /users/{id}/password`](/docs/reference/endpoints/users#setuserpassword)
|
||||
|
||||
## Audit-Log
|
||||
|
||||
Das **Audit-Log** protokolliert sicherheitsrelevante Änderungen (wer hat wann
|
||||
was geändert). Es ist über
|
||||
[`GET /audit-logs`](/docs/reference/endpoints/audit#listauditlogs) abrufbar und
|
||||
filterbar nach Entitätstyp, Entitäts-ID und Limit.
|
||||
|
||||
## Papierkorb (Trash)
|
||||
|
||||
Tools werden nicht sofort gelöscht, sondern zuerst **soft gelöscht** (in den
|
||||
Papierkorb verschoben):
|
||||
|
||||
- **Liste:** [`GET /tools/trash`](/docs/reference/endpoints/tools#listtrashedtools)
|
||||
- **In den Papierkorb verschieben:** [`POST /tools/trash`](/docs/reference/endpoints/tools#trashtools)
|
||||
- **Wiederherstellen:** [`POST /tools/trash/restore`](/docs/reference/endpoints/tools#restoretools)
|
||||
- **Endgültig löschen (einzeln):** [`DELETE /tools/trash`](/docs/reference/endpoints/tools#deletetrashedtools)
|
||||
- **Papierkorb leeren:** [`POST /tools/trash/empty`](/docs/reference/endpoints/tools#emptytrash)
|
||||
|
||||
> Die Aufbewahrungsfrist des Papierkorbs (in Tagen) ist im
|
||||
> [`VersionInfo`](/docs/reference/schemas/versioninfo)-Schema als
|
||||
> `trashRetentionDays` verfügbar.
|
||||
|
||||
## Felder im Überblick
|
||||
|
||||
### User
|
||||
|
||||
| Feld | Typ | Bedeutung |
|
||||
| --- | --- | --- |
|
||||
| `id` | integer | Eindeutige Nutzer-ID. |
|
||||
| `username` | string | Anmeldename. |
|
||||
| `email` | string? | E-Mail-Adresse. |
|
||||
| `role` | string | `admin` oder `user`. |
|
||||
| `tier` | string | `free`, `premium` oder `enterprise`. |
|
||||
| `authProvider` | string | `local` oder `oidc`. |
|
||||
| `createdAt` | date-time | Erstellungszeitpunkt. |
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
title: Analytics
|
||||
order: 6
|
||||
---
|
||||
|
||||
# Analytics
|
||||
|
||||
Der Bereich **Analytics** fasst die Plattform-Statistiken zusammen — für alle
|
||||
Nutzer:innen ohne Einschränkung sichtbar.
|
||||
|
||||
## Übersicht
|
||||
|
||||
| Widget | Quelle (Endpunkt) | Inhalt |
|
||||
| --- | --- | --- |
|
||||
| **Plattform-Kennzahlen** | [`GET /analytics/summary`](/docs/reference/endpoints/analytics#getanalyticssummary) | Gesamtzahl Tools, Bewertungen, Durchschnittswerte, Kategorienzahl. |
|
||||
| **Top-Tools** | [`GET /analytics/top-tools`](/docs/reference/endpoints/analytics#gettoppertools) | Bestbewertete Tools nach wählbarer Metrik (Nützlichkeit, Bedienbarkeit, kombiniert). |
|
||||
| **Nach Kategorie** | [`GET /analytics/by-category`](/docs/reference/endpoints/analytics#getanalyticsbycategory) | Kennzahlen je Kategorie. |
|
||||
| **Verteilung** | [`GET /analytics/rating-distribution`](/docs/reference/endpoints/analytics#getratingdistribution) | Verteilung der Bewertungswerte (optional je Tool). |
|
||||
|
||||
## Datenfelder
|
||||
|
||||
### AnalyticsSummary
|
||||
|
||||
| Feld | Typ | Bedeutung |
|
||||
| --- | --- | --- |
|
||||
| `totalTools` | integer | Anzahl aller Tools. |
|
||||
| `totalRatings` | integer | Anzahl aller Bewertungen. |
|
||||
| `avgUsefulness` | number? | Durchschnittliche Nützlichkeit. |
|
||||
| `avgUsability` | number? | Durchschnittliche Bedienbarkeit. |
|
||||
| `avgCombined` | number? | Durchschnitt kombinierter Wert. |
|
||||
| `categoriesCount` | integer | Anzahl der Kategorien. |
|
||||
| `mostRatedTool` | ToolWithStats | Das meistbewertete Tool. |
|
||||
|
||||
Vollständige Feldlisten: [`AnalyticsSummary`](/docs/reference/schemas/analyticssummary),
|
||||
[`TopToolEntry`](/docs/reference/schemas/topptoolentry),
|
||||
[`CategoryStats`](/docs/reference/schemas/categorystats),
|
||||
[`RatingDistribution`](/docs/reference/schemas/ratingdistribution).
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
title: Bewertungen
|
||||
order: 4
|
||||
---
|
||||
|
||||
# Bewertungen
|
||||
|
||||
Bewertungen sind das Herz von toolr: Sie zeigen, wie nützlich und wie gut
|
||||
bedienbar ein Tool in den Augen der Community ist.
|
||||
|
||||
## Wie funktioniert die Bewertung?
|
||||
|
||||
Auf der Detailseite eines Tools vergibst du zwei Werte (1–5 Sterne):
|
||||
|
||||
- **Nützlichkeit** — Wie gut löst das Tool sein Kernproblem?
|
||||
- **Bedienbarkeit** — Wie einfach ist es zu bedienen?
|
||||
|
||||
Optional kannst du einen **Kommentar** und deinen **Namen** hinterlassen. Die
|
||||
Eingabefelder entsprechen dem Schema
|
||||
[`RatingInput`](/docs/reference/schemas/ratinginput).
|
||||
|
||||
## Was passiert mit meiner Bewertung?
|
||||
|
||||
- Deine Bewertung wird sofort gespeichert und in den Durchschnittswerten des
|
||||
Tools berücksichtigt.
|
||||
- Jede Bewertung ist über [`GET /tools/{id}/ratings`](/docs/reference/endpoints/ratings#listtoolratings)
|
||||
abrufbar.
|
||||
- Der **Rating-Verlauf** über die Zeit ist über
|
||||
[`GET /tools/{id}/rating-history`](/docs/reference/endpoints/tools#gettoolratinghistory)
|
||||
einsehbar (grafisch auf der Detailseite).
|
||||
|
||||
## Datenfelder
|
||||
|
||||
Eine Bewertung besteht aus diesen Feldern
|
||||
([`Rating`](/docs/reference/schemas/rating)):
|
||||
|
||||
| Feld | Typ | Bedeutung |
|
||||
| --- | --- | --- |
|
||||
| `id` | integer | Eindeutige ID der Bewertung. |
|
||||
| `toolId` | integer | ID des bewerteten Tools. |
|
||||
| `usefulness` | integer (1–5) | Nützlichkeitsbewertung. |
|
||||
| `usability` | integer (1–5) | Bedienbarkeitsbewertung. |
|
||||
| `comment` | string? | Optionaler Kommentar. |
|
||||
| `reviewerName` | string? | Optionaler Anzeigename des Bewerters. |
|
||||
| `createdAt` | date-time | Zeitpunkt der Bewertung. |
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
title: Datenmodell & Felder
|
||||
order: 8
|
||||
---
|
||||
|
||||
# Datenmodell & Felder
|
||||
|
||||
Dieses Handbuch erklärt die zentralen Objekte von toolr auf verständliche
|
||||
Weise. Die **vollständige, maschinell generierte Feld-Referenz** findest du in
|
||||
der [Referenz](/docs/reference/schemas/tool) — dort sind alle Typen, Pflicht-
|
||||
angaben und Constraints der aktuellen Version dokumentiert.
|
||||
|
||||
> Die Referenz ist **versionsgebunden**: Über das Versions-Dropdown oben
|
||||
> kannst du ältere API-Stände einsehen.
|
||||
|
||||
## Die wichtigsten Objekte
|
||||
|
||||
### Tool
|
||||
|
||||
Ein Tool ist der zentrale Eintrag im Katalog
|
||||
([Feld-Referenz](/docs/reference/schemas/tool)):
|
||||
|
||||
| Feld | Bedeutung |
|
||||
| --- | --- |
|
||||
| `id` | Eindeutige ID. |
|
||||
| `name` | Anzeigename. |
|
||||
| `description` | Kurzbeschreibung. |
|
||||
| `category` | Kategorie-Zuordnung. |
|
||||
| `websiteUrl` / `iconUrl` | Offizielle Website bzw. Logo-Link (optional). |
|
||||
| `createdBy` | Nutzer, der das Tool angelegt hat (optional). |
|
||||
| `features` / `tags` | Listen von Schlüsselfähigkeiten bzw. Schlagwörtern. |
|
||||
| `createdAt` / `updatedAt` | Zeitstempel. |
|
||||
| `deletedAt` / `deletedBy` | Soft-Delete-Informationen (Papierkorb). |
|
||||
|
||||
> **ToolWithStats** erweitert `Tool` um die Aggregatwerte `ratingCount`,
|
||||
> `avgUsefulness`, `avgUsability` und `avgCombined` (siehe
|
||||
> [Feld-Referenz](/docs/reference/schemas/toolwithstats)).
|
||||
|
||||
### Rating
|
||||
|
||||
Eine Bewertung (`Rating`) besteht aus `usefulness` und `usability` (jeweils
|
||||
1–5) sowie optionalem Kommentar und Bewerternamen. Details unter
|
||||
[Bewertungen](/docs/handbook/bewertungen).
|
||||
|
||||
### User / AuthUser
|
||||
|
||||
- **User** (Admin-Sicht): `id`, `username`, `email`, `role`, `tier`,
|
||||
`authProvider`, `createdAt` — siehe [Administration](/docs/handbook/administration).
|
||||
- **AuthUser** (Eigenansicht): `sub`, `email`, `name`, `preferredUsername`,
|
||||
`role`, `tier`, `entitlements`, `isLocal`.
|
||||
|
||||
### VersionInfo
|
||||
|
||||
`GET /version` liefert `version`, `commitSha`, `buildDate` und
|
||||
`trashRetentionDays` (siehe [Feld-Referenz](/docs/reference/schemas/versioninfo)).
|
||||
|
||||
## Referenz selber durchsuchen
|
||||
|
||||
Nutze das **Suchfeld** in der Doku-Seitenleiste: Es durchsucht Handbuch,
|
||||
Endpunkt- und Feldbeschreibungen und springt direkt zum passenden Anker.
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
title: Erste Schritte
|
||||
order: 2
|
||||
---
|
||||
|
||||
# Erste Schritte
|
||||
|
||||
Diese Seite führt dich durch die wichtigsten Abläufe in toolr — vom ersten
|
||||
Besuch bis zum Anlegen und Bewerten eines Tools.
|
||||
|
||||
## 1. Anmelden
|
||||
|
||||
Die meisten Aktionen (Tool anlegen, bewerten, Watchlist) erfordern ein
|
||||
Konto. Klicke oben rechts auf **Anmelden**. Je nach Konfiguration der Instanz
|
||||
hast du zwei Möglichkeiten:
|
||||
|
||||
- **Lokale Konten:** Benutzername + Passwort. Der Zugang wird von einem Admin
|
||||
angelegt (siehe [Administration](/docs/handbook/administration)).
|
||||
- **OIDC (SSO):** Anmelden mit dem konfigurierten Identitätsanbieter.
|
||||
|
||||
Welcher Modus aktiv ist, steht im [Endpunkt
|
||||
`GET /auth/mode`](/docs/reference/endpoints/auth#getauthmode).
|
||||
|
||||
## 2. Tools finden
|
||||
|
||||
Öffne den Bereich **Tools durchsuchen**:
|
||||
|
||||
- **Suchen** — Volltextsuche über Name & Beschreibung.
|
||||
- **Filtern** — nach Kategorie, Tags und Features; zusätzlich
|
||||
Mindestbewertung (`minRating`).
|
||||
- **Sortieren** — nach Aktualität, Top-Bewertung, meistbewertet, Name
|
||||
(auf-/absteigend) oder letztem Update.
|
||||
|
||||
Die Such-, Filter- und Sortierparameter entsprechen den Query-Parametern von
|
||||
[`GET /tools`](/docs/reference/endpoints/tools#listtools).
|
||||
|
||||
## 3. Tool anlegen
|
||||
|
||||
Gehe auf **Tool hinzufügen** und fülle das Formular aus. Details zu jedem Feld
|
||||
findest du im [Handbuch "Tool anlegen"](/docs/handbook/tool-anlegen) und in der
|
||||
[Feld-Referenz](/docs/reference/schemas/toolinput).
|
||||
|
||||
## 4. Bewerten
|
||||
|
||||
Auf der Detailseite eines Tools kannst du **Nützlichkeit** und
|
||||
**Bedienbarkeit** (jeweils 1–5) vergeben und optional einen Kommentar
|
||||
hinterlassen. Deine Bewertung fließt sofort in die Statistiken ein.
|
||||
|
||||
## 5. Weiterführend
|
||||
|
||||
- [Tools vergleichen](/docs/handbook/vergleichen)
|
||||
- [Watchlist](/docs/handbook/watchlist)
|
||||
- [Analytics](/docs/handbook/analytics)
|
||||
- [Administration & Papierkorb](/docs/handbook/administration)
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
title: Überblick
|
||||
order: 1
|
||||
---
|
||||
|
||||
# Willkommen bei toolr
|
||||
|
||||
toolr ist eine Plattform zum **Entdecken, Bewerten und Vergleichen von
|
||||
Entwicklungstools**. Nutzer:innen pflegen einen gemeinsamen Katalog von Tools,
|
||||
vergeben Bewertungen (Nützlichkeit & Bedienbarkeit) und nutzen Statistiken, um
|
||||
die richtige Wahl zu treffen.
|
||||
|
||||
## Was kannst du mit toolr tun?
|
||||
|
||||
| Funktion | Beschreibung | Sichtbarkeit |
|
||||
| --- | --- | --- |
|
||||
| **Tools durchsuchen** | Katalog filtern, sortieren und durchsuchen | Alle |
|
||||
| **Tool anlegen** | Neues Tool mit Beschreibung, Kategorie, Features & Tags eintragen | Angemeldet |
|
||||
| **Bewerten** | Nützlichkeit & Bedienbarkeit (1–5) plus Kommentar vergeben | Angemeldet |
|
||||
| **Vergleichen** | Tools nebeneinander gegenüberstellen | Premium |
|
||||
| **Watchlist** | Tools als Favoriten speichern | Premium |
|
||||
| **Analytics** | Statistiken, Top-Tools, Verteilungen | Alle |
|
||||
| **Admin** | Nutzerverwaltung, Audit-Log, Papierkorb | Admin |
|
||||
| **Trash** | Soft-gelöschte Tools wiederherstellen oder endgültig löschen | Admin |
|
||||
|
||||
## Funktionen & Felder im Detail
|
||||
|
||||
Die **Referenz** ist automatisch aus der OpenAPI-Spezifikation generiert und
|
||||
deckt damit garantiert *alle* Endpunkte und Datenfelder der aktuellen Version
|
||||
ab:
|
||||
|
||||
- [Endpunkte](/docs/reference/endpoints/tools) — jede API-Operation mit
|
||||
Parametern und Antwort-Schemas.
|
||||
- [Datenmodell & Felder](/docs/reference/schemas/tool) — jedes Feld mit Typ,
|
||||
Pflichtstatus und Bedeutung.
|
||||
|
||||
> Die Referenz ist **versionsgebunden**: Wähle oben rechts eine ältere Version,
|
||||
> um den API-Stand dieses Releases zu sehen.
|
||||
|
||||
## Erste Schritte
|
||||
|
||||
- Neu hier? Starte mit dem [Erste-Schritte-Guide](/docs/handbook/getting-started).
|
||||
- Möchtest du ein Tool eintragen? Siehe [Tool anlegen](/docs/handbook/tool-anlegen).
|
||||
- Formulare zeigen neben jedem Feld ein **Hilfe-Icon (?)**, das direkt zur
|
||||
Erklärung des Felds in der Doku springt.
|
||||
|
||||
## Wo ist der Quellcode?
|
||||
|
||||
Über das **Repository-Logo oben rechts** gelangst du direkt zum Quellcode auf
|
||||
GitHub.
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
title: Tool anlegen & bearbeiten
|
||||
order: 3
|
||||
---
|
||||
|
||||
# Tool anlegen & bearbeiten
|
||||
|
||||
## Neues Tool anlegen
|
||||
|
||||
Unter **Tool hinzufügen** legst du ein neues Tool an. Die Felder entsprechen
|
||||
dem Eingabeschema [`ToolInput`](/docs/reference/schemas/toolinput):
|
||||
|
||||
| Feld | Pflicht | Bedeutung |
|
||||
| --- | --- | --- |
|
||||
| **Name** | ja | Anzeigename des Tools (min. 2 Zeichen). |
|
||||
| **Beschreibung** | ja | Was tut das Tool, warum nutzen es Leute? (min. 10 Zeichen) |
|
||||
| **Kategorie** | ja | Zugeordnete Kategorie (aus bestehenden Kategorien wählbar). |
|
||||
| **Website-URL** | nein | Offizielle Website (`https://…`). |
|
||||
| **Icon-/Logo-URL** | nein | Direktlink zu einem Logo-Bild. |
|
||||
| **Features** | nein | Schlüsselfähigkeiten, z. B. „Echtzeit-Kollaboration“. Bereits bekannte Features sind auswählbar. |
|
||||
| **Tags** | nein | Schlagwörter zum Auffinden. Bereits bekannte Tags sind auswählbar. |
|
||||
|
||||
> Hinter jedem Label findest du ein **Hilfe-Icon (?)** — es verlinkt direkt
|
||||
> zur Feldbeschreibung in dieser Doku.
|
||||
|
||||
### Hinweise
|
||||
|
||||
- **Features & Tags** sind Listen. Über **+ Feature / + Tag** fügst du weitere
|
||||
Einträge hinzu; über das ✕-Symbol entfernst du sie.
|
||||
- URLs müssen absolut und gültig sein.
|
||||
- Leere Einträge in Feature-/Tag-Listen werden beim Speichern verworfen.
|
||||
|
||||
## Tool bearbeiten
|
||||
|
||||
Auf der Detailseite eines Tools öffnet **Bearbeiten** das Formular mit den
|
||||
aktuellen Werten. Du kannst Name, Beschreibung, Kategorie, URLs, Features und
|
||||
Tags ändern. Nur angemeldete Nutzer:innen können Tools bearbeiten.
|
||||
|
||||
## Tool löschen
|
||||
|
||||
Über die Detailseite kannst du ein Tool **in den Papierkorb verschieben**
|
||||
(Soft-Delete). Es verschwindet aus dem Katalog, bleibt aber im Papierkorb
|
||||
erhalten. Siehe [Administration & Papierkorb](/docs/handbook/administration).
|
||||
|
||||
## Zugehörige Endpunkte
|
||||
|
||||
- [`POST /tools`](/docs/reference/endpoints/tools#createtool) — Tool anlegen
|
||||
- [`PATCH /tools/{id}`](/docs/reference/endpoints/tools#updatetool) — Tool bearbeiten
|
||||
- [`DELETE /tools/{id}`](/docs/reference/endpoints/tools#deletetool) — Tool löschen
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
title: Vergleichen & Watchlist
|
||||
order: 5
|
||||
---
|
||||
|
||||
# Vergleichen & Watchlist
|
||||
|
||||
Diese Funktionen sind für **Premium-Nutzer:innen** verfügbar.
|
||||
|
||||
## Tools vergleichen
|
||||
|
||||
Mit **Vergleichen** stellst du mehrere Tools nebeneinander und siehst deren
|
||||
Daten auf einen Blick — ideal für die Tool-Auswahl.
|
||||
|
||||
1. Füge Tools über die **Vergleichsleiste** (Vergleichs-Icon auf Karten) hinzu.
|
||||
2. Öffne den Bereich **Vergleichen**. Die Tools erscheinen in der gewählten
|
||||
Reihenfolge.
|
||||
3. Die Vergleichsansicht zeigt pro Tool die wichtigsten Felder und
|
||||
Durchschnittswerte.
|
||||
|
||||
Der zugrunde liegende Endpunkt ist
|
||||
[`GET /compare`](/docs/reference/endpoints/tools#listcomparetools) mit dem
|
||||
Parameter `ids` (kommagetrennt). Ohne Premium-Berechtigung liefert er `403`.
|
||||
|
||||
## Watchlist
|
||||
|
||||
Die **Watchlist** ist deine persönliche Favoritenliste:
|
||||
|
||||
- **Hinzufügen/Entfernen:** Nutze das Lesezeichen-Symbol auf der
|
||||
Tool-Karte oder der Detailseite.
|
||||
- Die gespeicherte Reihenfolge bleibt erhalten.
|
||||
- Sie wird über [`GET /auth/me/watchlist`](/docs/reference/endpoints/auth#getmewatchlist)
|
||||
geladen; das Setzen erfolgt über
|
||||
[`PUT /auth/me/preferences`](/docs/reference/endpoints/auth#updatemepreferences)
|
||||
(Feld `watchlist`).
|
||||
|
||||
### Feld `watchlist`
|
||||
|
||||
Das Feld `watchlist` in [`UserPreferences`](/docs/reference/schemas/userpreferences)
|
||||
ist ein Array von Tool-IDs in gespeicherter Reihenfolge:
|
||||
|
||||
| Feld | Typ | Bedeutung |
|
||||
| --- | --- | --- |
|
||||
| `view` | string | Ansichtsmodus (`grid`, `table`, `rows`). |
|
||||
| `density` | string | Dichte (`cozy`, `compact`). |
|
||||
| `watchlist` | integer[] | Tool-IDs in Favoriten-Reihenfolge. |
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
Generated
+71
-15
@@ -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
@@ -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
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"tsx": "catalog:"
|
||||
"tsx": "catalog:",
|
||||
"yaml": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
// Builds the /docs static content for the toolrate frontend.
|
||||
//
|
||||
// Sources:
|
||||
// - lib/api-spec/openapi.yaml -> reference.json (endpoints + schemas)
|
||||
// - docs/handbook/*.md -> handbook pages (current docs)
|
||||
// - docs/releases/*.md -> version-bound release notes
|
||||
// - docs/releases/<v>/reference.json -> per-version reference snapshots
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/src/generate-docs.mjs # build mode (run before vite build/dev)
|
||||
// node scripts/src/generate-docs.mjs --snapshot v0.8.0 # write docs/releases/<v>/reference.json
|
||||
//
|
||||
// Build mode copies committed snapshots and regenerates the CURRENT reference
|
||||
// from the live openapi.yaml. The --snapshot mode is run manually when
|
||||
// preparing a release so that older versions keep their own field reference.
|
||||
|
||||
import { readFile, readdir, copyFile, mkdir, rm, writeFile, stat } from "node:fs/promises";
|
||||
import { resolve, join, dirname, basename } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
|
||||
const root = resolve(fileURLToPath(new URL("../..", import.meta.url)));
|
||||
const openapiPath = resolve(root, "lib/api-spec/openapi.yaml");
|
||||
const handbookDir = resolve(root, "docs/handbook");
|
||||
const releasesDir = resolve(root, "docs/releases");
|
||||
const targetDir = resolve(root, "artifacts/toolrate/public/docs");
|
||||
|
||||
const isReleaseFile = (name) => /^v\d+\.\d+\.\d+\.md$/.test(name);
|
||||
const isHandbookFile = (name) => /\.md$/.test(name);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Version helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parseVersion(name) {
|
||||
return name.replace(/\.md$/, "");
|
||||
}
|
||||
|
||||
function cmp(a, b) {
|
||||
const pa = parseVersion(a).slice(1).split(".").map(Number);
|
||||
const pb = parseVersion(b).slice(1).split(".").map(Number);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) - (pb[i] ?? 0);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function extractTitle(content) {
|
||||
const m = content.match(/^#\s+(.+)$/m);
|
||||
return m ? m[1].trim() : null;
|
||||
}
|
||||
|
||||
function extractDate(content) {
|
||||
const m = content.match(/(?:Datum|Date)[^\d\n]{0,20}(\d{4}-\d{2}-\d{2})/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OpenAPI -> reference model
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function deref(schema) {
|
||||
return schema && typeof schema === "object" ? schema : {};
|
||||
}
|
||||
|
||||
function fieldType(schema) {
|
||||
const s = deref(schema);
|
||||
if (s.$ref) {
|
||||
return { kind: "ref", value: s.$ref.split("/").pop() };
|
||||
}
|
||||
if (Array.isArray(s.type)) {
|
||||
return { kind: "type", value: s.type.filter(Boolean).join(" | ") };
|
||||
}
|
||||
if (s.type === "array") {
|
||||
const item = deref(s.items);
|
||||
if (item.$ref) return { kind: "array", value: item.$ref.split("/").pop() };
|
||||
return { kind: "array", value: String(item.type ?? "any") };
|
||||
}
|
||||
return { kind: "type", value: String(s.type ?? "any") };
|
||||
}
|
||||
|
||||
function describeField(schema) {
|
||||
const s = deref(schema);
|
||||
const parts = [];
|
||||
if (s.format) parts.push(s.format);
|
||||
if (Array.isArray(s.enum) && s.enum.length > 0) parts.push(s.enum.join(", "));
|
||||
if (s.minLength != null) parts.push(`min ${s.minLength} chars`);
|
||||
if (s.minItems != null) parts.push(`min ${s.minItems} items`);
|
||||
if (s.maxItems != null) parts.push(`max ${s.maxItems} items`);
|
||||
if (s.minimum != null && s.maximum != null) parts.push(`${s.minimum}–${s.maximum}`);
|
||||
else if (s.minimum != null) parts.push(`>= ${s.minimum}`);
|
||||
else if (s.maximum != null) parts.push(`<= ${s.maximum}`);
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
function buildSchemaModel(name, schema) {
|
||||
const s = deref(schema);
|
||||
const required = new Set(Array.isArray(s.required) ? s.required : []);
|
||||
const fields = Object.entries(s.properties ?? {})
|
||||
.filter(([key]) => !key.startsWith("$"))
|
||||
.map(([key, prop]) => {
|
||||
const t = fieldType(prop);
|
||||
return {
|
||||
name: key,
|
||||
type: t,
|
||||
required: required.has(key),
|
||||
description: deref(prop).description ?? "",
|
||||
constraints: describeField(prop),
|
||||
};
|
||||
});
|
||||
return {
|
||||
name,
|
||||
description: s.description ?? "",
|
||||
fields,
|
||||
};
|
||||
}
|
||||
|
||||
function buildEndpointModel(path, pathItem) {
|
||||
const models = [];
|
||||
for (const [method, op] of Object.entries(pathItem)) {
|
||||
if (!["get", "post", "patch", "put", "delete"].includes(method)) continue;
|
||||
const o = deref(op);
|
||||
const parameters = (o.parameters ?? []).map((p) => {
|
||||
const s = deref(p.schema);
|
||||
const t = fieldType(p.schema);
|
||||
return {
|
||||
name: p.name,
|
||||
in: p.in,
|
||||
required: !!p.required,
|
||||
type: t,
|
||||
description: p.description ?? s.description ?? "",
|
||||
constraints: describeField(p.schema),
|
||||
};
|
||||
});
|
||||
const requestBody = o.requestBody
|
||||
? {
|
||||
required: !!o.requestBody.required,
|
||||
schema: fieldType(deref(o.requestBody).content?.["application/json"]?.schema),
|
||||
}
|
||||
: null;
|
||||
const responses = Object.entries(o.responses ?? {}).map(([status, r]) => ({
|
||||
status,
|
||||
description: deref(r).description ?? "",
|
||||
schema: fieldType(deref(r).content?.["application/json"]?.schema),
|
||||
}));
|
||||
models.push({
|
||||
operationId: o.operationId ?? `${method} ${path}`,
|
||||
method: method.toUpperCase(),
|
||||
path,
|
||||
summary: o.summary ?? "",
|
||||
description: o.description ?? "",
|
||||
parameters,
|
||||
requestBody,
|
||||
responses,
|
||||
});
|
||||
}
|
||||
return models;
|
||||
}
|
||||
|
||||
function buildReference(api) {
|
||||
const tagNames = (api.tags ?? []).map((t) => t.name);
|
||||
const tags = tagNames.map((name) => {
|
||||
const meta = (api.tags ?? []).find((t) => t.name === name) ?? {};
|
||||
const endpoints = [];
|
||||
for (const [path, pathItem] of Object.entries(api.paths ?? {})) {
|
||||
for (const model of buildEndpointModel(path, pathItem)) {
|
||||
const rawOp = pathItem[model.method.toLowerCase()];
|
||||
if ((rawOp?.tags ?? []).includes(name)) endpoints.push(model);
|
||||
}
|
||||
}
|
||||
return { name, description: meta.description ?? "", endpoints };
|
||||
});
|
||||
const schemas = Object.entries(api.components?.schemas ?? {}).map(([name, s]) =>
|
||||
buildSchemaModel(name, s),
|
||||
);
|
||||
return { tags, schemas };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handbook parsing (frontmatter: title, order, icon)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parseHandbook(content) {
|
||||
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
|
||||
if (!match) return { frontmatter: {}, body: content };
|
||||
let frontmatter = {};
|
||||
try {
|
||||
frontmatter = parseYaml(match[1]) ?? {};
|
||||
} catch {
|
||||
frontmatter = {};
|
||||
}
|
||||
return { frontmatter, body: content.slice(match[0].length) };
|
||||
}
|
||||
|
||||
function slugify(name) {
|
||||
return name.replace(/\.md$/, "").toLowerCase().replace(/[^a-z0-9-]+/g, "-");
|
||||
}
|
||||
|
||||
async function buildHandbookIndex() {
|
||||
let files;
|
||||
try {
|
||||
files = (await readdir(handbookDir)).filter(isHandbookFile);
|
||||
} catch (err) {
|
||||
if (err.code === "ENOENT") return [];
|
||||
throw err;
|
||||
}
|
||||
const pages = [];
|
||||
for (const file of files) {
|
||||
const content = await readFile(join(handbookDir, file), "utf8");
|
||||
const { frontmatter, body } = parseHandbook(content);
|
||||
pages.push({
|
||||
slug: slugify(basename(file)),
|
||||
file,
|
||||
title: frontmatter.title ?? extractTitle(body) ?? basename(file),
|
||||
order: typeof frontmatter.order === "number" ? frontmatter.order : 999,
|
||||
});
|
||||
await writeFile(join(targetDir, "handbook", file), body);
|
||||
}
|
||||
pages.sort((a, b) => a.order - b.order || a.title.localeCompare(b.title));
|
||||
return pages;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Search index
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function stripMarkdown(md) {
|
||||
return md
|
||||
.replace(/```[\s\S]*?```/g, " ")
|
||||
.replace(/[#>*`_\-\[\]()!]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
async function buildSearchIndex(reference, handbookPages, releaseVersions) {
|
||||
const entries = [];
|
||||
|
||||
for (const page of handbookPages) {
|
||||
const file = join(handbookDir, page.file);
|
||||
const content = await readFile(file, "utf8");
|
||||
const { body } = parseHandbook(content);
|
||||
entries.push({
|
||||
title: page.title,
|
||||
href: `/docs/handbook/${page.slug}`,
|
||||
kind: "guide",
|
||||
text: stripMarkdown(body),
|
||||
});
|
||||
}
|
||||
|
||||
for (const tag of reference.tags) {
|
||||
for (const ep of tag.endpoints) {
|
||||
entries.push({
|
||||
title: `${ep.method} ${ep.path}`,
|
||||
href: `/docs/reference/endpoints/${tag.name}#${ep.operationId}`,
|
||||
kind: "endpoint",
|
||||
text: `${ep.summary} ${ep.description} ${ep.parameters
|
||||
.map((p) => `${p.name} ${p.description}`)
|
||||
.join(" ")}`.trim(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const schema of reference.schemas) {
|
||||
for (const field of schema.fields) {
|
||||
entries.push({
|
||||
title: `${schema.name}.${field.name}`,
|
||||
href: `/docs/reference/schemas/${schema.name}#${field.name}`,
|
||||
kind: "field",
|
||||
text: `${field.description} ${field.constraints} ${field.type.value ?? ""}`.trim(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const version of releaseVersions) {
|
||||
const file = join(releasesDir, `${version}.md`);
|
||||
const content = await readFile(file, "utf8");
|
||||
entries.push({
|
||||
title: version,
|
||||
href: `/docs/releases/${version}`,
|
||||
kind: "release",
|
||||
text: stripMarkdown(content),
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Snapshot mode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function writeSnapshot(version) {
|
||||
const api = parseYaml(await readFile(openapiPath, "utf8"));
|
||||
const reference = buildReference(api);
|
||||
const dir = join(releasesDir, version);
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(join(dir, "reference.json"), JSON.stringify(reference, null, 2));
|
||||
console.log(`[generate-docs] snapshot ${version} -> ${join(dir, "reference.json")}`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build mode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main() {
|
||||
const snapshotArg = process.argv.indexOf("--snapshot");
|
||||
if (snapshotArg >= 0) {
|
||||
const version = process.argv[snapshotArg + 1];
|
||||
if (!version) {
|
||||
console.error("[generate-docs] --snapshot requires a version, e.g. v0.8.0");
|
||||
process.exit(1);
|
||||
}
|
||||
await writeSnapshot(version);
|
||||
return;
|
||||
}
|
||||
|
||||
let releaseNames;
|
||||
try {
|
||||
releaseNames = (await readdir(releasesDir)).filter(isReleaseFile);
|
||||
} catch (err) {
|
||||
if (err.code === "ENOENT") {
|
||||
console.warn("[generate-docs] docs/releases not found; nothing to sync");
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
releaseNames.sort(cmp).reverse();
|
||||
|
||||
await rm(targetDir, { recursive: true, force: true });
|
||||
await mkdir(join(targetDir, "handbook"), { recursive: true });
|
||||
await mkdir(join(targetDir, "releases"), { recursive: true });
|
||||
await mkdir(join(targetDir, "versions"), { recursive: true });
|
||||
|
||||
// 1. Current reference from live openapi.yaml
|
||||
const api = parseYaml(await readFile(openapiPath, "utf8"));
|
||||
const reference = buildReference(api);
|
||||
await writeFile(join(targetDir, "reference.json"), JSON.stringify(reference, null, 2));
|
||||
|
||||
// 2. Handbook pages (current docs)
|
||||
const handbookPages = await buildHandbookIndex();
|
||||
await writeFile(
|
||||
join(targetDir, "handbook/index.json"),
|
||||
JSON.stringify(handbookPages, null, 2),
|
||||
);
|
||||
|
||||
// 3. Release notes + versioned reference snapshots
|
||||
const versions = [];
|
||||
for (const name of releaseNames) {
|
||||
const version = parseVersion(name);
|
||||
const content = await readFile(join(releasesDir, name), "utf8");
|
||||
await copyFile(join(releasesDir, name), join(targetDir, "releases", name));
|
||||
const hasSnapshot = await stat(join(releasesDir, version, "reference.json"))
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (hasSnapshot) {
|
||||
await copyFile(
|
||||
join(releasesDir, version, "reference.json"),
|
||||
join(targetDir, "versions", `${version}.json`),
|
||||
);
|
||||
}
|
||||
versions.push({
|
||||
version,
|
||||
file: `releases/${name}`,
|
||||
title: extractTitle(content) ?? version,
|
||||
date: extractDate(content) ?? null,
|
||||
hasReference: hasSnapshot,
|
||||
});
|
||||
}
|
||||
|
||||
await writeFile(join(targetDir, "index.json"), JSON.stringify(versions, null, 2));
|
||||
|
||||
// 4. Search index
|
||||
const searchIndex = await buildSearchIndex(reference, handbookPages, versions.map((v) => v.version));
|
||||
await writeFile(join(targetDir, "search.json"), JSON.stringify(searchIndex, null, 2));
|
||||
|
||||
console.log(
|
||||
`[generate-docs] synced ${versions.length} release(s), ${handbookPages.length} handbook page(s), ` +
|
||||
`${reference.schemas.length} schema(s), ${searchIndex.length} search entries`,
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("[generate-docs] failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user