nook release changelog api available

This commit is contained in:
2026-10-11 10:19:49 -04:00
parent cbe56390f8
commit be90b3d068
3 changed files with 384 additions and 0 deletions

View File

@@ -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][] = [
[/&lt;/g, "<"],
[/&gt;/g, ">"],
[/&quot;/g, '"'],
[/&#39;/g, "'"],
[/&nbsp;/g, " "],
// Last, so "&amp;lt;" cannot decode twice.
[/&amp;/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 —
* `<p><b>Added</b></p><ul><li>…</li></ul>` — into titled bullet groups.
*
* A `<ul>` with no label before it (what `generate_appcast` writes on its own)
* becomes "Notes". A `<p>` is taken as the next list's label rather than as
* prose, which is safe for notes this pipeline generates and degrades to a
* stray heading if that ever changes.
*/
export function sectionsFromHtml(html: string): ReleaseSection[] {
const sections: ReleaseSection[] = [];
let label = "";
for (const block of html.matchAll(/<p>([\s\S]*?)<\/p>|<ul[^>]*>([\s\S]*?)<\/ul>/gi)) {
if (block[1] !== undefined) {
label = oneLine(stripTags(decodeEntities(block[1])));
continue;
}
const bullets = [...block[2]!.matchAll(/<li[^>]*>([\s\S]*?)<\/li>/gi)]
.map((b) => oneLine(stripTags(decodeEntities(b[1] ?? ""))))
.filter((b) => b.length > 0);
if (bullets.length === 0) continue;
sections.push({ title: label || "Notes", bullets });
label = "";
}
return sections;
}
/**
* Normalizes a published date to an ISO calendar date. The appcast carries an
* RFC-1123 `pubDate`, the archive a plain `2026-09-06`.
*
* The day is taken at the timestamp's own offset, not in UTC: a release
* published 23:29 -0400 is dated the 2nd, and reading it as UTC would move it
* to the 3rd.
*/
export function isoDateOrNull(raw: string | null): string | null {
const value = raw?.trim();
if (!value) return null;
const iso = /^(\d{4})-(\d{2})-(\d{2})/.exec(value);
if (iso) return `${iso[1]}-${iso[2]}-${iso[3]}`;
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return null;
const offset = /([+-])(\d{2}):?(\d{2})$/.exec(value);
const offsetMinutes = offset
? (offset[1] === "-" ? -1 : 1) * (Number(offset[2]) * 60 + Number(offset[3]))
: 0;
return new Date(parsed.getTime() + offsetMinutes * 60_000).toISOString().slice(0, 10);
}
function toAppRelease(release: AppcastRelease | ArchivedReleaseShape): AppRelease {
return {
version: release.version,
build: "build" in release ? release.build : null,
date: isoDateOrNull(release.date),
sections: sectionsFromHtml(release.notesHtml)
};
}
/** The archive carries no build number; this is that shape without it. */
type ArchivedReleaseShape = {
version: string;
date: string;
notesHtml: string;
};
/**
* Live releases first, newest first, then any archived release the appcast no
* longer lists. Order is concatenation rather than a sort because both inputs
* are newest-first and the archive only ever holds older releases — matching
* how /changelog builds its list.
*/
export function mergeReleases(
live: readonly AppcastRelease[],
archived: readonly ArchivedReleaseShape[]
): AppRelease[] {
const releases = live.map(toAppRelease);
const seen = new Set(releases.map((r) => r.version));
for (const release of archived) {
if (seen.has(release.version)) continue;
releases.push(toAppRelease(release));
}
return releases;
}
/**
* Everything the in-app changelog shows. Throws when the appcast cannot be
* read — the caller must not serve an archived-only list as if it were
* current, since the app then still has the newest release in its own bundle.
*/
export async function collectReleases(): Promise<AppRelease[]> {
return mergeReleases(await fetchAppcastReleases(), ARCHIVED_RELEASES);
}