0.8.0 and 0.7.0 rendered as a version header over an empty row on /changelog and in releases.json, the feed the macOS app's Changelog pane renders. Older still, 0.6.0/0.6.2/0.6.3 were gone from the page outright. Both come from one gap. The appcast is the only live source and it is not a store: it holds just the DMGs the release machine keeps, and generate_appcast drops the description of any rebuilt item whose DMG ships no notes file of its own — so a release it still lists can arrive with nothing to show, and one it has aged out of vanishes. The checked-in archive was meant to cover that, but it stopped at 0.5.0, and mergeReleases skipped an archived version whenever the appcast mentioned it, so it could never heal a row that existed and was blank. The archive now carries every release that shipped notes, 0.9.0 down to 0.2.0, and withArchivedNotes fills an item only where the feed has nothing, leaving a published description alone. /changelog and releases.json run the same rule rather than two copies, so the page and the feed cannot disagree about what a release said. 0.6.1 and 0.3.1 stay absent by design — they shipped with no notes written. Tests: bun test src/routes/api/the-nook/ (51 pass; the 4 new cases fail on this branch's parent); bun test unchanged at 49 failures, all in src/server/security, which needs an .env a worktree does not carry. Proved end to end against the live appcast as published by the 0.9.0 release: 11 releases, all with sections. NOOK-305 Co-Authored-By: qwen3.8-flash <noreply@freno.me>
140 lines
6.1 KiB
TypeScript
140 lines
6.1 KiB
TypeScript
/**
|
||
* `/changelog` on the Nook subdomain — rendered from the Sparkle appcast.
|
||
*
|
||
* The appcast already ships HTML release notes to the updater on every
|
||
* launch, so this page renders exactly what Sparkle publishes: one source
|
||
* of truth, no second place to write release notes. The checked-in archive
|
||
* fills an item the appcast lost its notes to — the feed is rebuilt from the
|
||
* DMGs on every publish, and Sparkle drops the notes of an item whose DMG
|
||
* carries none, which is how 0.8.0 and 0.7.0 came to render as blank rows.
|
||
* Doubles as the raw material for the release-announcement job
|
||
* (marketing/README.md).
|
||
*
|
||
* Layout: the version list lives in a fixed-height vertical scroller, so
|
||
* expanding a release never shifts the page — history is browsed inside
|
||
* the panel. Rows collapse to their header; notes stay in the DOM
|
||
* (visibility toggled, not unmounted) so crawlers still see every entry.
|
||
*/
|
||
import { For, Show, createSignal } from "solid-js";
|
||
import { query, createAsync } from "@solidjs/router";
|
||
import { PageHead } from "~/components/PageHead";
|
||
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||
import SubdomainHeader from "~/components/SubdomainHeader";
|
||
import { fetchAppcastReleases } from "~/routes/api/the-nook/_appcast";
|
||
import { withArchivedNotes } from "~/routes/api/the-nook/_releases";
|
||
import { ARCHIVED_RELEASES } from "~/routes/nook/changelog-archive";
|
||
|
||
const getReleases = query(async () => {
|
||
"use server";
|
||
// Appcast items are authoritative (they carry build numbers), with the
|
||
// archive filling any item whose notes the rebuilt feed lost. Releases the
|
||
// appcast no longer lists at all come from the archive. Both normalize to
|
||
// AppcastRelease so the page renders one shape, and the fill is the same
|
||
// helper releases.json runs, so the two cannot tell different stories.
|
||
const live = withArchivedNotes(
|
||
await fetchAppcastReleases(),
|
||
ARCHIVED_RELEASES
|
||
);
|
||
const liveVersions = new Set(live.map((r) => r.version));
|
||
const archived = ARCHIVED_RELEASES.filter(
|
||
(r) => !liveVersions.has(r.version)
|
||
).map((r) => ({ ...r, build: null }));
|
||
return [...live, ...archived];
|
||
}, "nook-changelog-releases");
|
||
|
||
export default function NookChangelogPage() {
|
||
const releases = createAsync(() => getReleases(), { deferStream: true });
|
||
// Newest release starts open; every other row starts collapsed. `null`
|
||
// means "the user hasn't touched the accordion yet"; after any click the
|
||
// signal holds a version or the NONE sentinel — collapsing the open row
|
||
// sets NONE, so all sections stay closed instead of snapping back open.
|
||
const NONE = "__none__";
|
||
const [openVersion, setOpenVersion] = createSignal<string | null>(null);
|
||
|
||
const isOpen = (version: string, index: number) => {
|
||
const open = openVersion();
|
||
if (open === null) return index === 0;
|
||
return open !== NONE && open === version;
|
||
};
|
||
|
||
const toggle = (version: string, index: number) =>
|
||
setOpenVersion(isOpen(version, index) ? NONE : version);
|
||
|
||
return (
|
||
<>
|
||
<EdgeCacheHeaders maxAge={300} />
|
||
<PageHead
|
||
title="Changelog"
|
||
description="Every release of The Nook, newest first — agent orchestration, fan and thermal control for macOS."
|
||
ogTitle="The Nook changelog"
|
||
/>
|
||
<SubdomainHeader />
|
||
<main class="min-h-screen px-[8vw] py-[8vh]">
|
||
<h1 class="text-text mb-3 text-3xl font-bold tracking-tight sm:text-4xl">
|
||
Changelog
|
||
</h1>
|
||
<p class="text-subtext0 mb-10 max-w-2xl text-lg">
|
||
What changed in each release.
|
||
</p>
|
||
|
||
<Show
|
||
when={(releases()?.length ?? 0) > 0}
|
||
fallback={
|
||
<p class="text-subtext0 text-sm">
|
||
No releases published yet — check back soon.
|
||
</p>
|
||
}
|
||
>
|
||
<div class="border-overlay0 bg-surface0/40 max-h-[70vh] overflow-y-auto rounded-2xl border">
|
||
<For each={releases()}>
|
||
{(release, i) => (
|
||
<article class="border-overlay0 not-last:border-b">
|
||
<button
|
||
type="button"
|
||
class="hover:bg-base flex w-full items-baseline gap-x-3 gap-y-1 p-5 text-left transition-colors"
|
||
aria-expanded={isOpen(release.version, i())}
|
||
onClick={() => toggle(release.version, i())}
|
||
>
|
||
<span class="text-subtext1 w-4 shrink-0 font-mono text-sm">
|
||
{isOpen(release.version, i()) ? "−" : "+"}
|
||
</span>
|
||
<h2 class="text-text text-lg font-semibold">
|
||
Version {release.version}
|
||
</h2>
|
||
<Show when={release.date}>
|
||
<span class="text-subtext1 ml-auto text-xs">
|
||
{release.date}
|
||
</span>
|
||
</Show>
|
||
</button>
|
||
{/* Notes are the publisher's own HTML, written to the same
|
||
S3 appcast the Sparkle updater consumes. Kept mounted
|
||
and toggled via `hidden` so the content stays crawlable.
|
||
Releases that shipped before notes were embedded in the
|
||
appcast carry no description — say so instead of
|
||
rendering a blank expanse. */}
|
||
<div
|
||
classList={{ hidden: !isOpen(release.version, i()) }}
|
||
class="text-subtext0 [&_h2]:text-text px-5 pb-5 pl-14 text-sm [&_h2]:mt-3 [&_h2]:mb-1 [&_h2]:text-base [&_h2]:font-semibold [&_li]:ml-5 [&_li]:list-disc [&_p]:mb-2 [&_ul]:mb-2"
|
||
>
|
||
<Show
|
||
when={release.notesHtml}
|
||
fallback={
|
||
<p class="text-subtext1 italic">
|
||
No notes recorded for this release.
|
||
</p>
|
||
}
|
||
>
|
||
<div innerHTML={release.notesHtml} />
|
||
</Show>
|
||
</div>
|
||
</article>
|
||
)}
|
||
</For>
|
||
</div>
|
||
</Show>
|
||
</main>
|
||
</>
|
||
);
|
||
}
|