mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 06:06:18 -08:00
Extends the release tooling around the .changes notes pipeline: - New optional `highlight:` frontmatter field marks a release's headline changes (max 80 chars, rejected on `type: internal`). - `--format telegram` renders a paste-ready plain-text post guaranteed to fit the 4096-character limit (highlights lead, the rest collapses into a counter); `--format reddit` renders markdown with a suggested title line, a Highlights section, and a collapsed change list. Both are stdout dry runs and must run before `--consume`. - The blog scaffold uses the highlight as a ready section heading instead of a TODO, and highlight notes without screenshots get their own section. - `release:verify:draft` waits for the v* tag build via `gh run watch`, then checks draft status, the authored body, and the complete 27-asset set (verified against a real full matrix build). Read-only; publishing stays manual. - `release:cards:generate` renders branded 1200x630 highlight cards (with a framed mock-server screenshot strip when the note names one, purely typographic otherwise) plus a release hero card into dist/, matching the website palette. Copying into the website tree stays a manual act. Docs: .changes/README.md, release-notes and release-cut skills (both copies byte-identical). No release note: release tooling only, no runtime behavior change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
297 lines
8.5 KiB
JavaScript
297 lines
8.5 KiB
JavaScript
import assert from 'node:assert/strict';
|
||
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
|
||
import { tmpdir } from 'node:os';
|
||
import path from 'node:path';
|
||
import { after, describe, it } from 'node:test';
|
||
import sharp from 'sharp';
|
||
|
||
import {
|
||
buildFeatureCardSvg,
|
||
buildHeroCardSvg,
|
||
CARD_HEIGHT,
|
||
CARD_WIDTH,
|
||
escapeXml,
|
||
planHighlightCards,
|
||
wrapText,
|
||
} from './highlight-cards.mjs';
|
||
import {
|
||
parseCardArguments,
|
||
renderCards,
|
||
} from './generate-highlight-cards.mjs';
|
||
|
||
const tempDirs = [];
|
||
|
||
function makeTempDir() {
|
||
const directory = mkdtempSync(path.join(tmpdir(), 'highlight-cards-'));
|
||
|
||
tempDirs.push(directory);
|
||
|
||
return directory;
|
||
}
|
||
|
||
function note(overrides = {}) {
|
||
return {
|
||
type: 'feature',
|
||
area: 'playback',
|
||
issues: [],
|
||
screenshot: null,
|
||
highlight: null,
|
||
unknownKeys: [],
|
||
body: 'Series now show an Up Next rail beside the player.',
|
||
sourcePath: '.changes/playback-up-next.md',
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
const planOptions = {
|
||
version: '0.24.0',
|
||
releaseSlug: 'v0-24',
|
||
screenshotsDir: '/blog/v0-24/screenshots',
|
||
theme: 'dark',
|
||
};
|
||
|
||
after(() => {
|
||
for (const directory of tempDirs) {
|
||
rmSync(directory, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
describe('wrapText', () => {
|
||
it('wraps on word boundaries within the budget', () => {
|
||
assert.deepEqual(wrapText('one two three four', 9, 3), [
|
||
'one two',
|
||
'three',
|
||
'four',
|
||
]);
|
||
});
|
||
|
||
it('gives an overlong single word its own line instead of cutting it', () => {
|
||
assert.deepEqual(wrapText('supercalifragilistic ok', 10, 3), [
|
||
'supercalifragilistic',
|
||
'ok',
|
||
]);
|
||
});
|
||
|
||
it('ellipsizes the last kept line on overflow', () => {
|
||
const lines = wrapText('aaa bbb ccc ddd eee', 3, 2);
|
||
|
||
assert.equal(lines.length, 2);
|
||
assert.match(lines[1], /…$/);
|
||
});
|
||
});
|
||
|
||
describe('escapeXml', () => {
|
||
it('escapes markup and quote characters', () => {
|
||
assert.equal(
|
||
escapeXml(`<a> & "b" 'c'`),
|
||
'<a> & "b" 'c''
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('planHighlightCards', () => {
|
||
it('plans one card per highlight in group order plus a hero', () => {
|
||
const plan = planHighlightCards(
|
||
[
|
||
note({ type: 'fix', body: 'Not highlighted.' }),
|
||
note({
|
||
highlight: 'Up Next rail',
|
||
screenshot: 'up-next-rail',
|
||
}),
|
||
note({
|
||
type: 'breaking',
|
||
highlight: 'New settings',
|
||
sourcePath: '.changes/settings-rework.md',
|
||
}),
|
||
],
|
||
planOptions
|
||
);
|
||
|
||
assert.deepEqual(
|
||
plan.feature.map((job) => job.fileName),
|
||
['card-settings-rework.png', 'card-up-next-rail.png']
|
||
);
|
||
assert.equal(
|
||
plan.feature[1].screenshotPath,
|
||
path.join('/blog/v0-24/screenshots', 'up-next-rail-dark.png')
|
||
);
|
||
assert.equal(plan.feature[0].screenshotPath, null);
|
||
assert.deepEqual(plan.hero.headlines, [
|
||
'New settings',
|
||
'Up Next rail',
|
||
]);
|
||
assert.equal(plan.hero.counts, '1 breaking change · 1 feature · 1 fix');
|
||
});
|
||
|
||
it('never plans a card for internal notes', () => {
|
||
const plan = planHighlightCards(
|
||
[note({ type: 'internal', highlight: 'Nope' })],
|
||
planOptions
|
||
);
|
||
|
||
assert.deepEqual(plan.feature, []);
|
||
assert.deepEqual(plan.hero.headlines, []);
|
||
});
|
||
|
||
it('respects the requested screenshot theme', () => {
|
||
const plan = planHighlightCards(
|
||
[note({ highlight: 'Up Next rail', screenshot: 'up-next-rail' })],
|
||
{ ...planOptions, theme: 'light' }
|
||
);
|
||
|
||
assert.match(plan.feature[0].screenshotPath, /-light\.png$/);
|
||
});
|
||
});
|
||
|
||
describe('card SVGs', () => {
|
||
it('escapes user text and carries the version chip', () => {
|
||
const svg = buildFeatureCardSvg(
|
||
{
|
||
slug: 'x',
|
||
fileName: 'card-x.png',
|
||
headline: 'Support <live> & "vod"',
|
||
body: "The app's import.",
|
||
screenshotPath: null,
|
||
},
|
||
'0.24.0'
|
||
);
|
||
|
||
assert.match(svg, /Support <live> & "vod"/);
|
||
assert.match(svg, /v0\.24\.0/);
|
||
assert.doesNotMatch(svg, /<live>/);
|
||
});
|
||
|
||
it('draws the screenshot frame only when a screenshot exists', () => {
|
||
const withShot = buildFeatureCardSvg(
|
||
{
|
||
headline: 'H',
|
||
body: 'B',
|
||
screenshotPath: '/shots/x-dark.png',
|
||
},
|
||
'0.24.0'
|
||
);
|
||
const without = buildFeatureCardSvg(
|
||
{ headline: 'H', body: 'B', screenshotPath: null },
|
||
'0.24.0'
|
||
);
|
||
|
||
// The frame rect is the only element using the surface frame color.
|
||
assert.match(withShot, /fill="#2e2e28"/);
|
||
assert.doesNotMatch(without, /fill="#2e2e28"/);
|
||
});
|
||
|
||
it('lists at most four highlights on the hero and counts the rest', () => {
|
||
const svg = buildHeroCardSvg({
|
||
fileName: 'hero.png',
|
||
version: '0.24.0',
|
||
releaseSlug: 'v0-24',
|
||
headlines: ['One', 'Two', 'Three', 'Four', 'Five'],
|
||
counts: '5 features',
|
||
});
|
||
|
||
assert.match(svg, />Four</);
|
||
assert.doesNotMatch(svg, />Five</);
|
||
assert.match(svg, /…and 1 more/);
|
||
assert.match(svg, /5 features/);
|
||
});
|
||
});
|
||
|
||
describe('parseCardArguments', () => {
|
||
it('requires a mode and defaults the theme', () => {
|
||
assert.equal(parseCardArguments([]), null);
|
||
assert.deepEqual(parseCardArguments(['--dry-run']), {
|
||
mode: 'dry-run',
|
||
theme: 'dark',
|
||
version: null,
|
||
dir: '.changes',
|
||
out: null,
|
||
});
|
||
});
|
||
|
||
it('parses the full flag set', () => {
|
||
assert.deepEqual(
|
||
parseCardArguments([
|
||
'--generate',
|
||
'--theme',
|
||
'light',
|
||
'--version',
|
||
'0.24.0',
|
||
'--dir',
|
||
'notes',
|
||
'--out',
|
||
'cards',
|
||
]),
|
||
{
|
||
mode: 'generate',
|
||
theme: 'light',
|
||
version: '0.24.0',
|
||
dir: 'notes',
|
||
out: 'cards',
|
||
}
|
||
);
|
||
});
|
||
|
||
it('rejects bad usage', () => {
|
||
for (const args of [
|
||
['--dry-run', '--generate'],
|
||
['--generate', '--theme', 'sepia'],
|
||
['--generate', '--version', '0.24'],
|
||
['--generate', '--nope'],
|
||
['--generate', '--out'],
|
||
]) {
|
||
assert.equal(parseCardArguments(args), null, args.join(' '));
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('renderCards', () => {
|
||
it('renders 1200×630 PNGs plus the hero pair from a real screenshot', async () => {
|
||
const shotsDir = makeTempDir();
|
||
const outputDir = path.join(makeTempDir(), 'cards');
|
||
const screenshotPath = path.join(shotsDir, 'up-next-rail-dark.png');
|
||
|
||
await sharp({
|
||
create: {
|
||
width: 1280,
|
||
height: 720,
|
||
channels: 3,
|
||
background: { r: 20, g: 20, b: 24 },
|
||
},
|
||
})
|
||
.png()
|
||
.toFile(screenshotPath);
|
||
|
||
const plan = planHighlightCards(
|
||
[
|
||
note({ highlight: 'Up Next rail', screenshot: 'up-next-rail' }),
|
||
note({
|
||
type: 'perf',
|
||
highlight: 'Faster imports',
|
||
body: 'Playlists import faster.',
|
||
sourcePath: '.changes/m3u-faster-imports.md',
|
||
}),
|
||
],
|
||
{ ...planOptions, screenshotsDir: shotsDir }
|
||
);
|
||
const written = await renderCards(plan, '0.24.0', outputDir);
|
||
|
||
assert.deepEqual(
|
||
written.map((file) => path.basename(file)),
|
||
[
|
||
'card-up-next-rail.png',
|
||
'card-m3u-faster-imports.png',
|
||
'hero.png',
|
||
'hero.jpg',
|
||
]
|
||
);
|
||
|
||
for (const file of written) {
|
||
assert.ok(existsSync(file), file);
|
||
const metadata = await sharp(file).metadata();
|
||
|
||
assert.equal(metadata.width, CARD_WIDTH, file);
|
||
assert.equal(metadata.height, CARD_HEIGHT, file);
|
||
}
|
||
});
|
||
});
|