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/); }); });