diff --git a/scripts/generate-card-images.test.js b/scripts/generate-card-images.test.js
index 5c579bf6..d9d9699f 100644
--- a/scripts/generate-card-images.test.js
+++ b/scripts/generate-card-images.test.js
@@ -53,6 +53,70 @@ test("parseFeedItem extracts markdown package, diff, and commit data", async ()
});
});
+test("parseFeedItem extracts HTML release-table data (Atom feed fallback)", async () => {
+ const { parseFeedItem } = await import("./lib/card-feed-parser.mjs");
+
+ // Real shape produced when GitHub renders release Markdown to HTML, as
+ // returned by the Atom feed fallback (fetch-feeds.js) when GITHUB_TOKEN
+ // is unavailable and the REST API can't be used.
+ const item = {
+ title: "stable-20260401 (F43.20260401, #123)",
+ pubDate: "2026-04-01T00:00:00Z",
+ link: "https://example.com/release",
+ content: [
+ "
Major packages
",
+ "",
+ "| Name | Version |
",
+ "",
+ "| Kernel | 6.14.0 ➡️ 6.15.0 |
",
+ "| Mesa | 25.0 |
",
+ "",
+ "
",
+ "Major DX packages
",
+ "",
+ "| Name | Version |
",
+ "",
+ "| Devpod | 0.5 ➡️ 0.6 |
",
+ "",
+ "
",
+ "All Images
",
+ "",
+ " | Name | Previous | New |
",
+ "",
+ "| ✨ | ghcr.io/example/new | | |
",
+ "| 🔄 | ghcr.io/example/changed | | |
",
+ "| ❌ | ghcr.io/example/removed | | |
",
+ "",
+ "
",
+ "Commits
",
+ "",
+ "| Hash | Message |
",
+ "",
+ "| abc123 | Update kernel |
",
+ "| def456 | Update mesa |
",
+ "",
+ "
",
+ ].join("\n"),
+ };
+
+ assert.deepEqual(parseFeedItem(item, "stable"), {
+ stream: "stable",
+ tag: "stable-20260401",
+ fedoraVersion: "43",
+ centosVersion: null,
+ majorPackages: [
+ { name: "Kernel", version: "6.15.0", prevVersion: "6.14.0" },
+ { name: "Mesa", version: "25.0", prevVersion: null },
+ ],
+ dxPackages: [{ name: "Devpod", version: "0.6", prevVersion: "0.5" }],
+ gdxPackages: [],
+ diffStats: { added: 1, changed: 1, removed: 1 },
+ commitCount: 2,
+ dateMs: Date.parse("2026-04-01T00:00:00Z"),
+ link: "https://example.com/release",
+ });
+});
+
test("parseFeedItem returns null when markdown tables are missing major packages", async () => {
const { parseFeedItem } = await import("./lib/card-feed-parser.mjs");
diff --git a/scripts/lib/card-feed-parser.mjs b/scripts/lib/card-feed-parser.mjs
index fa3500d1..9fa69acb 100644
--- a/scripts/lib/card-feed-parser.mjs
+++ b/scripts/lib/card-feed-parser.mjs
@@ -72,12 +72,77 @@ export function parseCommitRows(rows) {
return rows.filter((cells) => cells.length >= 2 && cells[0] && cells[0] !== "Hash").length;
}
+/**
+ * Strip HTML tags and decode the handful of entities GitHub uses when
+ * rendering release notes (Atom feed content, or the release page itself).
+ */
+export function stripHtml(text) {
+ return text
+ .replace(/<[^>]+>/g, "")
+ .replace(/&/g, "&")
+ .replace(/</g, "<")
+ .replace(/>/g, ">")
+ .replace(/"/g, '"')
+ .replace(/?39;/g, "'")
+ .trim();
+}
+
+export function splitHtmlRow(rowHtml) {
+ const cells = [];
+ const cellRe = /]*>([\s\S]*?)<\/t[hd]>/gi;
+ let match;
+ while ((match = cellRe.exec(rowHtml))) {
+ cells.push(stripHtml(match[1]));
+ }
+ return cells;
+}
+
+/**
+ * Extract `### Heading` -> table-rows sections from an HTML release body
+ * (e.g. the GitHub Atom feed, which renders Markdown to HTML). Each heading
+ * is paired with the next that appears before the following heading.
+ */
+export function extractSectionsHtml(content) {
+ const sections = new Map();
+ const headingRe = /]*>([\s\S]*?)<\/h[1-6]>/gi;
+ const headings = [];
+ let hm;
+ while ((hm = headingRe.exec(content))) {
+ headings.push({ index: hm.index, end: headingRe.lastIndex, text: stripHtml(hm[1]) });
+ }
+
+ const tableRe = /]*>([\s\S]*?)<\/table>/gi;
+ const tables = [];
+ let tm;
+ while ((tm = tableRe.exec(content))) {
+ tables.push({ index: tm.index, body: tm[1] });
+ }
+
+ for (let i = 0; i < headings.length; i++) {
+ const heading = headings[i];
+ const nextHeadingIndex = i + 1 < headings.length ? headings[i + 1].index : Infinity;
+ const table = tables.find((t) => t.index > heading.end && t.index < nextHeadingIndex);
+ if (!table) continue;
+
+ const rowRe = /]*>([\s\S]*?)<\/tr>/gi;
+ const rows = [];
+ let rm;
+ while ((rm = rowRe.exec(table.body))) {
+ const cells = splitHtmlRow(rm[1]);
+ if (cells.length > 0) rows.push(cells);
+ }
+ if (rows.length > 0) sections.set(heading.text, rows);
+ }
+ return sections;
+}
+
export function parseFeedItem(item, streamHint) {
const content = item.content ?? "";
const isMarkdown = /^\|[\s|:-]*---[\s|:-]*\|/m.test(content);
- if (!isMarkdown) return null;
+ const isHtml = !isMarkdown && /]*>[\s\S]*<\/table>/i.test(content);
+ if (!isMarkdown && !isHtml) return null;
- const sections = extractSectionsMd(content);
+ const sections = isHtml ? extractSectionsHtml(content) : extractSectionsMd(content);
const majorPackages = parseTwoColTableMd(sections.get("Major packages") ?? []);
const dxPackages = parseTwoColTableMd(sections.get("Major DX packages") ?? []);
const gdxPackages = parseTwoColTableMd(sections.get("Major GDX packages") ?? []);