feat(release-notes): terse bilingual notes as data files — new/fixed only, the dialog lists the latest ten (E28)
This commit is contained in:
+792
-1400
File diff suppressed because it is too large
Load Diff
@@ -44,8 +44,8 @@ import DemoBanner from "./components/DemoBanner";
|
||||
import VerifyBanner from "./components/VerifyBanner";
|
||||
import { focusAccessRequestsTab } from "./lib/adminUsersTab";
|
||||
// The built app version, NOT imported from releaseNotes — importing anything from that module
|
||||
// pulls its whole ~900-line changelog into the eager App chunk and defeats the lazy ReleaseNotes
|
||||
// split. FRONTEND_VERSION equals RELEASE_NOTES[0].version by release convention (both bump together).
|
||||
// pulls the release-notes loader into the eager App chunk and defeats the lazy ReleaseNotes
|
||||
// split. FRONTEND_VERSION equals the newest release note's version by release convention (both bump together).
|
||||
import { FRONTEND_VERSION } from "./lib/version";
|
||||
import { applyDiagnostics } from "./lib/diagnostics";
|
||||
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Bug, Sparkles } from "lucide-react";
|
||||
import { RELEASE_NOTES } from "../lib/releaseNotes";
|
||||
import { loadReleaseNotes, notesLang, visibleNotes } from "../lib/releaseNotes";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { formatDate } from "../lib/format";
|
||||
import { Button } from "./ui/Button";
|
||||
import Modal from "./ui/Modal";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
|
||||
// The release date in the UI language — `toLocaleDateString(undefined, …)` formats in the BROWSER's
|
||||
// locale, which put Hungarian-shaped dates in an English release-notes list. Unparseable input falls
|
||||
@@ -11,8 +16,12 @@ function fmtDate(d: string, lang: string): string {
|
||||
return isNaN(new Date(d).getTime()) ? d : formatDate(d, lang);
|
||||
}
|
||||
|
||||
// Public release notes (chores are intentionally omitted here). `highlight` rings the
|
||||
// version the user just updated to when opened from the new-version banner.
|
||||
// The newest releases are what a user opens this for; the whole history (150+ versions) rendered on
|
||||
// every open was what made the dialog slow to appear. The rest are one click away.
|
||||
const RECENT = 10;
|
||||
|
||||
// Public release notes in the UI language. `highlight` rings the version the user just updated to
|
||||
// when opened from the new-version banner.
|
||||
export default function ReleaseNotes({
|
||||
onClose,
|
||||
highlight,
|
||||
@@ -21,65 +30,86 @@ export default function ReleaseNotes({
|
||||
highlight?: string;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = notesLang(i18n.language);
|
||||
const q = useQuery({
|
||||
queryKey: qk.releaseNotes(lang),
|
||||
queryFn: () => loadReleaseNotes(lang),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const notes = visibleNotes(q.data ?? []);
|
||||
// Ring the newest note if `highlight` isn't one of the listed versions — e.g. a Vite dev build
|
||||
// where the caller's FRONTEND_VERSION is "dev", or a VERSION bump whose note hasn't landed yet —
|
||||
// so the banner always rings *something* current instead of nothing.
|
||||
const active = RELEASE_NOTES.some((r) => r.version === highlight)
|
||||
? highlight
|
||||
: RELEASE_NOTES[0]?.version;
|
||||
const at = notes.findIndex((r) => r.version === highlight);
|
||||
const active = at >= 0 ? highlight : notes[0]?.version;
|
||||
const shown = showAll || at >= RECENT ? notes : notes.slice(0, RECENT);
|
||||
return (
|
||||
<Modal title={t("about.releaseNotes")} onClose={onClose} maxWidth="max-w-2xl">
|
||||
<div className="flex flex-col gap-6">
|
||||
{RELEASE_NOTES.map((r) => (
|
||||
{q.isError ? (
|
||||
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
||||
{t("about.notesError")}
|
||||
</StateMessage>
|
||||
) : !q.data ? (
|
||||
<LoadingState />
|
||||
) : (
|
||||
<div className="flex flex-col gap-5">
|
||||
{shown.map((r) => (
|
||||
<section
|
||||
key={r.version}
|
||||
className={active === r.version ? "rounded-xl ring-1 ring-accent/40 p-3 -m-3" : ""}
|
||||
>
|
||||
<div className="flex items-baseline gap-2 flex-wrap">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<h3 className="text-base font-semibold">v{r.version}</h3>
|
||||
<span className="text-xs text-muted">{fmtDate(r.date, i18n.language)}</span>
|
||||
{r.sha && (
|
||||
<span className="text-2xs font-mono text-muted bg-surface px-1.5 py-0.5 rounded-sm">
|
||||
{r.sha}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{r.summary && <p className="text-sm text-muted mt-1">{r.summary}</p>}
|
||||
|
||||
{r.features?.length ? (
|
||||
<div className="mt-3">
|
||||
<div className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-accent">
|
||||
<Sparkles className="w-3.5 h-3.5" /> {t("about.new")}
|
||||
</div>
|
||||
<ul className="mt-1.5 space-y-1.5 text-sm">
|
||||
{r.features.map((f, i) => (
|
||||
<li key={i} className="flex gap-2">
|
||||
<span className="text-accent">•</span>
|
||||
<span>{f}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{r.fixes?.length ? (
|
||||
<div className="mt-3">
|
||||
<div className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-muted">
|
||||
<Bug className="w-3.5 h-3.5" /> {t("about.fixes")}
|
||||
</div>
|
||||
<ul className="mt-1.5 space-y-1.5 text-sm">
|
||||
{r.fixes.map((f, i) => (
|
||||
<li key={i} className="flex gap-2">
|
||||
<span className="text-muted">•</span>
|
||||
<span>{f}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
<Items icon={<Sparkles className="w-3.5 h-3.5" />} title={t("about.new")} accent>
|
||||
{r.new}
|
||||
</Items>
|
||||
<Items icon={<Bug className="w-3.5 h-3.5" />} title={t("about.fixes")}>
|
||||
{r.fixed}
|
||||
</Items>
|
||||
</section>
|
||||
))}
|
||||
{shown.length < notes.length && (
|
||||
<Button variant="outline" className="self-center" onClick={() => setShowAll(true)}>
|
||||
{t("about.olderVersions", { count: notes.length - shown.length })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function Items({
|
||||
icon,
|
||||
title,
|
||||
accent,
|
||||
children,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
accent?: boolean;
|
||||
children: string[];
|
||||
}) {
|
||||
if (children.length === 0) return null;
|
||||
const tone = accent ? "text-accent" : "text-muted";
|
||||
return (
|
||||
<div className="mt-2.5">
|
||||
<div
|
||||
className={`flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide ${tone}`}
|
||||
>
|
||||
{icon} {title}
|
||||
</div>
|
||||
<ul className="mt-1 space-y-1 text-sm">
|
||||
{children.map((line, i) => (
|
||||
<li key={i} className="flex gap-2">
|
||||
<span className={tone}>•</span>
|
||||
<span>{line}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -8,5 +8,8 @@
|
||||
"whatsNew": "What's new",
|
||||
"releaseNotes": "Release notes",
|
||||
"new": "New",
|
||||
"fixes": "Fixes"
|
||||
"fixes": "Fixes",
|
||||
"olderVersions_one": "{{count}} older version",
|
||||
"olderVersions_other": "{{count}} older versions",
|
||||
"notesError": "The release notes could not be loaded."
|
||||
}
|
||||
|
||||
@@ -8,5 +8,8 @@
|
||||
"whatsNew": "Mi újság",
|
||||
"releaseNotes": "Verziójegyzék",
|
||||
"new": "Új",
|
||||
"fixes": "Javítások"
|
||||
"fixes": "Javítások",
|
||||
"olderVersions_one": "{{count}} korábbi verzió",
|
||||
"olderVersions_other": "{{count}} korábbi verzió",
|
||||
"notesError": "A verziójegyzéket nem sikerült betölteni."
|
||||
}
|
||||
|
||||
@@ -56,6 +56,8 @@ export const qk = {
|
||||
// --- account / misc (R7 S2b) --- (me/myStatus/tags already declared as shared roots above)
|
||||
accounts: () => ["accounts"] as const,
|
||||
version: () => ["version"] as const,
|
||||
// Static text bundled with the app (lib/releaseNotes), one file per language: never stale.
|
||||
releaseNotes: (lang: string) => ["release-notes", lang] as const,
|
||||
setupStatus: () => ["setup-status"] as const,
|
||||
myUsage: () => ["my-usage"] as const,
|
||||
scheduler: () => ["scheduler"] as const,
|
||||
|
||||
@@ -2,20 +2,52 @@ import { readFileSync } from "node:fs";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { RELEASE_NOTES } from "./releaseNotes";
|
||||
import en from "../content/release-notes/en.json";
|
||||
import hu from "../content/release-notes/hu.json";
|
||||
import { type ReleaseEntry } from "./releaseNotes";
|
||||
|
||||
const EN = en as ReleaseEntry[];
|
||||
const HU = hu as ReleaseEntry[];
|
||||
|
||||
// DX-6: the app version is hand-maintained in two places that MUST agree — the repo-root VERSION file
|
||||
// (the single source baked into the image + served at /api/version) and the newest RELEASE_NOTES entry
|
||||
// (the single source baked into the image + served at /api/version) and the newest release note
|
||||
// (what the in-app "What's new" shows). A release that bumps one but forgets the other ships a wrong
|
||||
// changelog or a mismatched banner. This locks them together so the gate catches the drift.
|
||||
describe("release version consistency", () => {
|
||||
it("RELEASE_NOTES[0].version matches the VERSION file", () => {
|
||||
it("the newest note's version matches the VERSION file", () => {
|
||||
const version = readFileSync(new URL("../../../VERSION", import.meta.url), "utf-8").trim();
|
||||
expect(RELEASE_NOTES[0]?.version).toBe(version);
|
||||
expect(EN[0]?.version).toBe(version);
|
||||
});
|
||||
|
||||
it("release notes are ordered newest-first with unique versions", () => {
|
||||
const versions = RELEASE_NOTES.map((r) => r.version);
|
||||
expect(new Set(versions).size).toBe(versions.length); // no duplicate version entries
|
||||
const versions = EN.map((r) => r.version);
|
||||
expect(new Set(versions).size).toBe(versions.length);
|
||||
const num = (v: string) => v.split(".").map(Number) as [number, number, number];
|
||||
for (let i = 1; i < versions.length; i++) {
|
||||
const [a, b] = [num(versions[i - 1]!), num(versions[i]!)];
|
||||
const newer = a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
|
||||
expect(newer, `${versions[i - 1]} before ${versions[i]}`).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// E28 (owner): internal work never ships on its own — it waits for a release a user can see. So the
|
||||
// release being cut always says what changed for the user.
|
||||
it("the newest release has something a user notices", () => {
|
||||
expect(EN[0]!.new.length + EN[0]!.fixed.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// A note is a sentence or two (owner, E28): the old paragraphs were what made the dialog slow and
|
||||
// unread. The ceiling is generous for Hungarian, whose words run longer.
|
||||
it("every item is short", () => {
|
||||
const long = [...EN, ...HU].flatMap((r) =>
|
||||
[...r.new, ...r.fixed].filter((s) => s.length > 260).map((s) => `${r.version}: ${s}`),
|
||||
);
|
||||
expect(long).toEqual([]);
|
||||
});
|
||||
|
||||
it("the Hungarian notes are the English ones, item for item", () => {
|
||||
const shape = (all: ReleaseEntry[]) =>
|
||||
all.map((r) => [r.version, r.date, r.sha ?? "", r.new.length, r.fixed.length].join(" "));
|
||||
expect(shape(HU)).toEqual(shape(EN));
|
||||
});
|
||||
|
||||
+28
-2129
File diff suppressed because it is too large
Load Diff
+14
-23
@@ -1,11 +1,9 @@
|
||||
// Generates CHANGELOG.md from frontend/src/lib/releaseNotes.ts — the same entries the app shows
|
||||
// under Release Notes, so a self-hoster deciding whether to upgrade reads the same text an
|
||||
// Generates CHANGELOG.md from frontend/src/content/release-notes/en.json — the same entries the app
|
||||
// shows under Release Notes, so a self-hoster deciding whether to upgrade reads the same text an
|
||||
// in-app user does, without having to install the app first (DX-14).
|
||||
//
|
||||
// Why derive rather than hand-write: two lists of "what changed in 0.80.0" WILL drift, and the
|
||||
// in-app one is the one that gets written during a release. This makes the file a build product of
|
||||
// it. RELEASE_NOTES is a plain data literal by design, so it is imported by stripping the single TS
|
||||
// type annotation off the declaration — no transpiler, no dependency.
|
||||
// in-app one is the one that gets written during a release. This makes the file a build product of it.
|
||||
//
|
||||
// node scripts/gen-changelog.mjs # write CHANGELOG.md
|
||||
// node scripts/gen-changelog.mjs --check # exit 1 if CHANGELOG.md is stale (used by `siftlode release`)
|
||||
@@ -15,22 +13,15 @@ import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const SOURCE = path.join(root, "frontend", "src", "lib", "releaseNotes.ts");
|
||||
const SOURCE = path.join(root, "frontend", "src", "content", "release-notes", "en.json");
|
||||
const TARGET = path.join(root, "CHANGELOG.md");
|
||||
|
||||
async function loadEntries() {
|
||||
const src = await readFile(SOURCE, "utf8");
|
||||
const decl = "export const RELEASE_NOTES";
|
||||
const at = src.indexOf(decl);
|
||||
if (at === -1) {
|
||||
throw new Error(`${SOURCE} no longer declares ${decl} — the generator's assumption broke, fix it rather than hand-writing the changelog.`);
|
||||
const entries = JSON.parse(await readFile(SOURCE, "utf8"));
|
||||
if (!Array.isArray(entries) || entries.length === 0) {
|
||||
throw new Error(`${SOURCE} is not a non-empty array of release notes.`);
|
||||
}
|
||||
const body = src.slice(at).replace(/^export const RELEASE_NOTES\s*:[^=]*=/, "export const RELEASE_NOTES =");
|
||||
const mod = await import(`data:text/javascript,${encodeURIComponent(body)}`);
|
||||
if (!Array.isArray(mod.RELEASE_NOTES) || mod.RELEASE_NOTES.length === 0) {
|
||||
throw new Error("RELEASE_NOTES imported as something other than a non-empty array.");
|
||||
}
|
||||
return mod.RELEASE_NOTES;
|
||||
return entries;
|
||||
}
|
||||
|
||||
function section(title, items) {
|
||||
@@ -44,7 +35,7 @@ function render(entries) {
|
||||
"",
|
||||
"Every released version of Siftlode, newest first.",
|
||||
"",
|
||||
"**Generated from `frontend/src/lib/releaseNotes.ts` by `scripts/gen-changelog.mjs` — do not edit by hand;**",
|
||||
"**Generated from `frontend/src/content/release-notes/en.json` by `scripts/gen-changelog.mjs` — do not edit by hand;**",
|
||||
"edit the release notes and re-run the generator.",
|
||||
"",
|
||||
"Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading).",
|
||||
@@ -56,10 +47,10 @@ function render(entries) {
|
||||
.map((e) => {
|
||||
const sha = e.sha ? ` (\`${e.sha}\`)` : "";
|
||||
let out = `## ${e.version} — ${e.date}${sha}\n\n`;
|
||||
if (e.summary) out += `${e.summary}\n\n`;
|
||||
out += section("Features", e.features);
|
||||
out += section("Fixes", e.fixes);
|
||||
out += section("Internal", e.chores);
|
||||
out += section("New", e.new);
|
||||
out += section("Fixed", e.fixed);
|
||||
// A release that carried only internal work (none has since the owner's E28 rule).
|
||||
if (e.new.length === 0 && e.fixed.length === 0) out += "Internal changes only.\n\n";
|
||||
return out;
|
||||
})
|
||||
.join("");
|
||||
@@ -74,7 +65,7 @@ if (process.argv.includes("--check")) {
|
||||
const current = await readFile(TARGET, "utf8").catch(() => null);
|
||||
if (current !== rendered) {
|
||||
console.error(
|
||||
`CHANGELOG.md is out of date with releaseNotes.ts (newest entry: ${entries[0].version}).\n` +
|
||||
`CHANGELOG.md is out of date with the release notes (newest entry: ${entries[0].version}).\n` +
|
||||
"Run: node scripts/gen-changelog.mjs — then commit the result.",
|
||||
);
|
||||
process.exit(1);
|
||||
|
||||
Reference in New Issue
Block a user