forked from Mike/freno-dev
nook release changelog api available
This commit is contained in:
151
src/routes/api/the-nook/_releases.ts
Normal file
151
src/routes/api/the-nook/_releases.ts
Normal 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][] = [
|
||||
[/</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 —
|
||||
* `<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);
|
||||
}
|
||||
35
src/routes/api/the-nook/releases.json.ts
Normal file
35
src/routes/api/the-nook/releases.json.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { APIEvent } from "@solidjs/start/server";
|
||||
import { collectReleases } from "./_releases";
|
||||
import { error } from "./_lib";
|
||||
|
||||
/**
|
||||
* GET /api/the-nook/releases.json
|
||||
*
|
||||
* The release list behind The Nook's Settings > Changelog tab, newest first:
|
||||
* `{ releases: [{ version, build, date, sections: [{ title, bullets }] }] }`.
|
||||
*
|
||||
* Same source as /changelog — the Sparkle appcast plus the checked-in archive
|
||||
* of releases older than its retention — so the app and the website tell one
|
||||
* story. Section titles and ISO dates instead of HTML keep the client from
|
||||
* parsing markup it would only take apart again.
|
||||
*
|
||||
* Five-minute cache, in step with the appcast route. An unreadable appcast is
|
||||
* a 503 rather than an archived-only 200: the app keeps the newest release in
|
||||
* its own bundle and falls back to it, which beats showing stale notes as if
|
||||
* they were current.
|
||||
*/
|
||||
export async function GET(_event: APIEvent) {
|
||||
try {
|
||||
const releases = await collectReleases();
|
||||
return new Response(JSON.stringify({ releases }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Cache-Control": "public, max-age=300"
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to build the Nook release list:", err);
|
||||
return error("Release list unavailable", 503);
|
||||
}
|
||||
}
|
||||
198
src/routes/api/the-nook/releases.test.ts
Normal file
198
src/routes/api/the-nook/releases.test.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { describe, expect, it, mock, beforeEach } from "bun:test";
|
||||
|
||||
// The contract The Nook's Settings > Changelog tab is built on:
|
||||
// 1. One list, newest first, from the appcast plus the archive of releases
|
||||
// the appcast has aged out — the same merge /changelog renders.
|
||||
// 2. Structured notes and ISO dates, so the client renders instead of
|
||||
// parsing HTML it would only take apart again.
|
||||
// 3. An unreadable appcast is a 503, never an archived-only 200 that reads
|
||||
// as current.
|
||||
|
||||
type Release = {
|
||||
version: string;
|
||||
build: string | null;
|
||||
date: string | null;
|
||||
sections: { title: string; bullets: string[] }[];
|
||||
};
|
||||
|
||||
let appcastXml = "";
|
||||
let s3ShouldThrow = false;
|
||||
|
||||
mock.module("~/env/server", () => ({
|
||||
env: {
|
||||
AWS_REGION: "us-east-1",
|
||||
MY_AWS_ACCESS_KEY: "test-key",
|
||||
MY_AWS_SECRET_KEY: "test-secret",
|
||||
VITE_DOWNLOAD_BUCKET_STRING: "test-bucket"
|
||||
}
|
||||
}));
|
||||
|
||||
mock.module("@aws-sdk/client-s3", () => ({
|
||||
S3Client: class {
|
||||
async send() {
|
||||
if (s3ShouldThrow) throw new Error("s3 unavailable");
|
||||
return {
|
||||
Body: appcastXml
|
||||
? { transformToString: async () => appcastXml }
|
||||
: null
|
||||
};
|
||||
}
|
||||
},
|
||||
GetObjectCommand: class {
|
||||
input: unknown;
|
||||
constructor(input: unknown) {
|
||||
this.input = input;
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
const { GET } = await import("./releases.json");
|
||||
const { invalidateAppcastCache } = await import("./_appcast");
|
||||
const { isoDateOrNull, sectionsFromHtml } = await import("./_releases");
|
||||
const { ARCHIVED_RELEASES } = await import("~/routes/nook/changelog-archive");
|
||||
|
||||
const item = (version: string, pubDate: string, notes: string) => `
|
||||
<item>
|
||||
<title>Version ${version}</title>
|
||||
<pubDate>${pubDate}</pubDate>
|
||||
<sparkle:version>${version === "0.6.3" ? "10" : "9"}</sparkle:version>
|
||||
<sparkle:shortVersionString>${version}</sparkle:shortVersionString>
|
||||
<description><![CDATA[${notes}]]></description>
|
||||
<enclosure url="https://cdn.example/TheNook-${version}.dmg" type="application/octet-stream"/>
|
||||
</item>`;
|
||||
|
||||
const NOTES =
|
||||
"<p><b>Added</b></p><ul><li>Changelog tab in Settings.</li></ul>" +
|
||||
"<p><b>Fixed</b></p><ul><li>Call actions press only their own button; "End Call" no longer matches "Send".</li></ul>";
|
||||
|
||||
const appcast = (items: string) =>
|
||||
`<?xml version="1.0"?><rss><channel>${items}</channel></rss>`;
|
||||
|
||||
const request = () =>
|
||||
({
|
||||
request: new Request("https://freno.me/api/the-nook/releases.json"),
|
||||
nativeEvent: {}
|
||||
}) as never;
|
||||
|
||||
async function body(): Promise<{ releases: Release[] }> {
|
||||
const res = await GET(request());
|
||||
return (await res.json()) as { releases: Release[] };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
appcastXml = appcast(
|
||||
item("0.6.3", "Fri, 02 Oct 2026 23:29:42 -0400", NOTES) +
|
||||
item("0.6.2", "Thu, 24 Sep 2026 21:16:04 -0400", NOTES)
|
||||
);
|
||||
s3ShouldThrow = false;
|
||||
invalidateAppcastCache();
|
||||
});
|
||||
|
||||
describe("GET /api/the-nook/releases.json", () => {
|
||||
it("serves the appcast releases newest first, cacheable for five minutes", async () => {
|
||||
const res = await GET(request());
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("Content-Type")).toBe("application/json; charset=utf-8");
|
||||
expect(res.headers.get("Cache-Control")).toBe("public, max-age=300");
|
||||
|
||||
const { releases } = await body();
|
||||
expect(releases.map((r) => r.version)).toEqual(["0.6.3", "0.6.2", ...archivedVersions]);
|
||||
expect(releases[0].build).toBe("10");
|
||||
});
|
||||
|
||||
it("splits the published notes into titled bullet groups", async () => {
|
||||
const [first] = (await body()).releases;
|
||||
|
||||
expect(first!.sections).toEqual([
|
||||
{ title: "Added", bullets: ["Changelog tab in Settings."] },
|
||||
{
|
||||
title: "Fixed",
|
||||
bullets: [
|
||||
'Call actions press only their own button; "End Call" no longer matches "Send".'
|
||||
]
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("dates a release on its own day, not the UTC one", async () => {
|
||||
const releases = (await body()).releases;
|
||||
|
||||
// 23:29 -0400 is 03:29Z the next day; the release is still dated the 2nd.
|
||||
expect(releases[0]!.date).toBe("2026-10-02");
|
||||
expect(releases[1]!.date).toBe("2026-09-24");
|
||||
});
|
||||
|
||||
it("appends the archived releases the appcast has aged out", async () => {
|
||||
const releases = (await body()).releases;
|
||||
const archived = releases.filter((r) => !["0.6.3", "0.6.2"].includes(r.version));
|
||||
|
||||
expect(archived.map((r) => r.version)).toEqual(archivedVersions);
|
||||
expect(archived[0]!.build).toBeNull();
|
||||
// Archive dates are already calendar dates and pass through untouched.
|
||||
expect(archived[0]!.date).toBe(ARCHIVED_RELEASES[0]!.date);
|
||||
expect(archived[0]!.sections.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("lists a version once when the appcast and the archive both carry it", async () => {
|
||||
appcastXml = appcast(item("0.5.0", "Mon, 07 Sep 2026 16:11:44 -0400", NOTES));
|
||||
invalidateAppcastCache();
|
||||
|
||||
const releases = (await body()).releases;
|
||||
const matches = releases.filter((r) => r.version === "0.5.0");
|
||||
|
||||
expect(matches.length).toBe(1);
|
||||
// The live item wins: it carries the build number the archive lacks.
|
||||
expect(matches[0]!.build).toBe("9");
|
||||
expect(matches[0]!.date).toBe("2026-09-07");
|
||||
});
|
||||
|
||||
it("503s when the appcast cannot be read, rather than serving archive-only notes", async () => {
|
||||
s3ShouldThrow = true;
|
||||
|
||||
const res = await GET(request());
|
||||
expect(res.status).toBe(503);
|
||||
expect(((await res.json()) as { error: string }).error).toBe(
|
||||
"Release list unavailable"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sectionsFromHtml", () => {
|
||||
it("labels an unlabelled list as Notes", () => {
|
||||
expect(sectionsFromHtml("<ul><li>Fixes</li></ul>")).toEqual([
|
||||
{ title: "Notes", bullets: ["Fixes"] }
|
||||
]);
|
||||
});
|
||||
|
||||
it("joins a bullet the HTML wrapped across lines, and drops empty ones", () => {
|
||||
expect(
|
||||
sectionsFromHtml(
|
||||
"<p><b>Fixed</b></p><ul>\n<li>a bullet\n split over\n lines</li><li> </li></ul>"
|
||||
)
|
||||
).toEqual([{ title: "Fixed", bullets: ["a bullet split over lines"] }]);
|
||||
});
|
||||
|
||||
it("returns no sections for empty or note-less HTML", () => {
|
||||
expect(sectionsFromHtml("")).toEqual([]);
|
||||
expect(sectionsFromHtml("<p><b>Added</b></p>")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isoDateOrNull", () => {
|
||||
it("reads an RFC-1123 pubDate at its own offset", () => {
|
||||
expect(isoDateOrNull("Sat, 03 Oct 2026 00:00:00 +0000")).toBe("2026-10-03");
|
||||
expect(isoDateOrNull("Fri, 02 Oct 2026 23:29:42 -0400")).toBe("2026-10-02");
|
||||
expect(isoDateOrNull("Tue, 06 Oct 2026 09:00:00 +0200")).toBe("2026-10-06");
|
||||
});
|
||||
|
||||
it("passes a calendar date through and rejects junk", () => {
|
||||
expect(isoDateOrNull("2026-09-06")).toBe("2026-09-06");
|
||||
expect(isoDateOrNull("sometime in September")).toBeNull();
|
||||
expect(isoDateOrNull("")).toBeNull();
|
||||
expect(isoDateOrNull(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/** The archive in full, in file order (newest first) — the tail of the list. */
|
||||
const archivedVersions = ARCHIVED_RELEASES.map((r) => r.version);
|
||||
Reference in New Issue
Block a user