From be90b3d068d16c5eec0cb09ab901f83c6ed9f734 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Sun, 11 Oct 2026 10:19:49 -0400 Subject: [PATCH] nook release changelog api available --- src/routes/api/the-nook/_releases.ts | 151 +++++++++++++++++ src/routes/api/the-nook/releases.json.ts | 35 ++++ src/routes/api/the-nook/releases.test.ts | 198 +++++++++++++++++++++++ 3 files changed, 384 insertions(+) create mode 100644 src/routes/api/the-nook/_releases.ts create mode 100644 src/routes/api/the-nook/releases.json.ts create mode 100644 src/routes/api/the-nook/releases.test.ts diff --git a/src/routes/api/the-nook/_releases.ts b/src/routes/api/the-nook/_releases.ts new file mode 100644 index 0000000..063521f --- /dev/null +++ b/src/routes/api/the-nook/_releases.ts @@ -0,0 +1,151 @@ +/** + * The release list for The Nook's in-app changelog (Settings > Changelog). + * + * The /changelog page already answers this question, but only as HTML, and it + * assembles the list inside the route component. A native client must not + * couple to that markup, and the appcast alone is not the full story: releases + * older than the appcast's retention live only in the checked-in archive. So + * this module mirrors the page's merge — live appcast items, then archived + * releases the appcast has aged out — and normalizes it into data a client can + * render without parsing HTML. + * + * Underscore-prefixed file — not a route. + */ +import { ARCHIVED_RELEASES } from "~/routes/nook/changelog-archive"; +import { fetchAppcastReleases, type AppcastRelease } from "./_appcast"; + +export interface ReleaseSection { + /** "Added" / "Changed" / "Fixed"; "Notes" when the notes carry no label. */ + title: string; + bullets: string[]; +} + +export interface AppRelease { + version: string; + /** Build number, null for archived releases (they carry none). */ + build: string | null; + /** ISO calendar date, "2026-10-02"; null when no date was published. */ + date: string | null; + sections: ReleaseSection[]; +} + +const ENTITY_REPLACEMENTS: [RegExp, string][] = [ + [/</g, "<"], + [/>/g, ">"], + [/"/g, '"'], + [/'/g, "'"], + [/ /g, " "], + // Last, so "&lt;" cannot decode twice. + [/&/g, "&"] +]; + +function decodeEntities(text: string): string { + return ENTITY_REPLACEMENTS.reduce((acc, [re, to]) => acc.replace(re, to), text); +} + +function stripTags(text: string): string { + return text.replace(/<[^>]*>/g, ""); +} + +/** Collapses the whitespace an HTML source wraps bullets with. */ +function oneLine(text: string): string { + return text.replace(/\s+/g, " ").trim(); +} + +/** + * Reads the notes shape `embed_release_notes.py` writes — + * `

Added

` — into titled bullet groups. + * + * A `