diff --git a/.changes/README.md b/.changes/README.md
index 318217075..ac69427f5 100644
--- a/.changes/README.md
+++ b/.changes/README.md
@@ -55,8 +55,10 @@ The body is capped at 400 characters — depth belongs in the blog post.
gives it a short, poster-worthy name. The 60-character cap keeps it roughly to
one line on the hero card; card text wraps by estimated width, so a headline of
unusually wide glyphs may still wrap or ellipsize rather than overflow. Highlights lead the Telegram/Reddit
-announcements (everything else collapses into a "+N more" counter) and become
-ready-made section headings in the blog scaffold. Set it on the two or three
+announcements (everything else collapses into a "+N more" counter) and open the
+blog scaffold: a row in its "What changed" table and a `##` section ahead of
+the themed sections, while fixes without a highlight collapse under a spoiler
+there. Set it on the two or three
changes worth announcing — a release where everything is a highlight has none.
Not allowed on `type: internal`.
diff --git a/CLAUDE.md b/CLAUDE.md
index 797cc9693..42577ce17 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -32,7 +32,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- Name it `-.md`; `area` matches the conventional-commit scope. There is no version field — the release version is chosen at release time.
- Write the body for a user, not a reviewer: "the player now remembers volume between episodes", not "hoist volume state into the session". Max 400 characters; depth belongs in the release blog post.
- `type: internal` records invisible maintenance. Internal notes stay collapsed in `CHANGELOG.md`, are omitted from the blog scaffold, and are removed from the authored public GitHub body by `extract-changelog-section.mjs --public`; GitHub's generated commit list remains separate, so an internal-only release can have an empty authored body.
-- `highlight: ` (max 60 characters, rejected on `type: internal`) marks a note as one of the release's two or three headline changes. Highlights lead the Telegram/Reddit announcement drafts, become ready-made blog section headings, and are the input the highlight-card generator renders from. A release where everything is a highlight has none.
+- `highlight: ` (max 60 characters, rejected on `type: internal`) marks a note as one of the release's two or three headline changes. Highlights lead the Telegram/Reddit announcement drafts, open the blog scaffold (a "What changed" table row plus a leading `##` section each, while the remaining features fold into themed sections and non-highlighted fixes collapse under a spoiler — `tools/release/release-notes-blog.mjs`), and are the input the highlight-card generator renders from. A release where everything is a highlight has none.
- Skip the note for test-only changes, docs, CI/workflow plumbing, and pure refactors with no behavior change. When skipping on a PR that touches `apps/**` or `libs/**`, apply the `no-release-note` label.
- CI enforces this: the "Release note gate" job in `.github/workflows/ci.yml` fails PRs that change runtime code without an added `.changes/*.md` or the label (policy in `tools/release/check-release-note-gate.mjs`; tests/e2e/website/mock-server/docs paths are auto-exempt).
- The `release-notes` skill covers writing notes; the `release-cut` skill covers the full release sequence. Canonical contract — surfaces, ordering constraints, the required draft asset set: `docs/architecture/release-pipeline.md`.
diff --git a/docs/architecture/release-pipeline.md b/docs/architecture/release-pipeline.md
index ddf11e0fe..9f5f86b17 100644
--- a/docs/architecture/release-pipeline.md
+++ b/docs/architecture/release-pipeline.md
@@ -91,8 +91,9 @@ Highlights drive three behaviors:
dropping from the tail of the grouped list, which is ordered breaking →
feature → fix → perf so the least consequential go first. A breaking change
is never dropped there either.
-- **The blog scaffold** uses the highlight as a ready `###` heading instead of
- emitting `TODO headline ()`.
+- **The blog scaffold** gives each highlight a row in the opening "What
+ changed" table and its own `##` section ahead of everything else, instead of
+ emitting `TODO headline ()`. Shape below.
Prose fields keep `#`. `parseFrontmatterLine` strips trailing `# comment` text
only from closed-vocabulary fields (`type`, `area`, `issues`, `screenshot`),
@@ -106,6 +107,33 @@ Both announcement formats then print an explanation on stderr, leave stdout
empty, and exit 0 — the same shape `extract-changelog-section.mjs --public`
already uses for its empty public body.
+## Blog scaffold shape
+
+`renderBlogScaffold` (`tools/release/release-notes-blog.mjs`) emits the shape
+the published posts end up in, so the editor starts from the form rather than
+from the inventory — the v0.23 post shipped as the raw type-grouped list with
+area prefixes and had to be restructured after publication. In order:
+narrative intro (TODO) → `ReleaseMeta` → `## What changed` (a `ChangeTable`
+with one row per highlight; the theme is the default area label, the impact a
+TODO) → the "About the screenshots" alert when any note names a screenshot →
+one `##` section per highlight or screenshot note, with a `StatusPill`
+matching the note type → `## Breaking changes` → the remaining features folded
+into themed `##` sections → `## Performance` → `## Everything else`, holding
+every remaining fix under a `Spoiler` grouped by theme → the before-updating
+alert → `## Thanks` → `## Download` link cards (release tag, full notes, the
+compare link when a previous version is known, all releases).
+
+Themes come from `BLOG_THEMES`: a conventional-commit `area` says nothing to a
+reader ("matching", "window-controls", "electron-backend"), so notes fold into
+reader-facing headings ("Stalker portals", "Live TV, EPG and M3U"). An unmapped
+area lands in "Other changes" rather than failing; add it to the map when it
+recurs. Two defaults are deliberately dumb: every non-highlighted fix goes into
+the spoiler, and every bullet keeps its full note body. Promoting the fixes
+users will notice, compressing bullets to one line and writing the bold
+lead-ins is the editorial pass, and the scaffold marks where with `TODO`. Only
+the components the post actually uses are imported, so an MDX build never
+fails on an unused import.
+
## Highlight cards
`tools/release/highlight-cards.mjs` plans and lays out;
diff --git a/tools/release/build-release-notes.mjs b/tools/release/build-release-notes.mjs
index 648a29bdb..521a5c6ff 100644
--- a/tools/release/build-release-notes.mjs
+++ b/tools/release/build-release-notes.mjs
@@ -26,9 +26,9 @@ import {
} from './release-announcements.mjs';
import { loadNotes } from './release-notes.mjs';
import { manifestSlugs } from './screenshot-guards.mjs';
+import { renderBlogScaffold } from './release-notes-blog.mjs';
import {
releaseSlug,
- renderBlogScaffold,
renderChangelogSection,
renderGithubBody,
upsertChangelogSection,
@@ -347,6 +347,7 @@ function main() {
const content = renderBlogScaffold(notes, {
version,
date: options.date,
+ previousVersion: options.previous ?? detectPreviousVersion(),
links,
});
const target = writeBlogScaffold(content, version, options.force);
diff --git a/tools/release/project.json b/tools/release/project.json
index 8c3518d36..28a2f2fc8 100644
--- a/tools/release/project.json
+++ b/tools/release/project.json
@@ -10,6 +10,7 @@
"inputs": [
"{workspaceRoot}/tools/release/release-notes.mjs",
"{workspaceRoot}/tools/release/release-notes-render.mjs",
+ "{workspaceRoot}/tools/release/release-notes-blog.mjs",
"{workspaceRoot}/tools/release/release-announcements.mjs",
"{workspaceRoot}/tools/release/verify-draft-release.mjs",
"{workspaceRoot}/tools/release/highlight-cards.mjs",
diff --git a/tools/release/release-notes-blog.mjs b/tools/release/release-notes-blog.mjs
new file mode 100644
index 000000000..9e8c9f420
--- /dev/null
+++ b/tools/release/release-notes-blog.mjs
@@ -0,0 +1,397 @@
+/**
+ * Website blog scaffold for
+ * `apps/website/src/content/blog/-release-notes.mdx`.
+ *
+ * The scaffold ships in the shape the published posts end up in, so a release
+ * starts from the form instead of from an inventory the editor has to
+ * rebuild (the v0.23 post went out as the raw type-grouped list and had to be
+ * restructured afterwards):
+ *
+ * intro → ReleaseMeta → "What changed" table (one row per `highlight:`)
+ * → one `##` section per highlight, first → breaking changes → themed
+ * "improved" sections for the remaining features → Performance → every
+ * remaining fix under a Spoiler, grouped by theme → Before-updating alert
+ * → Thanks → Download cards.
+ *
+ * Deliberately incomplete: `draft: true`, TODO markers wherever a human has
+ * to write. The prose is editorial work, only the inventory is mechanical —
+ * including which fixes deserve promotion out of the spoiler.
+ */
+
+import { NOTE_TYPES, REPO_URL } from './release-notes.mjs';
+import {
+ escapeMdx,
+ formatLongDate,
+ formatReferences,
+ oneLine,
+ releaseSlug,
+ truncate,
+} from './release-notes-render.mjs';
+
+/**
+ * Reader-facing sections, in post order. `area` is the conventional-commit
+ * scope of the PR, which says nothing to a user ("matching",
+ * "window-controls", "electron-backend"), so notes are folded into these
+ * themes instead. An area not listed here lands in {@link BLOG_FALLBACK_THEME}
+ * rather than failing — the editor merges it where it belongs.
+ */
+export const BLOG_THEMES = [
+ { heading: 'Downloads and recordings', areas: ['downloads', 'recordings'] },
+ {
+ heading: 'Playback',
+ areas: ['playback', 'player', 'embedded-mpv', 'mpv', 'vlc', 'subtitles'],
+ },
+ {
+ heading: 'Movies, series and the dashboard',
+ areas: [
+ 'portals',
+ 'portal',
+ 'vod',
+ 'series',
+ 'tmdb',
+ 'dashboard',
+ 'matching',
+ 'favorites',
+ 'recent',
+ ],
+ },
+ { heading: 'Xtream', areas: ['xtream'] },
+ { heading: 'Stalker portals', areas: ['stalker'] },
+ {
+ heading: 'Live TV, EPG and M3U',
+ areas: ['m3u', 'epg', 'live', 'radio', 'catchup', 'remote-control'],
+ },
+ { heading: 'Search', areas: ['search'] },
+ {
+ heading: 'Settings, import and the desktop app',
+ areas: [
+ 'settings',
+ 'playlist',
+ 'playlists',
+ 'import',
+ 'backup',
+ 'ui',
+ 'components',
+ 'workspace',
+ 'i18n',
+ 'electron',
+ 'electron-backend',
+ 'window-controls',
+ 'updater',
+ 'deps',
+ 'packaging',
+ 'database',
+ ],
+ },
+ { heading: 'Self-hosted web version', areas: ['pwa', 'web-backend', 'docker'] },
+];
+
+export const BLOG_FALLBACK_THEME = 'Other changes';
+
+const STATUS_PILL_BY_TYPE = {
+ breaking: 'breaking',
+ feature: 'new',
+ fix: 'fixed',
+ perf: 'improved',
+};
+
+const COMPONENT_IMPORTS = {
+ Alert: "import Alert from '../../components/blog/Alert.astro';",
+ BlogImageSlider:
+ "import BlogImageSlider from '../../components/blog/BlogImageSlider.astro';",
+ ChangeTable:
+ "import ChangeTable from '../../components/blog/ChangeTable.astro';",
+ LinkCards: "import LinkCards from '../../components/blog/LinkCards.astro';",
+ ReleaseMeta:
+ "import ReleaseMeta from '../../components/blog/ReleaseMeta.astro';",
+ Spoiler: "import Spoiler from '../../components/blog/Spoiler.astro';",
+ StatusPill:
+ "import StatusPill from '../../components/blog/StatusPill.astro';",
+};
+
+const SCREENSHOT_ALERT = [
+ '',
+ "The screenshots and posters shown here come from the project's own mock servers with fictional catalog data. IPTVnator is a pure media player — it does not provide, host, bundle, or distribute any streams, playlists, or media content. You bring your own sources; the app just plays them.",
+ '',
+].join('\n');
+
+/**
+ * Embedded in a single-quoted JS string inside MDX. Backslashes must be
+ * escaped before apostrophes, or a body ending in `\` produces an unterminated
+ * string and breaks the website build.
+ */
+function jsString(text) {
+ return text.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
+}
+
+function themeHeadingOf(area) {
+ return (
+ BLOG_THEMES.find((theme) => theme.areas.includes(area))?.heading ??
+ BLOG_FALLBACK_THEME
+ );
+}
+
+/**
+ * @returns {{ heading: string, notes: object[] }[]} non-empty theme groups in
+ * {@link BLOG_THEMES} order, the fallback theme last
+ */
+export function groupNotesByTheme(notes) {
+ const headings = [
+ ...BLOG_THEMES.map((theme) => theme.heading),
+ BLOG_FALLBACK_THEME,
+ ];
+
+ return headings
+ .map((heading) => ({
+ heading,
+ notes: notes.filter((note) => themeHeadingOf(note.area) === heading),
+ }))
+ .filter((group) => group.notes.length > 0);
+}
+
+/** A note that carries a headline or a screenshot gets its own section. */
+function isFeatured(note) {
+ return Boolean(note.highlight || note.screenshot);
+}
+
+function statusPill(type) {
+ return ``;
+}
+
+function bullet(note, links) {
+ return `- ${escapeMdx(oneLine(note.body))}${formatReferences(note, links)}`;
+}
+
+function renderSlider(note, slug) {
+ const alt = jsString(truncate(oneLine(note.body), 120));
+ const images = ['dark', 'light']
+ .map(
+ (theme) =>
+ ` {\n src: '/iptvnator/blog/${slug}/screenshots/${note.screenshot}-${theme}.png',\n alt: '${alt}',\n },`
+ )
+ .join('\n');
+
+ return ``;
+}
+
+/**
+ * A `highlight:` headline is the editorial headline; a screenshot note
+ * without one still deserves its own section, but the heading is editorial
+ * work a note body cannot stand in for — leave a visible TODO instead of
+ * pretending otherwise.
+ */
+function renderFeaturedSection(note, { slug, links }) {
+ const blocks = [
+ note.highlight
+ ? `## ${escapeMdx(note.highlight)}`
+ : `## TODO headline (${note.area})`,
+ statusPill(note.type),
+ `${escapeMdx(oneLine(note.body))}${formatReferences(note, links)}`,
+ ];
+
+ if (note.screenshot) {
+ blocks.push(renderSlider(note, slug));
+ }
+
+ return blocks.join('\n\n');
+}
+
+function renderChangeTable(highlights) {
+ const rows = highlights
+ .map((note) =>
+ [
+ ' {',
+ ` area: '${jsString(themeHeadingOf(note.area))}',`,
+ ` change: '${jsString(oneLine(note.body))}',`,
+ " impact: 'TODO — one short phrase',",
+ ' },',
+ ].join('\n')
+ )
+ .join('\n');
+
+ return `## What changed\n\n`;
+}
+
+/**
+ * @param {string} pill a `StatusPill` type — the published posts label every
+ * bullet section `improved` (its items are not the release's headline
+ * features, those have sections of their own) except breaking changes
+ */
+function renderBulletSection(heading, pill, notes, links) {
+ return [
+ `## ${heading}`,
+ ``,
+ notes.map((note) => bullet(note, links)).join('\n'),
+ ].join('\n\n');
+}
+
+function renderThemedSections(notes, links) {
+ return groupNotesByTheme(notes).map((group) =>
+ renderBulletSection(group.heading, 'improved', group.notes, links)
+ );
+}
+
+/**
+ * Fixes without a headline collapse under a Spoiler, grouped by theme. That
+ * is a default, not a verdict: the editor promotes the fixes worth a
+ * paragraph into the open sections above.
+ */
+function renderFixesSpoiler(fixes, links) {
+ const count = fixes.length;
+ const groups = groupNotesByTheme(fixes)
+ .map((group) =>
+ [
+ `**${group.heading}**`,
+ group.notes.map((note) => bullet(note, links)).join('\n'),
+ ].join('\n\n')
+ )
+ .join('\n\n');
+
+ return [
+ '## Everything else',
+ `That is the part worth reading in one sitting. The remaining ${count} ${count === 1 ? 'fix is' : 'fixes are'} below, one line each. {/* TODO: promote the fixes users will notice into the sections above. */}`,
+ `\n\n${groups}\n\n`,
+ ].join('\n\n');
+}
+
+function renderLinkCards(version, previousVersion) {
+ const tag = `v${version}`;
+ const cards = [
+ {
+ label: `Download ${tag}`,
+ href: `${REPO_URL}/releases/tag/${tag}`,
+ hint: 'Official binaries for macOS, Windows and Linux.',
+ icon: 'download',
+ },
+ {
+ label: 'Full release notes',
+ href: `${REPO_URL}/releases/tag/${tag}`,
+ hint: 'Every entry in full, on GitHub.',
+ icon: 'github',
+ },
+ ];
+
+ if (previousVersion) {
+ cards.push({
+ label: 'Full Changelog',
+ href: `${REPO_URL}/compare/v${previousVersion}...${tag}`,
+ hint: `Every commit between v${previousVersion} and ${tag}.`,
+ icon: 'github',
+ });
+ }
+
+ cards.push({
+ label: 'All Releases',
+ href: `${REPO_URL}/releases`,
+ hint: 'Browse every IPTVnator release.',
+ icon: 'github',
+ });
+
+ const links = cards
+ .map((card) =>
+ [
+ ' {',
+ ` label: '${jsString(card.label)}',`,
+ ` href: '${card.href}',`,
+ ` hint: '${jsString(card.hint)}',`,
+ ` icon: '${card.icon}',`,
+ ' },',
+ ].join('\n')
+ )
+ .join('\n');
+
+ return ``;
+}
+
+function renderClosing(version, previousVersion) {
+ return [
+ '\nPlease back up your playlists, credentials, URLs, and other important data before installing a new release. {/* TODO: say whether this release changes the database schema or carries breaking changes. */}\n',
+ '## Thanks',
+ '{/* TODO: thank the testers, reporters, translators and sponsors behind this release. */}',
+ '## Download',
+ renderLinkCards(version, previousVersion),
+ ];
+}
+
+function renderFrontmatter(shortVersion, slug, date) {
+ return [
+ '---',
+ `title: ${shortVersion} - Release Notes`,
+ 'description: TODO — one sentence naming the two or three headline changes.',
+ 'featured: true',
+ `pubDate: ${date}`,
+ 'author: 4gray',
+ `heroImage: /iptvnator/blog/${slug}/hero.jpg`,
+ 'tags:',
+ ' - release',
+ ' - release-notes',
+ ` - ${shortVersion}`,
+ 'draft: true',
+ '---',
+ ].join('\n');
+}
+
+/**
+ * @param {object[]} notes
+ * @param {{ version: string, date: string, previousVersion?: string | null, links?: Map }} options
+ * @returns {string}
+ */
+export function renderBlogScaffold(
+ notes,
+ { version, date, previousVersion = null, links = new Map() }
+) {
+ const slug = releaseSlug(version);
+ const shortVersion = slug.replace('-', '.');
+ const publicNotes = notes.filter((note) => note.type !== 'internal');
+ const byType = (type) => publicNotes.filter((note) => note.type === type);
+
+ const featured = NOTE_TYPES.flatMap((type) => byType(type).filter(isFeatured));
+ const highlights = featured.filter((note) => note.highlight);
+ const rest = (type) => byType(type).filter((note) => !isFeatured(note));
+ const breaking = rest('breaking');
+ const features = rest('feature');
+ const fixes = rest('fix');
+ const perf = rest('perf');
+ const hasScreenshot = featured.some((note) => note.screenshot);
+
+ const used = new Set(['Alert', 'LinkCards', 'ReleaseMeta']);
+ if (publicNotes.length > 0) used.add('StatusPill');
+ if (highlights.length > 0) used.add('ChangeTable');
+ if (hasScreenshot) used.add('BlogImageSlider');
+ if (fixes.length > 0) used.add('Spoiler');
+
+ const imports = Object.keys(COMPONENT_IMPORTS)
+ .filter((name) => used.has(name))
+ .map((name) => COMPONENT_IMPORTS[name])
+ .join('\n');
+
+ const meta = [
+ '',
+ ].join('\n');
+
+ const body = [
+ renderFrontmatter(shortVersion, slug, date),
+ imports,
+ '{/* TODO: narrative intro — what this release is about, not what it contains. */}',
+ meta,
+ ];
+
+ if (highlights.length > 0) body.push(renderChangeTable(highlights));
+ if (hasScreenshot) body.push(SCREENSHOT_ALERT);
+ body.push(...featured.map((note) => renderFeaturedSection(note, { slug, links })));
+ if (breaking.length > 0) {
+ body.push(renderBulletSection('Breaking changes', 'breaking', breaking, links));
+ }
+ body.push(...renderThemedSections(features, links));
+ if (perf.length > 0) {
+ body.push(renderBulletSection('Performance', 'improved', perf, links));
+ }
+ if (fixes.length > 0) body.push(renderFixesSpoiler(fixes, links));
+ body.push(...renderClosing(version, previousVersion), '');
+
+ return body.join('\n\n');
+}
diff --git a/tools/release/release-notes-render.mjs b/tools/release/release-notes-render.mjs
index f18fbc0e8..1eb8a6a7e 100644
--- a/tools/release/release-notes-render.mjs
+++ b/tools/release/release-notes-render.mjs
@@ -1,7 +1,7 @@
/**
- * Renderers turning grouped `.changes/*.md` notes into the three release
- * surfaces: the GitHub release body, the CHANGELOG.md section, and the
- * website blog scaffold.
+ * Renderers turning grouped `.changes/*.md` notes into the GitHub release
+ * body and the CHANGELOG.md section, plus the text helpers shared with the
+ * website blog scaffold in `release-notes-blog.mjs`.
*/
import { extractSection } from './extract-changelog-section.mjs';
@@ -48,12 +48,12 @@ export function releaseSlug(version) {
}
/** Collapses a note body to a single line for list entries. */
-function oneLine(body) {
+export function oneLine(body) {
return body.replace(/\s+/g, ' ').trim();
}
/** Trims to a word boundary; used for image alt text, never for prose. */
-function truncate(text, max) {
+export function truncate(text, max) {
if (text.length <= max) {
return text;
}
@@ -70,7 +70,7 @@ function truncate(text, max) {
* break the website build. The closing counterparts are escaped too, so a
* body like `` renders as written instead of half-escaped.
*/
-function escapeMdx(text) {
+export function escapeMdx(text) {
return text
.replace(//g, '>')
@@ -83,7 +83,7 @@ function escapeMdx(text) {
* @param {Map} links
* @returns {string} trailing `([#123](url), closes [#45](url))` or ''
*/
-function formatReferences(note, links) {
+export function formatReferences(note, links) {
const parts = [];
const link = links.get(note.sourcePath);
@@ -221,119 +221,3 @@ export function upsertChangelogSection(changelog, section, version, marker) {
};
}
-/**
- * Blog entries carrying a `screenshot:` slug or a `highlight:` headline become
- * their own subsection (with an image slider when a screenshot exists); the
- * rest stay bullets.
- */
-function renderBlogGroup(group, { slug, links }) {
- const bullets = group.notes.filter(
- (note) => !note.screenshot && !note.highlight
- );
- const featured = group.notes.filter(
- (note) => note.screenshot || note.highlight
- );
- const blocks = [`## ${group.heading}`];
-
- if (bullets.length > 0) {
- blocks.push(
- bullets
- .map(
- (note) =>
- `- **${note.area}** — ${escapeMdx(oneLine(note.body))}${formatReferences(note, links)}`
- )
- .join('\n')
- );
- }
-
- for (const note of featured) {
- // A `highlight:` headline is the editorial headline; without one the
- // heading is editorial work a note body cannot stand in for — leave a
- // visible TODO instead of pretending otherwise; the whole scaffold
- // ships as `draft: true` anyway.
- blocks.push(
- note.highlight
- ? `### ${escapeMdx(note.highlight)}`
- : `### TODO headline (${note.area})`
- );
- blocks.push(
- `${escapeMdx(oneLine(note.body))}${formatReferences(note, links)}`
- );
-
- if (!note.screenshot) {
- continue;
- }
-
- // Embedded in a single-quoted JS string inside MDX. Backslashes must
- // be escaped before apostrophes, or a body ending in `\` produces an
- // unterminated string and breaks the website build.
- const alt = truncate(oneLine(note.body), 120)
- .replace(/\\/g, '\\\\')
- .replace(/'/g, "\\'");
- const images = ['dark', 'light']
- .map(
- (theme) =>
- ` {\n src: '/iptvnator/blog/${slug}/screenshots/${note.screenshot}-${theme}.png',\n alt: '${alt}',\n },`
- )
- .join('\n');
-
- blocks.push(``);
- }
-
- return blocks.join('\n\n');
-}
-
-/**
- * Scaffold for `apps/website/src/content/blog/v0-24-release-notes.mdx`.
- * Deliberately incomplete: `draft: true`, TODO markers for the narrative and
- * description. The prose is editorial work, only the inventory is mechanical.
- *
- * @param {object[]} notes
- * @param {{ version: string, date: string, links?: Map }} options
- * @returns {string}
- */
-export function renderBlogScaffold(notes, { version, date, links = new Map() }) {
- const slug = releaseSlug(version);
- const shortVersion = slug.replace('-', '.');
- const sections = groupNotes(notes)
- .filter((group) => group.type !== 'internal')
- .map((group) => renderBlogGroup(group, { slug, links }));
-
- const frontmatter = [
- '---',
- `title: ${shortVersion} - Release Notes`,
- 'description: TODO — one sentence naming the two or three headline changes.',
- 'featured: true',
- `pubDate: ${date}`,
- 'author: 4gray',
- `heroImage: /iptvnator/blog/${slug}/hero.jpg`,
- 'tags:',
- ' - release',
- ' - release-notes',
- ` - ${shortVersion}`,
- 'draft: true',
- '---',
- ].join('\n');
-
- const imports = [
- "import BlogImageSlider from '../../components/blog/BlogImageSlider.astro';",
- "import ReleaseMeta from '../../components/blog/ReleaseMeta.astro';",
- ].join('\n');
-
- const meta = [
- '',
- ].join('\n');
-
- return [
- frontmatter,
- imports,
- '{/* TODO: narrative intro — what this release is about, not what it contains. */}',
- meta,
- ...sections,
- '',
- ].join('\n\n');
-}
diff --git a/tools/release/release-notes.test.mjs b/tools/release/release-notes.test.mjs
index 61d30915c..86d17f8d8 100644
--- a/tools/release/release-notes.test.mjs
+++ b/tools/release/release-notes.test.mjs
@@ -10,10 +10,14 @@ import {
parseNote,
validateNote,
} from './release-notes.mjs';
+import {
+ BLOG_FALLBACK_THEME,
+ groupNotesByTheme,
+ renderBlogScaffold,
+} from './release-notes-blog.mjs';
import {
formatLongDate,
releaseSlug,
- renderBlogScaffold,
renderChangelogSection,
renderGithubBody,
upsertChangelogSection,
@@ -396,12 +400,34 @@ describe('renderChangelogSection', () => {
});
});
+describe('groupNotesByTheme', () => {
+ it('folds areas into reader-facing themes in post order, unknown areas last', () => {
+ const groups = groupNotesByTheme([
+ note({ area: 'quantum', body: 'Odd.' }),
+ note({ area: 'stalker', body: 'Stalker.' }),
+ note({ area: 'xtream', body: 'Xtream.' }),
+ note({ area: 'window-controls', body: 'Windows.' }),
+ ]);
+
+ assert.deepEqual(
+ groups.map((group) => group.heading),
+ [
+ 'Xtream',
+ 'Stalker portals',
+ 'Settings, import and the desktop app',
+ BLOG_FALLBACK_THEME,
+ ]
+ );
+ assert.equal(groups.at(-1).notes[0].body, 'Odd.');
+ });
+});
+
describe('renderBlogScaffold', () => {
+ const options = { version: '0.24.0', date: '2026-08-01' };
+ const spoilerOf = (content) => content.match(//)?.[0] ?? '';
+
it('emits a draft with TODO markers and a release meta block', () => {
- const content = renderBlogScaffold([note()], {
- version: '0.24.0',
- date: '2026-08-01',
- });
+ const content = renderBlogScaffold([note()], options);
assert.match(content, /^---\ntitle: v0\.24 - Release Notes/);
assert.match(content, /draft: true/);
@@ -409,13 +435,66 @@ describe('renderBlogScaffold', () => {
assert.match(content, /releaseDate="August 1, 2026"/);
});
- it('gives screenshot notes their own section with a dark/light slider', () => {
+ it('opens with a What changed table holding one row per highlight', () => {
const content = renderBlogScaffold(
- [note({ screenshot: 'up-next-rail' })],
- { version: '0.24.0', date: '2026-08-01' }
+ [
+ note({ highlight: 'Up Next rail' }),
+ note({
+ area: 'stalker',
+ highlight: 'Portal login',
+ body: "Portals that ask for a login work; the app's do_auth step completes.",
+ sourcePath: '.changes/stalker-login.md',
+ }),
+ note({ type: 'fix', body: 'A plain fix.', sourcePath: '.changes/playback-fix.md' }),
+ ],
+ options
);
- assert.match(content, /### TODO headline \(playback\)/);
+ assert.match(content, /## What changed\n\n {
+ const content = renderBlogScaffold([note()], options);
+
+ assert.doesNotMatch(content, /## What changed/);
+ assert.doesNotMatch(content, /import ChangeTable/);
+ });
+
+ it('puts highlight sections first, as h2 with a status pill, ahead of the themed sections', () => {
+ const content = renderBlogScaffold(
+ [
+ note({ body: 'Plain feature.' }),
+ note({ highlight: 'Up Next rail', sourcePath: '.changes/playback-rail.md' }),
+ ],
+ options
+ );
+
+ assert.match(
+ content,
+ /## Up Next rail\n\n\n\nSeries now show an Up Next rail/
+ );
+ assert.ok(content.indexOf('## Up Next rail') < content.indexOf('## Playback'));
+ assert.doesNotMatch(content, /^### /m);
+ });
+
+ it('gives screenshot notes their own section with a dark/light slider and the screenshot alert', () => {
+ const content = renderBlogScaffold(
+ [note({ screenshot: 'up-next-rail' })],
+ options
+ );
+
+ assert.match(content, /## TODO headline \(playback\)/);
assert.match(
content,
/\/iptvnator\/blog\/v0-24\/screenshots\/up-next-rail-dark\.png/
@@ -424,34 +503,149 @@ describe('renderBlogScaffold', () => {
content,
/\/iptvnator\/blog\/v0-24\/screenshots\/up-next-rail-light\.png/
);
- });
-
- it('uses the highlight as the section headline instead of a TODO', () => {
- const content = renderBlogScaffold(
- [note({ highlight: 'Up Next rail', screenshot: 'up-next-rail' })],
- { version: '0.24.0', date: '2026-08-01' }
- );
-
- assert.match(content, /### Up Next rail/);
- assert.doesNotMatch(content, /### TODO headline/);
+ assert.match(content, //);
+ assert.match(content, /import BlogImageSlider from/);
});
it('gives a highlight note without a screenshot its own section, no slider', () => {
const content = renderBlogScaffold(
[note({ highlight: 'Up Next rail' })],
- { version: '0.24.0', date: '2026-08-01' }
+ options
);
- assert.match(content, /### Up Next rail/);
- assert.doesNotMatch(content, /BlogImageSlider\n {4}images/);
+ assert.match(content, /## Up Next rail/);
assert.doesNotMatch(content, / {
+ const content = renderBlogScaffold(
+ [
+ note({ area: 'stalker', body: 'Stalker thing.', sourcePath: '.changes/stalker-a.md' }),
+ note({ area: 'xtream', body: 'Xtream thing.', sourcePath: '.changes/xtream-a.md' }),
+ note({ area: 'quantum', body: 'Odd thing.', sourcePath: '.changes/quantum-a.md' }),
+ ],
+ options
+ );
+
+ assert.match(content, /## Xtream\n\n\n\n- Xtream thing\./);
+ assert.match(content, /## Stalker portals\n\n\n\n- Stalker thing\./);
+ assert.match(content, /## Other changes\n\n\n\n- Odd thing\./);
+ assert.doesNotMatch(content, /\*\*stalker\*\* —/);
+ assert.ok(content.indexOf('## Xtream') < content.indexOf('## Stalker portals'));
+ assert.ok(content.indexOf('## Stalker portals') < content.indexOf('## Other changes'));
+ });
+
+ it('collapses fixes without a headline under a spoiler grouped by theme', () => {
+ const content = renderBlogScaffold(
+ [
+ note({ type: 'fix', area: 'search', body: 'Search fix.', sourcePath: '.changes/search-a.md' }),
+ note({ type: 'fix', area: 'stalker', body: 'Stalker fix.', sourcePath: '.changes/stalker-b.md' }),
+ note({ type: 'fix', body: 'Player fix.', highlight: 'Big fix', sourcePath: '.changes/playback-b.md' }),
+ ],
+ options
+ );
+ const spoiler = spoilerOf(content);
+
+ assert.match(content, /## Everything else\n\nThat is the part worth reading in one sitting\. The remaining 2 fixes are below/);
+ assert.match(spoiler, /^\n\n\*\*Stalker portals\*\*\n\n- Stalker fix\./);
+ assert.match(spoiler, /\*\*Search\*\*\n\n- Search fix\./);
+ assert.match(spoiler, /\n\n<\/Spoiler>$/);
+ assert.match(content, /import Spoiler from/);
+ // A highlighted fix is a section of its own, never a spoiler line.
+ assert.match(content, /## Big fix\n\n\n\nPlayer fix\./);
+ assert.doesNotMatch(spoiler, /Player fix/);
+ });
+
+ it('phrases a single remaining fix in the singular and skips the spoiler without fixes', () => {
+ const single = renderBlogScaffold([note({ type: 'fix' })], options);
+ const none = renderBlogScaffold([note()], options);
+
+ assert.match(single, /The remaining 1 fix is below/);
+ assert.doesNotMatch(none, /## Everything else/);
+ assert.doesNotMatch(none, /import Spoiler/);
+ });
+
+ it('keeps breaking changes out of the spoiler and ahead of the themed sections', () => {
+ const content = renderBlogScaffold(
+ [
+ note({ body: 'Feature.' }),
+ note({ type: 'breaking', area: 'settings', body: 'Breaking.', sourcePath: '.changes/settings-b.md' }),
+ note({ type: 'fix', area: 'settings', body: 'Fix.', sourcePath: '.changes/settings-f.md' }),
+ ],
+ options
+ );
+
+ assert.match(content, /## Breaking changes\n\n\n\n- Breaking\./);
+ assert.ok(content.indexOf('## Breaking changes') < content.indexOf('## Playback'));
+ assert.doesNotMatch(spoilerOf(content), /Breaking\./);
+ });
+
+ it('renders performance notes in their own section after the themes', () => {
+ const content = renderBlogScaffold(
+ [note({ body: 'Feature.' }), note({ type: 'perf', body: 'Perf.', sourcePath: '.changes/playback-p.md' })],
+ options
+ );
+
+ assert.match(content, /## Performance\n\n\n\n- Perf\./);
+ assert.ok(content.indexOf('## Playback') < content.indexOf('## Performance'));
+ });
+
+ it('orders the post: table, highlights, breaking, themes, performance, spoiler, closing', () => {
+ const content = renderBlogScaffold(
+ [
+ note({ highlight: 'Headline', sourcePath: '.changes/a.md' }),
+ note({ type: 'breaking', body: 'Breaking.', sourcePath: '.changes/b.md' }),
+ note({ body: 'Feature.', sourcePath: '.changes/c.md' }),
+ note({ type: 'perf', body: 'Perf.', sourcePath: '.changes/d.md' }),
+ note({ type: 'fix', body: 'Fix.', sourcePath: '.changes/e.md' }),
+ ],
+ options
+ );
+ const order = [
+ '## What changed',
+ '## Headline',
+ '## Breaking changes',
+ '## Playback',
+ '## Performance',
+ '## Everything else',
+ 'title="Before updating"',
+ '## Thanks',
+ '## Download',
+ ].map((marker) => content.indexOf(marker));
+
+ assert.ok(order.every((index) => index >= 0), `missing marker in ${order}`);
+ assert.deepEqual(order, [...order].sort((a, b) => a - b));
+ });
+
+ it('closes with the before-updating alert, thanks and download cards', () => {
+ const content = renderBlogScaffold([note()], options);
+
+ assert.match(content, /\nPlease back up/);
+ assert.match(content, /## Thanks\n\n\{\/\* TODO/);
+ assert.match(content, /## Download\n\n {
+ const content = renderBlogScaffold([note()], {
+ ...options,
+ previousVersion: '0.23.0',
+ });
+
+ assert.match(content, /label: 'Full Changelog'/);
+ assert.match(content, /compare\/v0\.23\.0\.\.\.v0\.24\.0/);
+ assert.match(content, /hint: 'Every commit between v0\.23\.0 and v0\.24\.0\.'/);
});
it('truncates a long body for image alt text without cutting mid-word', () => {
const body = `${'Series show the rest of the season beside the player '.repeat(4)}now.`;
const content = renderBlogScaffold(
[note({ body, screenshot: 'up-next-rail' })],
- { version: '0.24.0', date: '2026-08-01' }
+ options
);
const alt = content.match(/alt: '([^']*)'/)[1];
@@ -468,7 +662,7 @@ describe('renderBlogScaffold', () => {
screenshot: 'windows-import',
}),
],
- { version: '0.24.0', date: '2026-08-01' }
+ options
);
const alt = content.match(/alt: '(.*)',/)[1];
@@ -478,10 +672,24 @@ describe('renderBlogScaffold', () => {
assert.doesNotMatch(alt, /(^|[^\\])(\\\\)*\\$/);
});
+ it('escapes backslashes and apostrophes inside table cells', () => {
+ const content = renderBlogScaffold(
+ [
+ note({
+ highlight: 'Windows import',
+ body: "Windows paths like C:\\Users no longer break the app's import.",
+ }),
+ ],
+ options
+ );
+
+ assert.match(content, /change: 'Windows paths like C:\\\\Users no longer break the app\\'s import\.'/);
+ });
+
it('escapes characters MDX would parse as markup', () => {
const content = renderBlogScaffold(
[note({ body: 'Channels named and {vod} now sort correctly.' })],
- { version: '0.24.0', date: '2026-08-01' }
+ options
);
assert.doesNotMatch(content, //);
@@ -490,14 +698,25 @@ describe('renderBlogScaffold', () => {
assert.match(content, /{vod}/);
});
- it('imports only the components it emits', () => {
- const content = renderBlogScaffold([note()], {
- version: '0.24.0',
- date: '2026-08-01',
- });
+ it('imports only the components it emits, alphabetically', () => {
+ const content = renderBlogScaffold([note()], options);
+ const imports = content.match(/^import \w+ from/gm);
- assert.match(content, /import ReleaseMeta from/);
- assert.match(content, /import BlogImageSlider from/);
+ assert.deepEqual(imports, [
+ 'import Alert from',
+ 'import LinkCards from',
+ 'import ReleaseMeta from',
+ 'import StatusPill from',
+ ]);
+ });
+
+ it('omits internal notes entirely', () => {
+ const content = renderBlogScaffold(
+ [note(), note({ type: 'internal', body: 'Internal plumbing.', sourcePath: '.changes/deps-x.md' })],
+ options
+ );
+
+ assert.doesNotMatch(content, /Internal plumbing/);
});
});