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 ( +
  • + {renderInlineMarkdown(item.text)} + {item.children.length > 0 ? ( + + ) : null} +
  • + ); +} + +function ReleaseSection({ section }: { section: ChangelogSection }) { + return ( +
    +

    + {section.title} +

    + +
    + ); +} + +function ReleaseArticle({ release }: { release: ChangelogRelease }) { + const date = formatDate(release.date); + + return ( +
    +
    +
    +

    + {release.tag} +

    + {date ? ( + + ) : null} +
    + + Download from GitHub + +
    + + {release.sections.map((section) => ( + + ))} +
    + ); +} + +function FilterNotice({ children }: { children: ReactNode }) { + return ( +
    + {children}{" "} + + Show all releases. + +
    + ); +} + +export function Component() { + const { version: versionParam } = useParams(); + const requestedVersion = versionParam ? normalizeVersionParam(versionParam) : null; + const baselineIndex = requestedVersion + ? RELEASES.findIndex((release) => release.version === requestedVersion) + : -1; + const baselineVersion = baselineIndex >= 0 ? requestedVersion : null; + const hasInvalidFilter = Boolean(versionParam) && !baselineVersion; + const visibleReleases = hasInvalidFilter + ? [] + : baselineVersion + ? RELEASES.slice(0, baselineIndex) + : RELEASES; + + return ( + <> + + +
    +
    +
    +

    + Changelog +

    +

    + Release notes for MouseTerm. +

    +
    + + {hasInvalidFilter ? ( + No such release "{versionParam}". + ) : null} + + {baselineVersion ? ( + Showing releases newer than v{baselineVersion}. + ) : null} + + {visibleReleases.map((release) => ( + + ))} + + {baselineVersion && visibleReleases.length === 0 ? ( +
    + No releases newer than v{baselineVersion}. +
    + ) : null} +
    +
    + + ); +} diff --git a/website/src/pages/Dependencies.tsx b/website/src/pages/Dependencies.tsx index b3a22d7..1a69227 100644 --- a/website/src/pages/Dependencies.tsx +++ b/website/src/pages/Dependencies.tsx @@ -1,13 +1,10 @@ import deps from "../data/dependencies.json"; -import SiteHeader from "../components/SiteHeader"; - -const HEADER_BG = "rgba(10, 10, 10, 0.85)"; -const HEADER_STYLE = { background: HEADER_BG, backdropFilter: "blur(12px)" }; +import SiteHeader, { STATIC_PAGE_HEADER_STYLE } from "../components/SiteHeader"; export function Component() { return ( <> - +