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, HelpCircle, 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(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(url: string): Promise { return fetch(url).then((res) => { if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json() as Promise; }); } function useJson(url: string | null) { const [data, setData] = useState(null); const [error, setError] = useState(false); useEffect(() => { if (!url) { setData(null); setError(false); return; } let cancelled = false; setData(null); setError(false); fetchJson(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(); 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 `${text}`; }, }; 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 = { 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 (

{t("docs.title")}

{onSearchChange && (
onSearchChange(e.target.value)} className="pl-8 w-52" data-testid="input-docs-search" />
)} {versions.length > 0 && ( )}
); } 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 ( ); } function Toc({ headings, title }: { headings: Heading[]; title?: string }) { const [activeId, setActiveId] = useState(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 ( ); } function MarkdownView({ file }: { file: string | null }) { const { t } = useTranslation(); const { html, headings, error } = useMarkdown(file); return (
{error ? (

{t("docs.noDocs")}

) : !html ? (
) : (
)}
); } function FieldTypeChip({ type }: { type: FieldType }) { const href = resolveTypeHref(type); const label = fieldTypeLabel(type); if (href) { return ( {label} ); } return {label}; } function FieldTable({ fields }: { fields: Field[] }) { const { t } = useTranslation(); return (
{fields.map((f) => ( ))}
{t("docs.field")} {t("docs.type")} {t("docs.required")} {t("docs.description")}
{f.name} {f.required ? ( {t("docs.required")} ) : ( )}
{f.description}
{f.constraints && (
{f.constraints}
)}
); } function SchemaView({ schema }: { schema: SchemaModel }) { const { t } = useTranslation(); const headings: Heading[] = schema.fields.map((f) => ({ id: f.name, text: f.name, level: 2 })); return (

{schema.name}

{schema.description &&

{schema.description}

}

{t("docs.fieldHelpHint")}

); } 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 (

{tag.name}

{tag.description &&

{tag.description}

}
{tag.endpoints.map((ep) => (
{ep.method} {ep.path}

{ep.summary}

{ep.description &&

{ep.description}

} {ep.parameters.length > 0 && (

{t("docs.parameter")}

{ep.parameters.map((p) => ( ))}
{t("docs.name")} {t("docs.in")} {t("docs.type")} {t("docs.required")} {t("docs.description")}
{p.name} {p.in} {p.required ? {t("docs.required")} : "–"} {p.description} {p.constraints && {p.constraints}}
)} {ep.requestBody && (

{t("docs.requestBody")} {ep.requestBody.required && {t("docs.required")}}

)}
{ep.responses.map((r) => ( ))}
{t("docs.status")} {t("docs.description")} {t("docs.schema")}
{r.status} {r.description}
))}
); } function ReleasesView({ versions }: { versions: ReleaseDoc[] }) { const [, setLocation] = useLocation(); const { t } = useTranslation(); const locale = useDocsLocale(); return (

{t("docs.releases")}

{t("docs.subtitle")}

{versions.map((v) => ( ))}
); } function ReleaseNoteView({ version, doc }: { version: string; doc: ReleaseDoc | null }) { const locale = useDocsLocale(); const file = doc ? `releases/${localizedFile(doc.file, doc.fileEn, locale)}` : `releases/${version}.md`; return (
{version}
); } 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 ; } // --------------------------------------------------------------------------- // Search overlay // --------------------------------------------------------------------------- function useDocsSearch(query: string) { const locale = useDocsLocale(); const indexFile = locale === "en" ? "search.en.json" : "search.json"; const { data, error } = useJson(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 (
{results.length === 0 ? (

{t("docs.noResults")}

) : ( results.map((r) => ( {r.kind === "endpoint" && } {r.kind === "field" && } {r.kind === "guide" && } {r.kind === "release" && } {r.title} {r.text.slice(0, 80)} )) )}
); } // --------------------------------------------------------------------------- // 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(`${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(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(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 ? ( ) : ( ); } else if (section === "home") { content = handbook && handbook.length > 0 ? ( ) : releases && releases.length > 0 ? ( ) : (

{t("docs.noDocs")}

); } else if (section === "handbook" && param) { content = ; } else if (section === "reference" && param === "endpoints" && path[2]) { const tag = reference?.tags.find( (tg) => tg.name.toLowerCase() === path[2].toLowerCase(), ); content = tag ? ( ) : refError || (reference && !tag) ? (

{t("docs.noDocs")}

) : ( ); } else if (section === "reference" && param === "schemas" && path[2]) { const schema = reference?.schemas.find( (s) => s.name.toLowerCase() === path[2].toLowerCase(), ); content = schema ? ( ) : refError || (reference && !schema) ? (

{t("docs.noDocs")}

) : ( ); } else if (section === "reference") { content = (

{t("docs.reference")}

{t("docs.referenceIntro")}

{!reference && !refError && } {reference && ( <>

{t("docs.endpoints")}

{reference.tags.map((tg) => ( {tg.name} {tg.endpoints.length} {t("docs.endpoints")} ))}

{t("docs.schemas")}

{reference.schemas.map((s) => ( {s.name} ))}
)}
); } else if (section === "releases" && param) { content = r.version === param) ?? null} />; } else if (section === "releases") { content = releases ? : ; } else { content =

{t("docs.noDocs")}

; } const showSearch = version === null && section !== "releases"; return (
{t("docs.nav")} toolr {t("docs.backToApp")}
{showSearch && setSearchQuery("")} />}
{content}
); }