Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions scripts/generate-card-images.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
"<h3>Major packages</h3>",
"<table>",
"<thead><tr><th>Name</th><th>Version</th></tr></thead>",
"<tbody>",
"<tr><td><strong>Kernel</strong></td><td>6.14.0 ➡️ 6.15.0</td></tr>",
"<tr><td><strong>Mesa</strong></td><td>25.0</td></tr>",
"</tbody>",
"</table>",
"<h3>Major DX packages</h3>",
"<table>",
"<thead><tr><th>Name</th><th>Version</th></tr></thead>",
"<tbody>",
"<tr><td><strong>Devpod</strong></td><td>0.5 ➡️ 0.6</td></tr>",
"</tbody>",
"</table>",
"<h3>All Images</h3>",
"<table>",
"<thead><tr><th></th><th>Name</th><th>Previous</th><th>New</th></tr></thead>",
"<tbody>",
"<tr><td>✨</td><td>ghcr.io/example/new</td><td></td><td></td></tr>",
"<tr><td>🔄</td><td>ghcr.io/example/changed</td><td></td><td></td></tr>",
"<tr><td>❌</td><td>ghcr.io/example/removed</td><td></td><td></td></tr>",
"</tbody>",
"</table>",
"<h3>Commits</h3>",
"<table>",
"<thead><tr><th>Hash</th><th>Message</th></tr></thead>",
"<tbody>",
"<tr><td>abc123</td><td>Update kernel</td></tr>",
"<tr><td>def456</td><td>Update mesa</td></tr>",
"</tbody>",
"</table>",
].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");

Expand Down
69 changes: 67 additions & 2 deletions scripts/lib/card-feed-parser.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#0?39;/g, "'")
.trim();
}

export function splitHtmlRow(rowHtml) {
const cells = [];
const cellRe = /<t[hd][^>]*>([\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 <table> that appears before the following heading.
*/
export function extractSectionsHtml(content) {
const sections = new Map();
const headingRe = /<h[1-6][^>]*>([\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 = /<table[^>]*>([\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 = /<tr[^>]*>([\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 && /<table[^>]*>[\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") ?? []);
Expand Down