+ Changelog +
++ Release notes for MouseTerm. +
+diff --git a/.claude/commands/release-notes.md b/.claude/commands/release-notes.md
index 122f84e..7f2a22d 100644
--- a/.claude/commands/release-notes.md
+++ b/.claude/commands/release-notes.md
@@ -1,10 +1,10 @@
---
name: release-notes
-description: Draft release notes and recommend a version bump for the next mouseterm release by analyzing all merge commits and squash-merged PRs since the last release tag. Outputs a Keep a Changelog section ready to paste into CHANGELOG.md. Used as step 2 of the release checklist in docs/specs/deploy.md.
+description: Draft release notes, recommend and apply a version bump, and update CHANGELOG.md for the next mouseterm release by analyzing all merge commits and squash-merged PRs since the last release tag. Used as step 2 of the release checklist in docs/specs/deploy.md.
user-invocable: true
---
-You are drafting release notes and recommending a version bump for the next mouseterm release.
+You are drafting release notes, recommending and applying a version bump, and updating CHANGELOG.md for the next mouseterm release.
## 1. Gather context
@@ -34,7 +34,11 @@ mouseterm uses **breaking.added.bugfix** semantics (semver-shaped, but named for
Pick the highest-severity bump that any single change requires.
-## 3. Edit `CHANGELOG.md`
+## 3. Run the version bump
+
+Run `./scripts/bump-version.sh X.Y.Z` with the recommended version before editing the changelog. Show the script's output so the user can review the diff stat.
+
+## 4. Edit `CHANGELOG.md`
Edit `CHANGELOG.md` directly — insert a new section above the most recent existing release, in this exact shape:
@@ -65,8 +69,10 @@ Rules for the entries:
Do not ask the user to paste it themselves — make the edit. The earlier flat-bullet entries (0.8.0 and below) are legacy; do not reformat them.
-## 4. Run the version bump
+## 5. Finish
+
+Do not run `website/scripts/generate-changelog.js` as part of the release-notes command. `website/src/data/changelog.json` is a gitignored generated artifact, and the website `prebuild`, `predev`, and `pretest` lifecycle scripts regenerate it from `CHANGELOG.md`.
-After saving the changelog edit, run `./scripts/bump-version.sh X.Y.Z` with the recommended version. Show the script's output so the user can review the diff stat. Then remind the user of the next step:
+Then remind the user of the next step:
> Review the diff, then `git commit -am 'Release vX.Y.Z'` and `git tag vX.Y.Z`.
diff --git a/.gitignore b/.gitignore
index aff2428..4f9729e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -29,6 +29,7 @@ standalone/node_modules/
# Website
website/dist/
website/node_modules/
+website/src/data/changelog.json
# OS
.DS_Store
diff --git a/docs/specs/deploy.md b/docs/specs/deploy.md
index e788597..cce12a9 100644
--- a/docs/specs/deploy.md
+++ b/docs/specs/deploy.md
@@ -16,15 +16,13 @@ Every release produces three artifact groups under one version and changelog:
Human-driven steps, in order:
1. **Update dependencies page** — run `node website/scripts/generate-deps.js` and review the diff in `website/src/data/dependencies.json`. Commit if changed.
-2. **Draft release notes and pick version** — run `/release-notes` in Claude Code at the repo root. The slash command (defined in [.claude/commands/release-notes.md](../../.claude/commands/release-notes.md)) walks the merge commits and squash-merged PRs since the last tag, drafts a Keep a Changelog block, and recommends a `breaking.added.bugfix` version bump. Review and edit the output, paste it into `CHANGELOG.md` replacing the `[Unreleased]` section, and use the recommended `X.Y.Z` in the next step.
-
-3. **Bump versions** — `./scripts/bump-version.sh X.Y.Z`. Edits the four version files in lockstep and runs `cargo check` so `Cargo.lock` follows along.
-4. **Commit and tag** — `git commit -m "Release vX.Y.Z"` then `git tag vX.Y.Z`.
-5. **Push** — `git push && git push origin vX.Y.Z`. This triggers CI (Stage 1).
-6. **Set environment variables** — copy the relevant secrets into the terminal from your password manager (see [Environment / secrets](#environment--secrets) for the list).
-7. **Run local signing** — plug in the PIV USB key, then `./scripts/sign-and-deploy.sh all X.Y.Z`. The script waits for CI, downloads unsigned artifacts, signs macOS + Windows, generates the Tauri update manifest into `website/public/standalone-latest.json`, and creates the GitHub Release. Run `./scripts/sign-and-deploy.sh --help` for resume-after-failure subcommands.
-8. **Deploy website** — commit the updated `website/public/standalone-latest.json` and deploy mouseterm.com so the updater endpoint is live.
-9. **Verify the release**
+2. **Draft release notes and bump version** — run `/release-notes` in Claude Code at the repo root. The slash command (defined in [.claude/commands/release-notes.md](../../.claude/commands/release-notes.md)) walks the merge commits and squash-merged PRs since the last tag, recommends a `breaking.added.bugfix` version bump, runs `./scripts/bump-version.sh X.Y.Z`, and edits `CHANGELOG.md` for the same version. Review and edit the resulting diff if needed.
+3. **Commit and tag** — `git commit -am "Release vX.Y.Z"` then `git tag vX.Y.Z`.
+4. **Push** — `git push && git push origin vX.Y.Z`. This triggers CI (Stage 1).
+5. **Set environment variables** — copy the relevant secrets into the terminal from your password manager (see [Environment / secrets](#environment--secrets) for the list).
+6. **Run local signing** — plug in the PIV USB key, then `./scripts/sign-and-deploy.sh all X.Y.Z`. The script waits for CI, downloads unsigned artifacts, signs macOS + Windows, generates the Tauri update manifest into `website/public/standalone-latest.json`, and creates the GitHub Release. Run `./scripts/sign-and-deploy.sh --help` for resume-after-failure subcommands.
+7. **Deploy website** — commit the updated `website/public/standalone-latest.json` and deploy mouseterm.com so the updater endpoint is live.
+8. **Verify the release**
- Check GitHub Release assets are correct
- On a Mac: extract the `.tar.gz`, open the `.app`, confirm no Gatekeeper warnings
- On Windows: run the `.exe` installer, confirm no SmartScreen warnings
@@ -204,6 +202,12 @@ Note: the update manifest URLs include the version in the *path* (`/v0.1.0/`) bu
A single `CHANGELOG.md` at the repo root, following [Keep a Changelog](https://keepachangelog.com/) format. The `[Unreleased]` section is promoted to `[X.Y.Z]` at release time. The release notes include both standalone and VSCode changes in one entry.
+The website changelog page imports generated data from `website/src/data/changelog.json`, but `CHANGELOG.md` is the source of truth and the JSON is gitignored. You do not normally run `website/scripts/generate-changelog.js` by hand:
+- `pnpm --filter mouseterm-website build` runs it through the website `prebuild` script before Vite bundles the static site.
+- `pnpm --filter mouseterm-website dev` and `pnpm --filter mouseterm-website test` also regenerate it through lifecycle scripts so clean checkouts work locally.
+
+If you edit `CHANGELOG.md` manually outside `/release-notes` and want to preview the generated data immediately, run `node website/scripts/generate-changelog.js`. Do not commit `website/src/data/changelog.json`.
+
## Environment / secrets
| Secret | Where | Purpose |
diff --git a/website/package.json b/website/package.json
index e4a5d78..d8704ac 100644
--- a/website/package.json
+++ b/website/package.json
@@ -6,9 +6,11 @@
"type": "module",
"scripts": {
"dev": "vite-react-ssg dev",
- "prebuild": "node scripts/generate-deps.js",
+ "predev": "node scripts/generate-changelog.js",
+ "prebuild": "node scripts/generate-deps.js && node scripts/generate-changelog.js",
"build": "vite-react-ssg build",
"preview": "vite preview",
+ "pretest": "node scripts/generate-changelog.js",
"test": "vitest run"
},
"dependencies": {
diff --git a/website/public/_redirects b/website/public/_redirects
new file mode 100644
index 0000000..9b1d532
--- /dev/null
+++ b/website/public/_redirects
@@ -0,0 +1 @@
+/changelog/after/* /changelog 200
diff --git a/website/scripts/changelog-parser.js b/website/scripts/changelog-parser.js
new file mode 100644
index 0000000..676f766
--- /dev/null
+++ b/website/scripts/changelog-parser.js
@@ -0,0 +1,88 @@
+const RELEASE_HEADING_RE = /^## \[([^\]]+)\](?: - (\d{4}-\d{2}-\d{2}))?\s*$/;
+const SECTION_HEADING_RE = /^###\s+(.+?)\s*$/;
+const BULLET_RE = /^(\s*)-\s+(.*)$/;
+
+function normalizeVersion(version) {
+ return version.trim().replace(/^v/i, "");
+}
+
+function createRelease(rawVersion, date) {
+ const version = normalizeVersion(rawVersion);
+ return {
+ version,
+ tag: `v${version}`,
+ date: date ?? null,
+ sections: [],
+ };
+}
+
+function ensureSection(release, title = "Changes") {
+ if (!release.sections.length || release.sections[release.sections.length - 1].title !== title) {
+ release.sections.push({ title, items: [] });
+ }
+ return release.sections[release.sections.length - 1];
+}
+
+function pushBullet(section, indent, text) {
+ const item = { text: text.trim(), children: [] };
+ const isNested = indent.replace(/\t/g, " ").length >= 2;
+ const lastTopLevelItem = section.items[section.items.length - 1];
+
+ if (isNested && lastTopLevelItem) {
+ lastTopLevelItem.children.push(item);
+ return;
+ }
+
+ section.items.push(item);
+}
+
+function appendContinuation(section, text) {
+ const trimmed = text.trim();
+ if (!trimmed) return;
+
+ const lastTopLevelItem = section.items[section.items.length - 1];
+ if (lastTopLevelItem) {
+ lastTopLevelItem.text = `${lastTopLevelItem.text} ${trimmed}`;
+ return;
+ }
+
+ section.items.push({ text: trimmed, children: [] });
+}
+
+export function parseChangelog(markdown) {
+ const releases = [];
+ let currentRelease = null;
+ let currentSection = null;
+
+ for (const line of markdown.replace(/\r\n?/g, "\n").split("\n")) {
+ const releaseHeading = line.match(RELEASE_HEADING_RE);
+ if (releaseHeading) {
+ currentRelease = createRelease(releaseHeading[1], releaseHeading[2]);
+ releases.push(currentRelease);
+ currentSection = null;
+ continue;
+ }
+
+ if (!currentRelease) continue;
+
+ const sectionHeading = line.match(SECTION_HEADING_RE);
+ if (sectionHeading) {
+ currentSection = ensureSection(currentRelease, sectionHeading[1].trim());
+ continue;
+ }
+
+ if (!line.trim()) continue;
+
+ const bullet = line.match(BULLET_RE);
+ if (bullet) {
+ currentSection = currentSection ?? ensureSection(currentRelease);
+ pushBullet(currentSection, bullet[1], bullet[2]);
+ continue;
+ }
+
+ currentSection = currentSection ?? ensureSection(currentRelease);
+ appendContinuation(currentSection, line);
+ }
+
+ return { releases };
+}
diff --git a/website/scripts/changelog-parser.test.js b/website/scripts/changelog-parser.test.js
new file mode 100644
index 0000000..c832b68
--- /dev/null
+++ b/website/scripts/changelog-parser.test.js
@@ -0,0 +1,54 @@
+import { describe, expect, it } from "vitest";
+import { parseChangelog } from "./changelog-parser.js";
+
+describe("parseChangelog", () => {
+ it("parses release headings, sections, and nested bullets", () => {
+ const parsed = parseChangelog(`# Changelog
+
+## [0.2.0] - 2026-01-02
+### Added
+- First item.
+ - Nested detail.
+
+### Fixed
+- Second item.
+
+## [0.1.0] - 2026-01-01
+- Initial release.
+`);
+
+ expect(parsed.releases).toEqual([
+ {
+ version: "0.2.0",
+ tag: "v0.2.0",
+ date: "2026-01-02",
+ sections: [
+ {
+ title: "Added",
+ items: [
+ {
+ text: "First item.",
+ children: [{ text: "Nested detail.", children: [] }],
+ },
+ ],
+ },
+ {
+ title: "Fixed",
+ items: [{ text: "Second item.", children: [] }],
+ },
+ ],
+ },
+ {
+ version: "0.1.0",
+ tag: "v0.1.0",
+ date: "2026-01-01",
+ sections: [
+ {
+ title: "Changes",
+ items: [{ text: "Initial release.", children: [] }],
+ },
+ ],
+ },
+ ]);
+ });
+});
diff --git a/website/scripts/generate-changelog.js b/website/scripts/generate-changelog.js
new file mode 100644
index 0000000..a9eaece
--- /dev/null
+++ b/website/scripts/generate-changelog.js
@@ -0,0 +1,14 @@
+import { readFileSync, writeFileSync } from "node:fs";
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import { parseChangelog } from "./changelog-parser.js";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const repoRoot = resolve(__dirname, "../..");
+const changelogPath = resolve(repoRoot, "CHANGELOG.md");
+const outPath = resolve(__dirname, "../src/data/changelog.json");
+
+const changelog = parseChangelog(readFileSync(changelogPath, "utf-8"));
+
+writeFileSync(outPath, JSON.stringify(changelog, null, 2) + "\n");
+console.log(`Wrote ${changelog.releases.length} changelog releases to src/data/changelog.json`);
diff --git a/website/src/App.tsx b/website/src/App.tsx
index 1063ad9..c24edf0 100644
--- a/website/src/App.tsx
+++ b/website/src/App.tsx
@@ -9,6 +9,14 @@ export const routes: RouteRecord[] = [
path: "/playground",
lazy: () => import("./pages/Playground"),
},
+ {
+ path: "/changelog",
+ lazy: () => import("./pages/Changelog"),
+ },
+ {
+ path: "/changelog/after/:version",
+ lazy: () => import("./pages/Changelog"),
+ },
{
path: "/dependencies",
lazy: () => import("./pages/Dependencies"),
diff --git a/website/src/components/SiteHeader.tsx b/website/src/components/SiteHeader.tsx
index f9731c3..9cd28fa 100644
--- a/website/src/components/SiteHeader.tsx
+++ b/website/src/components/SiteHeader.tsx
@@ -1,5 +1,10 @@
import { forwardRef } from "react";
+export const STATIC_PAGE_HEADER_STYLE: React.CSSProperties = {
+ background: "rgba(10, 10, 10, 0.85)",
+ backdropFilter: "blur(12px)",
+};
+
const NAV_LINKS: readonly { href: string; label: string; external?: boolean; hideOnMobile?: boolean }[] = [
{ href: "/playground", label: "Playground", hideOnMobile: true },
{ href: "/#download", label: "Download", hideOnMobile: true },
diff --git a/website/src/pages/Changelog.tsx b/website/src/pages/Changelog.tsx
new file mode 100644
index 0000000..a56a93c
--- /dev/null
+++ b/website/src/pages/Changelog.tsx
@@ -0,0 +1,211 @@
+import type { ReactNode } from "react";
+import { Link, useParams } from "react-router-dom";
+import SiteHeader, { STATIC_PAGE_HEADER_STYLE } from "../components/SiteHeader";
+import changelog from "../data/changelog.json";
+
+interface ChangelogItem {
+ text: string;
+ children: ChangelogItem[];
+}
+
+interface ChangelogSection {
+ title: string;
+ items: ChangelogItem[];
+}
+
+interface ChangelogRelease {
+ version: string;
+ tag: string;
+ date: string | null;
+ sections: ChangelogSection[];
+}
+
+interface ChangelogData {
+ releases: ChangelogRelease[];
+}
+
+const RELEASES = (changelog as ChangelogData).releases;
+const INLINE_TOKEN_RE = /`([^`]+)`|\[([^\]]+)\]\(([^)]+)\)/g;
+const DATE_FORMATTER = new Intl.DateTimeFormat("en", {
+ month: "long",
+ day: "numeric",
+ year: "numeric",
+ timeZone: "UTC",
+});
+
+function normalizeVersionParam(version: string) {
+ const normalized = version.trim().replace(/^v/i, "");
+ return /^\d+\.\d+\.\d+$/.test(normalized) ? normalized : null;
+}
+
+function formatDate(date: string | null) {
+ if (!date) return null;
+ return DATE_FORMATTER.format(new Date(`${date}T00:00:00Z`));
+}
+
+function renderInlineMarkdown(text: string) {
+ const nodes: ReactNode[] = [];
+ let cursor = 0;
+ let key = 0;
+
+ for (const match of text.matchAll(INLINE_TOKEN_RE)) {
+ if (match.index > cursor) {
+ nodes.push(text.slice(cursor, match.index));
+ }
+
+ if (match[1]) {
+ nodes.push(
+
+ {match[1]}
+ ,
+ );
+ } else if (match[2] && match[3]) {
+ nodes.push(
+
+ {match[2]}
+ ,
+ );
+ }
+
+ cursor = match.index + match[0].length;
+ key += 1;
+ }
+
+ if (cursor < text.length) nodes.push(text.slice(cursor));
+ return nodes;
+}
+
+function ChangelogListItem({ item }: { item: ChangelogItem }) {
+ return (
+
+ Release notes for MouseTerm. +
+