mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
1 parent
fa9084fca3
commit
81ce8e8c90
8 files changed
+693
-161
No files matched your search
+4
-2
@@ -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`.
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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, '<')
|
||||
.replace(/>/g, '>')
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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, /{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/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user