feat(release): scaffold the blog post in its published shape

`release:notes:blog` used to emit the notes as a type-grouped inventory with
area prefixes and the highlight sections buried after the feature list; the
v0.23 post shipped in exactly that form and had to be restructured after
publication. The scaffold now starts from the shape the posts end up in:
a "What changed" table with one row per highlight, one `##` section per
highlight ahead of everything else, breaking changes on their own, the
remaining features folded into reader-facing themed sections instead of
conventional-commit scopes, Performance, every remaining fix under a Spoiler
grouped by theme, and the before-updating alert, Thanks and Download cards
(including the compare link to the previous version). Only the components a
post uses are imported.

The blog renderer moves to `release-notes-blog.mjs`; `release-notes-render.mjs`
keeps the GitHub/CHANGELOG renderers and exports the shared text helpers.
Editorial work stays editorial and is marked with TODOs: which fixes deserve
promotion out of the spoiler, one-line bullets, lead-ins, intro and thanks.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-03 20:57:15 +02:00
1 parent fa9084fca3
commit 81ce8e8c90
8 files changed
+693 -161

No files matched your search

+4 -2
View File
@@ -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`.
+1 -1
View File
@@ -32,7 +32,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- Name it `<area>-<short-slug>.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: <short headline>` (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: <short headline>` (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`.
+30 -2
View File
@@ -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 (<area>)`.
- **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 (<area>)`. 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;
+2 -1
View File
@@ -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);
+1
View File
@@ -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",
+397
View File
@@ -0,0 +1,397 @@
/**
* Website blog scaffold for
* `apps/website/src/content/blog/<vX-Y>-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 = [
'<Alert type="info" title="About the screenshots">',
"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.",
'</Alert>',
].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 `<StatusPill type="${STATUS_PILL_BY_TYPE[type]}" />`;
}
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 `<BlogImageSlider\n images={[\n${images}\n ]}\n/>`;
}
/**
* 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<ChangeTable\n rows={[\n${rows}\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}`,
`<StatusPill type="${pill}" />`,
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. */}`,
`<Spoiler title="Show the remaining fixes">\n\n${groups}\n\n</Spoiler>`,
].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 `<LinkCards\n links={[\n${links}\n ]}\n/>`;
}
function renderClosing(version, previousVersion) {
return [
'<Alert type="warning" title="Before updating">\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</Alert>',
'## 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<string, object> }} 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 = [
'<ReleaseMeta',
` version="v${version}"`,
` releaseDate="${formatLongDate(date)}"`,
" channels={['Desktop', 'PWA']}",
'/>',
].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');
}
+7 -123
View File
@@ -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 `<live>` renders as written instead of half-escaped.
*/
function escapeMdx(text) {
export function escapeMdx(text) {
return text
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
@@ -83,7 +83,7 @@ function escapeMdx(text) {
* @param {Map<string, { pr?: number, commit?: string }>} 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(`<BlogImageSlider\n images={[\n${images}\n ]}\n/>`);
}
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<string, object> }} 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 = [
'<ReleaseMeta',
` version="v${version}"`,
` releaseDate="${formatLongDate(date)}"`,
" channels={['Desktop', 'PWA']}",
'/>',
].join('\n');
return [
frontmatter,
imports,
'{/* TODO: narrative intro — what this release is about, not what it contains. */}',
meta,
...sections,
'',
].join('\n\n');
}
+251 -32
View File
@@ -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(/<Spoiler[\s\S]*<\/Spoiler>/)?.[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<ChangeTable\n {4}rows=\{\[/);
assert.match(
content,
/area: 'Playback',\n\s+change: 'Series now show an Up Next rail beside the player\.',\n\s+impact: 'TODO/
);
// The theme is the default area label; apostrophes are JS-escaped.
assert.match(
content,
/area: 'Stalker portals',\n\s+change: 'Portals that ask for a login work; the app\\'s do_auth step completes\.'/
);
assert.equal((content.match(/^\s+change: /gm) ?? []).length, 2);
assert.match(content, /import ChangeTable from/);
});
it('omits the table and its import when nothing is highlighted', () => {
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<StatusPill type="new" \/>\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, /<Alert type="info" title="About the screenshots">/);
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, /<BlogImageSlider/);
assert.doesNotMatch(content, /import BlogImageSlider/);
assert.doesNotMatch(content, /About the screenshots/);
});
it('folds the remaining features into themed sections without the area prefix', () => {
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<StatusPill type="improved" \/>\n\n- Xtream thing\./);
assert.match(content, /## Stalker portals\n\n<StatusPill type="improved" \/>\n\n- Stalker thing\./);
assert.match(content, /## Other changes\n\n<StatusPill type="improved" \/>\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, /^<Spoiler title="Show the remaining fixes">\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<StatusPill type="fixed" \/>\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<StatusPill type="breaking" \/>\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<StatusPill type="improved" \/>\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, /<Alert type="warning" title="Before updating">\nPlease back up/);
assert.match(content, /## Thanks\n\n\{\/\* TODO/);
assert.match(content, /## Download\n\n<LinkCards/);
assert.match(content, /label: 'Download v0\.24\.0',\n\s+href: 'https:\/\/github\.com\/4gray\/iptvnator\/releases\/tag\/v0\.24\.0'/);
assert.match(content, /label: 'All Releases'/);
assert.doesNotMatch(content, /Full Changelog/);
});
it('adds the compare card only when the previous version is known', () => {
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 <live> and {vod} now sort correctly.' })],
{ version: '0.24.0', date: '2026-08-01' }
options
);
assert.doesNotMatch(content, /<live>/);
@@ -490,14 +698,25 @@ describe('renderBlogScaffold', () => {
assert.match(content, /&#123;vod&#125;/);
});
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/);
});
});