feat(docs): version-bound release documentation served at /docs
Build & Push Docker Image / build (push) Successful in 2m35s
Build & Push Docker Image / build (push) Successful in 2m35s
Adds a docs pipeline so each release has a version-bound Markdown document (docs/releases/vX.Y.Z.md) rendered publicly in the app: - sync-release-docs.mjs copies docs/releases/*.md into the toolrate public dir and generates index.json before every dev/build - /docs lists all releases; /docs/:version renders the sanitized Markdown (marked + DOMPurify, typography styles) - template + workflow documented in docs/README.md - current release (v0.6.0) documented as the first entry
This commit is contained in:
@@ -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/sync-release-docs.mjs && vite --config vite.config.ts --host 0.0.0.0",
|
||||
"build": "node ../../scripts/src/sync-release-docs.mjs && vite build --config vite.config.ts",
|
||||
"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,8 @@ function Router() {
|
||||
<Route path="/admin" component={Admin} />
|
||||
<Route path="/admin/redundancy" component={Redundancy} />
|
||||
<Route path="/trash" component={Trash} />
|
||||
<Route path="/docs" component={Docs} />
|
||||
<Route path="/docs/:version" component={Docs} />
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
);
|
||||
|
||||
@@ -37,6 +37,10 @@ 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("nav.docs") });
|
||||
const match = location.match(/^\/docs\/(.+)$/);
|
||||
if (match) crumbs.push({ label: match[1] });
|
||||
} else if (location.startsWith("/login")) {
|
||||
crumbs.push({ label: t("auth.signIn") });
|
||||
}
|
||||
|
||||
@@ -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,12 @@
|
||||
"text": "Diese Seite existiert nicht.",
|
||||
"backHome": "Zurück zur Startseite"
|
||||
},
|
||||
"docs": {
|
||||
"title": "Versionsdokumentation",
|
||||
"subtitle": "Version-gebundene Dokumentation je Release — was ist neu, was hat sich geändert und was beim Upgrade zu beachten ist.",
|
||||
"backToIndex": "Alle Releases",
|
||||
"noDocs": "Noch keine Release-Dokumentation verfügbar."
|
||||
},
|
||||
"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,12 @@
|
||||
"text": "This page doesn't exist.",
|
||||
"backHome": "Back to Home"
|
||||
},
|
||||
"docs": {
|
||||
"title": "Release Documentation",
|
||||
"subtitle": "Version-bound documentation for each release — what's new, what changed, and what to know when upgrading.",
|
||||
"backToIndex": "All releases",
|
||||
"noDocs": "No release documentation available yet."
|
||||
},
|
||||
"command": {
|
||||
"navigate": "Navigate",
|
||||
"recent": "Recent",
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useLocation, useParams } from "wouter";
|
||||
import { marked } from "marked";
|
||||
import DOMPurify from "dompurify";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Layout } from "@/components/layout";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { FileText, CalendarDays, Tag, ArrowLeft, ExternalLink } from "lucide-react";
|
||||
|
||||
type ReleaseDoc = {
|
||||
version: string;
|
||||
file: string;
|
||||
title: string;
|
||||
date: string | null;
|
||||
};
|
||||
|
||||
const DOCS_BASE = `${import.meta.env.BASE_URL.replace(/\/$/, "")}/docs`;
|
||||
|
||||
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 useReleases() {
|
||||
const [releases, setReleases] = useState<ReleaseDoc[] | null>(null);
|
||||
const [error, setError] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetchJson<ReleaseDoc[]>(`${DOCS_BASE}/index.json`)
|
||||
.then((data) => {
|
||||
if (!cancelled) setReleases(data);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setError(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { releases, error };
|
||||
}
|
||||
|
||||
function useReleaseMarkdown(file: string) {
|
||||
const [html, setHtml] = useState<string | null>(null);
|
||||
const [error, setError] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setHtml(null);
|
||||
setError(false);
|
||||
fetch(`${DOCS_BASE}/${encodeURIComponent(file)}`)
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.text();
|
||||
})
|
||||
.then(async (md) => {
|
||||
const rendered = await marked.parse(md, { async: false, gfm: true });
|
||||
if (!cancelled) setHtml(DOMPurify.sanitize(rendered));
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setError(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [file]);
|
||||
|
||||
return { html, error };
|
||||
}
|
||||
|
||||
function DocsIndex() {
|
||||
const { t } = useTranslation();
|
||||
const { releases, error } = useReleases();
|
||||
const [, setLocation] = useLocation();
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="space-y-6 pb-10">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">{t("docs.title")}</h1>
|
||||
<p className="text-muted-foreground">{t("docs.subtitle")}</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-muted-foreground">{t("docs.noDocs")}</p>
|
||||
)}
|
||||
|
||||
{!releases && !error && (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-20 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{releases && releases.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">{t("docs.noDocs")}</p>
|
||||
)}
|
||||
|
||||
{releases && releases.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{releases.map((release) => (
|
||||
<Card key={release.version} className="hover:bg-accent/50 transition-colors">
|
||||
<CardContent className="p-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLocation(`/docs/${release.version}`)}
|
||||
className="flex w-full items-center gap-4 p-4 text-left"
|
||||
>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<FileText className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold">{release.title}</span>
|
||||
<Badge variant="secondary">{release.version}</Badge>
|
||||
</div>
|
||||
{release.date && (
|
||||
<p className="mt-0.5 flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<CalendarDays className="h-3.5 w-3.5" />
|
||||
{new Date(`${release.date}T00:00:00`).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<ExternalLink className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
function DocsDetail() {
|
||||
const { t } = useTranslation();
|
||||
const params = useParams<{ version: string }>();
|
||||
const version = params.version;
|
||||
const { releases, error: indexError } = useReleases();
|
||||
const release = releases?.find((r) => r.version === version);
|
||||
const { html, error: mdError } = useReleaseMarkdown(release?.file ?? `${version}.md`);
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="space-y-6 pb-10">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href="/docs">
|
||||
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||
{t("docs.backToIndex")}
|
||||
</Link>
|
||||
</Button>
|
||||
{release && (
|
||||
<a
|
||||
href={`https://git.kubebase.de/admin/tool-evaluator/tags/${release.version}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Tag className="h-4 w-4" /> {release.version}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{mdError || (release && !html) ? (
|
||||
<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>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Docs() {
|
||||
const [location] = useLocation();
|
||||
const match = location.match(/^\/docs\/(.+)$/);
|
||||
return match ? <DocsDetail /> : <DocsIndex />;
|
||||
}
|
||||
Reference in New Issue
Block a user