From 520f917723306a09653a00d6a23797bcb20497d7 Mon Sep 17 00:00:00 2001 From: opencode Date: Mon, 3 Aug 2026 16:41:08 +0200 Subject: [PATCH] feat(docs): version-bound release documentation served at /docs 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 --- .gitignore | 3 + artifacts/toolrate/package.json | 6 +- artifacts/toolrate/src/App.tsx | 3 + .../toolrate/src/components/breadcrumbs.tsx | 4 + artifacts/toolrate/src/components/layout.tsx | 3 +- artifacts/toolrate/src/i18n/locales/de.json | 7 + artifacts/toolrate/src/i18n/locales/en.json | 7 + artifacts/toolrate/src/pages/docs.tsx | 197 ++++++++++++++++++ docs/README.md | 38 ++++ docs/releases/TEMPLATE.md | 35 ++++ docs/releases/v0.6.0.md | 37 ++++ pnpm-lock.yaml | 32 +++ pnpm-workspace.yaml | 2 + scripts/src/sync-release-docs.mjs | 87 ++++++++ 14 files changed, 458 insertions(+), 3 deletions(-) create mode 100644 artifacts/toolrate/src/pages/docs.tsx create mode 100644 docs/README.md create mode 100644 docs/releases/TEMPLATE.md create mode 100644 docs/releases/v0.6.0.md create mode 100644 scripts/src/sync-release-docs.mjs diff --git a/.gitignore b/.gitignore index 12bc7fa..9f24f26 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/artifacts/toolrate/package.json b/artifacts/toolrate/package.json index 930b709..d583eb9 100644 --- a/artifacts/toolrate/package.json +++ b/artifacts/toolrate/package.json @@ -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", diff --git a/artifacts/toolrate/src/App.tsx b/artifacts/toolrate/src/App.tsx index 28f10e6..6094cf9 100644 --- a/artifacts/toolrate/src/App.tsx +++ b/artifacts/toolrate/src/App.tsx @@ -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() { + + ); diff --git a/artifacts/toolrate/src/components/breadcrumbs.tsx b/artifacts/toolrate/src/components/breadcrumbs.tsx index bc4c537..46a05a3 100644 --- a/artifacts/toolrate/src/components/breadcrumbs.tsx +++ b/artifacts/toolrate/src/components/breadcrumbs.tsx @@ -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") }); } diff --git a/artifacts/toolrate/src/components/layout.tsx b/artifacts/toolrate/src/components/layout.tsx index 7863b8b..3876cf7 100644 --- a/artifacts/toolrate/src/components/layout.tsx +++ b/artifacts/toolrate/src/components/layout.tsx @@ -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 = [ diff --git a/artifacts/toolrate/src/i18n/locales/de.json b/artifacts/toolrate/src/i18n/locales/de.json index b69f8e7..e0c040b 100644 --- a/artifacts/toolrate/src/i18n/locales/de.json +++ b/artifacts/toolrate/src/i18n/locales/de.json @@ -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", diff --git a/artifacts/toolrate/src/i18n/locales/en.json b/artifacts/toolrate/src/i18n/locales/en.json index 9e04bcc..03a3ffd 100644 --- a/artifacts/toolrate/src/i18n/locales/en.json +++ b/artifacts/toolrate/src/i18n/locales/en.json @@ -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", diff --git a/artifacts/toolrate/src/pages/docs.tsx b/artifacts/toolrate/src/pages/docs.tsx new file mode 100644 index 0000000..85253b2 --- /dev/null +++ b/artifacts/toolrate/src/pages/docs.tsx @@ -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(url: string): Promise { + return fetch(url).then((res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json() as Promise; + }); +} + +function useReleases() { + const [releases, setReleases] = useState(null); + const [error, setError] = useState(false); + + useEffect(() => { + let cancelled = false; + fetchJson(`${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(null); + const [error, setError] = useState(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 ( + +
+
+

{t("docs.title")}

+

{t("docs.subtitle")}

+
+ + {error && ( +

{t("docs.noDocs")}

+ )} + + {!releases && !error && ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ )} + + {releases && releases.length === 0 && ( +

{t("docs.noDocs")}

+ )} + + {releases && releases.length > 0 && ( +
+ {releases.map((release) => ( + + + + + + ))} +
+ )} +
+
+ ); +} + +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 ( + +
+
+ + {release && ( + + {release.version} + + )} +
+ + {mdError || (release && !html) ? ( +

{t("docs.noDocs")}

+ ) : !html ? ( +
+ + + +
+ ) : ( +
+ )} +
+ + ); +} + +export default function Docs() { + const [location] = useLocation(); + const match = location.match(/^\/docs\/(.+)$/); + return match ? : ; +} diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..b5306da --- /dev/null +++ b/docs/README.md @@ -0,0 +1,38 @@ +# Release-Dokumentation + +Jeder Release hat eine version-gebundene Dokumentation unter +`docs/releases/`. Die Dokumentation wird öffentlich in der App unter +`/docs` (Index) und `/docs/` (Detail) angezeigt. + +## Struktur + +- `docs/releases/TEMPLATE.md` — Vorlage für neue Releases +- `docs/releases/vX.Y.Z.md` — Dokumentation pro Release (eine Datei je Version) + +## Inhalt + +Pro Release wird abgedeckt (kombiniert): + +- **Changelog:** Neue Features, Fixes & Verbesserungen +- **API-Änderungen:** Neue/geänderte/entfernte Endpunkte (Delta zur Vorversion) +- **Betrieb / Upgrade:** Env-Vars, DB-Migrationen, Breaking Changes + +## 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 seit dem letzten Tag + ableiten: + ```sh + git log --oneline vX.Y.Z-1..vX.Y.Z + ``` + (Funktions-/Fix-Commits in die passenden Abschnitte übernehmen, API-Delta + anhand `lib/api-spec/openapi.yaml` prüfen.) +3. **Committen & pushen.** Der Sync-Schritt (`scripts/sync-release-docs.mjs`) + kopiert die Markdown-Dateien beim Frontend-Build automatisch nach + `artifacts/toolrate/public/docs/` und generiert `index.json`. Dadurch sind + die Releases im Deployment als `/docs/...` verfügbar. + +> Hinweis: `index.json` und die kopierten Dateien unter +> `artifacts/toolrate/public/docs/` sind Build-Artefakte und werden bei jedem +> Build neu generiert — nicht von Hand bearbeiten. diff --git a/docs/releases/TEMPLATE.md b/docs/releases/TEMPLATE.md new file mode 100644 index 0000000..28f1c5e --- /dev/null +++ b/docs/releases/TEMPLATE.md @@ -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: [``](https://git.kubebase.de/admin/tool-evaluator/commit/) +- Tag: [`vX.Y.Z`](https://git.kubebase.de/admin/tool-evaluator/tags/vX.Y.Z) diff --git a/docs/releases/v0.6.0.md b/docs/releases/v0.6.0.md new file mode 100644 index 0000000..38758c7 --- /dev/null +++ b/docs/releases/v0.6.0.md @@ -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) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 64d68dc..4ad0181 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 @@ -585,6 +591,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 +609,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) @@ -2101,6 +2113,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 +2485,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 +2977,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'} @@ -5145,6 +5168,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': {} @@ -5412,6 +5438,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 @@ -5807,6 +5837,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: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d153d52..6cff4b3 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -61,9 +61,11 @@ 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 diff --git a/scripts/src/sync-release-docs.mjs b/scripts/src/sync-release-docs.mjs new file mode 100644 index 0000000..874a2e2 --- /dev/null +++ b/scripts/src/sync-release-docs.mjs @@ -0,0 +1,87 @@ +// Copies docs/releases/*.md into the toolrate frontend's static public dir +// and generates index.json (version list) so the app can render /docs. +// +// Run before `vite build` (Vite copies public/ -> dist/public verbatim), and +// before `vite dev` so the docs are available locally too. +// +// Usage: node scripts/src/sync-release-docs.mjs + +import { readdir, readFile, copyFile, mkdir, rm, writeFile } from "node:fs/promises"; +import { resolve, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = resolve(fileURLToPath(new URL("../..", import.meta.url))); +const sourceDir = resolve(root, "docs/releases"); +const targetDir = resolve(root, "artifacts/toolrate/public/docs"); + +const isReleaseFile = (name) => /^v\d+\.\d+\.\d+\.md$/.test(name); + +function parseVersion(name) { + return name.replace(/\.md$/, ""); +} + +// Semantic-ish comparison: v0.6.0 > v0.5.0 > v0.4.2 +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) { + // e.g. "**Datum:** 2026-08-03" or "**Date:** 2026-08-03" in the header block. + // Match the first ISO date that follows a Datum/Date label, tolerating + // bold markers/colons around it. + const m = content.match(/(?:Datum|Date)[^\d\n]{0,20}(\d{4}-\d{2}-\d{2})/); + return m ? m[1] : null; +} + +async function main() { + let names; + try { + names = (await readdir(sourceDir)).filter(isReleaseFile); + } catch (err) { + if (err.code === "ENOENT") { + console.warn(`[sync-release-docs] ${sourceDir} not found; nothing to sync`); + return; + } + throw err; + } + + if (names.length === 0) { + console.warn("[sync-release-docs] no release docs found; clearing target dir"); + } + + names.sort(cmp).reverse(); + + await rm(targetDir, { recursive: true, force: true }); + await mkdir(targetDir, { recursive: true }); + + const index = []; + for (const name of names) { + const content = await readFile(join(sourceDir, name), "utf8"); + await copyFile(join(sourceDir, name), join(targetDir, name)); + index.push({ + version: parseVersion(name), + file: name, + title: extractTitle(content) ?? parseVersion(name), + date: extractDate(content) ?? null, + }); + } + + await writeFile(join(targetDir, "index.json"), JSON.stringify(index, null, 2)); + + console.log(`[sync-release-docs] synced ${names.length} release doc(s) -> ${targetDir}`); +} + +main().catch((err) => { + console.error("[sync-release-docs] failed:", err); + process.exit(1); +});