Files
tool-evaluator/artifacts/toolrate/src/pages/docs.tsx
T
opencode 6eb3dfd8f9 docs: cut to v0.9.0 with unified .de/.en naming
- Remove all prior release notes and version snapshots (v0.6.0-v0.8.6)
- Rename handbook to *.de.md; keep *.en.md
- New release notes v0.9.0.de.md/.en.md with v0.9.0 snapshot
- generate-docs.mjs: .de/.en convention incl. search index and en-fallback
- docs.tsx: release fallback now uses version.de.md
- Wording: neutral tagline, anonymous user, no gender forms
- Add docs/examples/import-tools.yaml and link from administration docs
2026-08-05 00:13:41 +02:00

1080 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState, createContext, useContext } from "react";
import { Link, useLocation } from "wouter";
import { Marked } from "marked";
import DOMPurify from "dompurify";
import { useTranslation } from "react-i18next";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { ThemeToggle } from "@/components/theme-toggle";
import { LanguageSwitcher } from "@/components/language-switcher";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Sheet,
SheetContent,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { useGetVersion, getGetVersionQueryKey } from "@workspace/api-client-react";
import {
ArrowLeft,
BookOpen,
CalendarDays,
ExternalLink,
FileText,
GitBranch,
Library,
Menu,
Search,
Server,
Tag,
Wrench,
type LucideIcon,
} from "lucide-react";
const DOCS_BASE = `${import.meta.env.BASE_URL.replace(/\/$/, "")}/docs`;
const REPO_URL = "https://git.kubebase.de/admin/tool-evaluator";
// Active docs version (null = current docs). Shared via context so every
// sub-view builds versioned links and file paths.
const DocsVersionContext = createContext<string | null>(null);
const useDocsVersion = () => useContext(DocsVersionContext);
// Full in-app URL for a docs path, prefixed with the active version.
function docsHref(version: string | null, path: string) {
return version === null ? `/docs/${path}` : `/docs/${version}/${path}`;
}
// Static file path under /docs, prefixed with the versioned snapshot dir.
function docsFile(version: string | null, relPath: string) {
return version === null ? relPath : `versions/${version}/${relPath}`;
}
// Prefer the English file/title variant when the active UI language is English.
function useDocsLocale() {
const { i18n } = useTranslation();
return (i18n.language ?? "en").toLowerCase().startsWith("en") ? "en" : "de";
}
function localizedFile(file: string, fileEn: string | null, locale: "en" | "de"): string {
return locale === "en" && fileEn ? fileEn : file;
}
function localizedTitle(title: string, titleEn: string | null, locale: "en" | "de"): string {
return locale === "en" && titleEn ? titleEn : title;
}
// ---------------------------------------------------------------------------
// 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;
fileEn: string | null;
title: string;
titleEn: string | null;
date: string | null;
hasReference: boolean;
hasHandbook: boolean;
};
type HandbookPage = {
slug: string;
file: string;
fileEn: string | null;
title: string;
titleEn: string | null;
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 {
const version = useDocsVersion();
const href = (v: string | null) =>
v === null ? `/docs/reference/schemas/${t.value}` : `/docs/${v}/reference/schemas/${t.value}`;
if (t.kind === "ref") return href(version);
if (t.kind === "array" && /^[A-Z]/.test(t.value)) return href(version);
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 locale = useDocsLocale();
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) {
groups.push({
label: t("docs.guides"),
icon: BookOpen,
items: handbook.map((p) => ({
href: docsHref(version, `handbook/${p.slug}`),
label: localizedTitle(p.title, p.titleEn, locale),
active: navLink(docsHref(version, `handbook/${p.slug}`)),
})),
});
}
if (reference) {
groups.push({
label: t("docs.endpoints"),
icon: Server,
items: reference.tags.map((tag) => ({
href: docsHref(version, `reference/endpoints/${tag.name}`),
label: tag.name,
active: navLink(docsHref(version, `reference/endpoints/${tag.name}`)),
})),
});
groups.push({
label: t("docs.schemas"),
icon: Library,
items: reference.schemas.map((s) => ({
href: docsHref(version, `reference/schemas/${s.name}`),
label: s.name,
active: navLink(docsHref(version, `reference/schemas/${s.name}`)),
})),
});
}
groups.push({
label: t("docs.releases"),
icon: Tag,
items: versions.map((v) => ({
href: `/docs/releases/${v.version}`,
label: v.version,
active: navLink(`/docs/releases/${v.version}`),
})),
});
return (
<nav className="space-y-6" aria-label={t("docs.nav")}>
{groups.map((group) => (
<div key={group.label}>
<div className="mb-1.5 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
<group.icon className="h-3.5 w-3.5" />
{group.label}
</div>
<ul className="space-y-0.5">
{group.items.map((item) => (
<li key={item.href}>
<Link
href={item.href}
className={`block rounded-md px-2 py-1.5 text-sm transition-colors ${
item.active
? "bg-accent text-accent-foreground font-medium"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
}`}
>
<span className="truncate block">{item.label}</span>
</Link>
</li>
))}
</ul>
</div>
))}
</nav>
);
}
function Toc({ headings, title }: { headings: Heading[]; title?: string }) {
const [activeId, setActiveId] = useState<string | null>(null);
const { t } = useTranslation();
useEffect(() => {
if (headings.length === 0) return;
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) setActiveId(entry.target.id);
}
},
{ rootMargin: "-80px 0px -70% 0px" },
);
for (const h of headings) {
const el = document.getElementById(h.id);
if (el) observer.observe(el);
}
return () => observer.disconnect();
}, [headings]);
if (headings.length === 0) return null;
return (
<aside className="hidden xl:block" aria-label={t("docs.onThisPage")}>
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{title ?? t("docs.onThisPage")}
</p>
<ul className="space-y-1 border-l">
{headings.map((h) => (
<li key={h.id} style={{ paddingLeft: `${Math.min(h.level - 1, 2)}rem` }}>
<a
href={`#${h.id}`}
className={`block border-l -ml-px px-2 py-0.5 text-xs transition-colors ${
activeId === h.id
? "border-primary text-foreground font-medium"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
{h.text}
</a>
</li>
))}
</ul>
</aside>
);
}
function MarkdownView({ file }: { file: string | null }) {
const { t } = useTranslation();
const { html, headings, error } = useMarkdown(file);
return (
<div className="flex flex-col lg:flex-row gap-8">
<div className="flex-1 min-w-0 max-w-3xl">
{error ? (
<p className="text-sm text-muted-foreground">{t("docs.noDocs")}</p>
) : !html ? (
<div className="space-y-3">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
) : (
<div
className="docs-prose prose dark:prose-invert max-w-none"
dangerouslySetInnerHTML={{ __html: html }}
/>
)}
</div>
<Toc headings={headings ?? []} />
</div>
);
}
function FieldTypeChip({ type }: { type: FieldType }) {
const href = resolveTypeHref(type);
const label = fieldTypeLabel(type);
if (href) {
return (
<Link href={href} className="inline-flex">
<Badge variant="secondary" className="font-mono hover:bg-accent">
{label}
</Badge>
</Link>
);
}
return <Badge variant="secondary" className="font-mono">{label}</Badge>;
}
function FieldTable({ fields }: { fields: Field[] }) {
const { t } = useTranslation();
return (
<div className="overflow-x-auto rounded-lg border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50 text-left text-xs uppercase tracking-wider text-muted-foreground">
<th className="px-3 py-2 font-semibold">{t("docs.field")}</th>
<th className="px-3 py-2 font-semibold">{t("docs.type")}</th>
<th className="px-3 py-2 font-semibold">{t("docs.required")}</th>
<th className="px-3 py-2 font-semibold">{t("docs.description")}</th>
</tr>
</thead>
<tbody>
{fields.map((f) => (
<tr key={f.name} id={f.name} className="border-b last:border-0 align-top">
<td className="px-3 py-2">
<a href={`#${f.name}`} className="font-mono text-primary hover:underline">
{f.name}
</a>
</td>
<td className="px-3 py-2">
<FieldTypeChip type={f.type} />
</td>
<td className="px-3 py-2">
{f.required ? (
<Badge className="bg-primary/10 text-primary border-primary/20">{t("docs.required")}</Badge>
) : (
<span className="text-muted-foreground"></span>
)}
</td>
<td className="px-3 py-2">
<div className="text-muted-foreground">{f.description}</div>
{f.constraints && (
<div className="mt-0.5 text-xs text-muted-foreground/70 font-mono">{f.constraints}</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function SchemaView({ schema }: { schema: SchemaModel }) {
const { t } = useTranslation();
const headings: Heading[] = schema.fields.map((f) => ({ id: f.name, text: f.name, level: 2 }));
return (
<div className="flex flex-col lg:flex-row gap-8">
<div className="flex-1 min-w-0 max-w-3xl space-y-4">
<div className="space-y-1">
<h1 className="text-2xl font-bold tracking-tight font-mono">{schema.name}</h1>
{schema.description && <p className="text-muted-foreground">{schema.description}</p>}
</div>
<FieldTable fields={schema.fields} />
</div>
<Toc headings={headings} title={t("docs.fields")} />
</div>
);
}
function EndpointTagView({ tag }: { tag: TagGroup }) {
const { t } = useTranslation();
const headings: Heading[] = tag.endpoints.map((e) => ({
id: e.operationId,
text: `${e.method} ${e.path}`,
level: 2,
}));
return (
<div className="flex flex-col lg:flex-row gap-8">
<div className="flex-1 min-w-0 max-w-3xl space-y-8">
<div className="space-y-1">
<h1 className="text-2xl font-bold tracking-tight">{tag.name}</h1>
{tag.description && <p className="text-muted-foreground">{tag.description}</p>}
</div>
{tag.endpoints.map((ep) => (
<section key={ep.operationId} id={ep.operationId} className="scroll-mt-20">
<div className="mb-2 flex items-center gap-2">
<Badge className={`font-mono ${METHOD_STYLES[ep.method] ?? "bg-muted text-muted-foreground"}`}>
{ep.method}
</Badge>
<code className="font-mono text-sm">{ep.path}</code>
<a href={`#${ep.operationId}`} className="ml-auto text-muted-foreground hover:text-foreground">
<ExternalLink className="h-3.5 w-3.5" />
</a>
</div>
<h2 className="mb-1 text-lg font-semibold">{ep.summary}</h2>
{ep.description && <p className="mb-3 text-sm text-muted-foreground">{ep.description}</p>}
{ep.parameters.length > 0 && (
<div className="mb-3">
<p className="mb-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground">{t("docs.parameter")}</p>
<div className="overflow-x-auto rounded-lg border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50 text-left text-xs uppercase tracking-wider text-muted-foreground">
<th className="px-3 py-2 font-semibold">{t("docs.name")}</th>
<th className="px-3 py-2 font-semibold">{t("docs.in")}</th>
<th className="px-3 py-2 font-semibold">{t("docs.type")}</th>
<th className="px-3 py-2 font-semibold">{t("docs.required")}</th>
<th className="px-3 py-2 font-semibold">{t("docs.description")}</th>
</tr>
</thead>
<tbody>
{ep.parameters.map((p) => (
<tr key={`${p.name}-${p.in}`} className="border-b last:border-0">
<td className="px-3 py-1.5 font-mono">{p.name}</td>
<td className="px-3 py-1.5 text-muted-foreground">{p.in}</td>
<td className="px-3 py-1.5"><FieldTypeChip type={p.type} /></td>
<td className="px-3 py-1.5">
{p.required ? <Badge className="bg-primary/10 text-primary border-primary/20">{t("docs.required")}</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">
{t("docs.requestBody")} {ep.requestBody.required && <Badge className="ml-1">{t("docs.required")}</Badge>}
</p>
<FieldTypeChip type={ep.requestBody.schema} />
</div>
)}
<div className="overflow-x-auto rounded-lg border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50 text-left text-xs uppercase tracking-wider text-muted-foreground">
<th className="px-3 py-2 font-semibold">{t("docs.status")}</th>
<th className="px-3 py-2 font-semibold">{t("docs.description")}</th>
<th className="px-3 py-2 font-semibold">{t("docs.schema")}</th>
</tr>
</thead>
<tbody>
{ep.responses.map((r) => (
<tr key={r.status} className="border-b last:border-0">
<td className="px-3 py-1.5 font-mono">{r.status}</td>
<td className="px-3 py-1.5 text-muted-foreground">{r.description}</td>
<td className="px-3 py-1.5"><FieldTypeChip type={r.schema} /></td>
</tr>
))}
</tbody>
</table>
</div>
</section>
))}
</div>
<Toc headings={headings} title={t("docs.endpoints")} />
</div>
);
}
function ReleasesView({ versions }: { versions: ReleaseDoc[] }) {
const [, setLocation] = useLocation();
const { t } = useTranslation();
const locale = useDocsLocale();
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">{localizedTitle(v.title, v.titleEn, locale)}</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">{t("docs.reference")}</Badge>}
</button>
))}
</div>
</div>
<Toc headings={[]} />
</div>
);
}
function ReleaseNoteView({ version, doc }: { version: string; doc: ReleaseDoc | null }) {
const locale = useDocsLocale();
const file = doc ? `releases/${localizedFile(doc.file, doc.fileEn, locale)}` : `releases/${version}.de.md`;
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}/releases/tag/${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={file} />
</div>
</div>
);
}
function HandbookView({ slug, pages }: { slug: string; pages: HandbookPage[] | null }) {
const version = useDocsVersion();
const locale = useDocsLocale();
const page = pages?.find((p) => p.slug === slug);
const file = page
? docsFile(version, `handbook/${localizedFile(page.file, page.fileEn, locale)}`)
: docsFile(version, `handbook/${slug}.md`);
return <MarkdownView file={file} />;
}
// ---------------------------------------------------------------------------
// Search overlay
// ---------------------------------------------------------------------------
function useDocsSearch(query: string) {
const locale = useDocsLocale();
const indexFile = locale === "en" ? "search.en.json" : "search.json";
const { data, error } = useJson<SearchEntry[]>(query ? `${DOCS_BASE}/${indexFile}` : null);
const results = useMemo(() => {
if (!query.trim() || !data) return [];
const q = query.trim().toLowerCase();
return data
.filter(
(e) =>
e.title.toLowerCase().includes(q) ||
e.text.toLowerCase().includes(q),
)
.slice(0, 25);
}, [query, data]);
return { results, error };
}
function SearchOverlay({ query, onClose }: { query: string; onClose: () => void }) {
const { t } = useTranslation();
const { results } = useDocsSearch(query);
if (!query.trim()) return null;
return (
<div className="mt-3 rounded-lg border bg-card p-2 shadow-md max-h-96 overflow-auto">
{results.length === 0 ? (
<p className="px-3 py-2 text-sm text-muted-foreground">{t("docs.noResults")}</p>
) : (
results.map((r) => (
<Link
key={r.href}
href={r.href}
onClick={onClose}
className="flex items-start gap-2 rounded-md px-3 py-2 text-sm hover:bg-accent"
>
<span className="shrink-0">
{r.kind === "endpoint" && <Server className="h-4 w-4 text-emerald-500" />}
{r.kind === "field" && <Library className="h-4 w-4 text-blue-500" />}
{r.kind === "guide" && <BookOpen className="h-4 w-4 text-amber-500" />}
{r.kind === "release" && <Tag className="h-4 w-4 text-primary" />}
</span>
<span className="min-w-0">
<span className="block font-medium truncate">{r.title}</span>
<span className="block text-xs text-muted-foreground truncate">{r.text.slice(0, 80)}</span>
</span>
</Link>
))
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export default function Docs() {
const [location, setLocation] = useLocation();
const { t } = useTranslation();
const [searchQuery, setSearchQuery] = useState("");
const [navOpen, setNavOpen] = useState(false);
// Close the mobile nav drawer after navigating.
useEffect(() => {
setNavOpen(false);
}, [location]);
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 activeRelease = releases?.find((r) => r.version === version) ?? null;
const handbookAvailable = version === null || activeRelease?.hasHandbook;
const handbookUrl = handbookAvailable ? `${DOCS_BASE}/${docsFile(version, "handbook/index.json")}` : null;
const { data: handbook, error: handbookError } = useJson<HandbookPage[]>(handbookUrl);
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`
: activeRelease?.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]);
const handleVersionChange = (v: string | null) => {
setSearchQuery("");
if (v === null || v === currentVersion) {
setLocation("/docs");
return;
}
setLocation(`/docs/${v}`);
};
// ---- route resolution ----
const section = path[0] ?? "home";
const param = path[1];
// Version shown in the header dropdown: an explicit first-segment version
// (/docs/vX.Y.Z) or the releases route (/docs/releases/vX.Y.Z).
const headerVersion =
version !== null
? version
: section === "releases" && param
? param
: null;
let content: React.ReactNode = null;
if (version !== null && path.length === 0) {
content =
handbook && handbook.length > 0 ? (
<HandbookView slug={handbook[0].slug} pages={handbook} />
) : (
<ReleaseNoteView version={version} doc={activeRelease} />
);
} else if (section === "home") {
content =
handbook && handbook.length > 0 ? (
<HandbookView slug={handbook[0].slug} pages={handbook} />
) : 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} pages={handbook} />;
} else if (section === "reference" && param === "endpoints" && path[2]) {
const tag = reference?.tags.find(
(tg) => tg.name.toLowerCase() === path[2].toLowerCase(),
);
content = tag ? (
<EndpointTagView tag={tag} />
) : refError || (reference && !tag) ? (
<p className="text-sm text-muted-foreground">{t("docs.noDocs")}</p>
) : (
<Skeleton className="h-64 w-full" />
);
} else if (section === "reference" && param === "schemas" && path[2]) {
const schema = reference?.schemas.find(
(s) => s.name.toLowerCase() === path[2].toLowerCase(),
);
content = schema ? (
<SchemaView schema={schema} />
) : refError || (reference && !schema) ? (
<p className="text-sm text-muted-foreground">{t("docs.noDocs")}</p>
) : (
<Skeleton className="h-64 w-full" />
);
} else if (section === "reference") {
content = (
<div className="flex flex-col lg:flex-row gap-8">
<div className="flex-1 min-w-0 max-w-3xl space-y-4">
<h1 className="text-2xl font-bold tracking-tight">{t("docs.reference")}</h1>
<p className="text-muted-foreground">{t("docs.referenceIntro")}</p>
{!reference && !refError && <Skeleton className="h-64 w-full" />}
{reference && (
<>
<div>
<h2 className="mb-1 text-lg font-semibold">{t("docs.endpoints")}</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{reference.tags.map((tg) => (
<Link
key={tg.name}
href={docsHref(version, `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={docsHref(version, `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} doc={releases?.find((r) => r.version === param) ?? null} />;
} 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 (
<DocsVersionContext.Provider value={version}>
<div className="min-h-screen bg-background flex flex-col">
<header className="border-b bg-card shrink-0">
<div className="mx-auto max-w-6xl px-4 md:px-6 h-14 flex items-center justify-between gap-3">
<div className="flex items-center gap-1 min-w-0">
<Sheet open={navOpen} onOpenChange={setNavOpen}>
<SheetTrigger asChild>
<Button
variant="ghost"
size="icon"
className="lg:hidden -ml-1 shrink-0"
title={t("docs.nav")}
data-testid="button-docs-nav-mobile"
>
<Menu className="h-5 w-5" />
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-80 overflow-y-auto p-4">
<SheetTitle className="sr-only">{t("docs.nav")}</SheetTitle>
<DocsNav
version={version}
handbook={handbook}
reference={reference}
versions={releases ?? []}
/>
</SheetContent>
</Sheet>
<Link
href="/"
className="inline-flex items-center gap-2 text-primary font-bold text-lg min-w-0"
data-testid="link-docs-back"
>
<Wrench className="w-5 h-5 shrink-0" />
<span className="truncate">toolr</span>
<span className="hidden md:inline-flex items-center gap-1 text-xs font-normal text-muted-foreground border-l pl-2 ml-1">
<ArrowLeft className="w-3.5 h-3.5" />
{t("docs.backToApp")}
</span>
</Link>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<LanguageSwitcher />
<ThemeToggle />
</div>
</div>
</header>
<div className="mx-auto max-w-6xl w-full flex-1 space-y-6 px-4 md:px-6 py-6 pb-12">
<DocsHeader
versions={releases ?? []}
activeVersion={headerVersion}
onVersionChange={handleVersionChange}
onSearchChange={showSearch ? setSearchQuery : undefined}
/>
{showSearch && <SearchOverlay query={searchQuery} onClose={() => setSearchQuery("")} />}
<div className="grid grid-cols-1 lg:grid-cols-[240px_minmax(0,1fr)] gap-8">
<aside className="hidden lg:block">
<div className="sticky top-20 max-h-[calc(100vh-6rem)] overflow-auto">
<DocsNav
version={version}
handbook={handbook}
reference={reference}
versions={releases ?? []}
/>
</div>
</aside>
<div className="min-w-0">{content}</div>
</div>
</div>
</div>
</DocsVersionContext.Provider>
);
}