Files
iptvnator/tools/release/generate-highlight-cards.mjs
T

384 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* Renders release highlight cards from `highlight:` notes: one 1200×630
* card per highlight (branded background, headline, body, framed screenshot
* strip when the note names one) plus a release hero card.
*
* node tools/release/generate-highlight-cards.mjs --dry-run
* node tools/release/generate-highlight-cards.mjs --generate
* node tools/release/generate-highlight-cards.mjs --generate --theme light
*
* Must run BEFORE `--consume` (the highlight metadata lives only in the note
* files) and after `release:screenshots` (screenshot strips are read from the
* published blog directory). Output goes to dist/, outside version control —
* committing a card into the website tree is a deliberate manual act.
*/
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
realpathSync,
rmSync,
} from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import sharp from 'sharp';
import {
buildFeatureCardSvg,
buildHeroCardSvg,
buildShotMaskSvg,
CARD_HEIGHT,
isOwnedCardFile,
SHOT_LEFT,
SHOT_TOP,
SHOT_WIDTH,
planHighlightCards,
} from './highlight-cards.mjs';
import { loadNotes } from './release-notes.mjs';
import { releaseSlug } from './release-notes-render.mjs';
const workspaceRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'../..'
);
const CLI_USAGE =
'Usage: generate-highlight-cards.mjs (--dry-run | --generate) [--theme dark|light] [--version <semver>] [--dir <notes-dir>] [--out <dir>]';
export function parseCardArguments(args) {
const options = {
mode: null,
theme: 'dark',
version: null,
dir: '.changes',
out: null,
};
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
const takeValue = () => {
const value = args[index + 1];
if (!value || value.startsWith('--')) {
return null;
}
index += 1;
return value;
};
if (arg === '--dry-run' || arg === '--generate') {
if (options.mode) {
return null;
}
options.mode = arg.slice(2);
} else if (arg === '--theme') {
const value = takeValue();
if (value !== 'dark' && value !== 'light') {
return null;
}
options.theme = value;
} else if (arg === '--version') {
const value = takeValue();
if (!value || !/^\d+\.\d+\.\d+$/.test(value)) {
return null;
}
options.version = value;
} else if (arg === '--dir' || arg === '--out') {
const value = takeValue();
if (!value) {
return null;
}
options[arg.slice(2)] = value;
} else if (arg !== '--') {
return null;
}
}
return options.mode ? options : null;
}
/**
* Composites the bottom screenshot strip: resize to the strip width, crop to
* the visible height, round the corners via a dest-in mask.
*
* @param {string} screenshotPath
* @returns {Promise<{ input: Buffer, left: number, top: number }>}
*/
async function prepareShotOverlay(screenshotPath) {
const stripHeight = CARD_HEIGHT - SHOT_TOP;
const resized = sharp(screenshotPath).resize({ width: SHOT_WIDTH });
const metadata = await resized.png().toBuffer({ resolveWithObject: true });
const visibleHeight = Math.min(stripHeight, metadata.info.height);
const cropped = await sharp(metadata.data)
.extract({
left: 0,
top: 0,
width: SHOT_WIDTH,
height: visibleHeight,
})
.composite([
{
input: Buffer.from(buildShotMaskSvg(SHOT_WIDTH, visibleHeight)),
blend: 'dest-in',
},
])
.png()
.toBuffer();
return { input: cropped, left: SHOT_LEFT, top: SHOT_TOP };
}
/**
* Repo-relative when the path is inside the workspace, absolute otherwise:
* `--out /tmp/cards` printed as a stack of `../../..` segments is worse than
* no shortening at all.
*
* @param {string} target
* @returns {string}
*/
function displayPath(target) {
const relative = path.relative(workspaceRoot, target);
return relative.startsWith('..') ? target : relative;
}
/**
* Removes the cards a previous run of THIS generator left in `outputDir`, so a
* renamed, dropped, or newly-internal highlight cannot leave a stale image
* sitting there waiting to be published. Only files matching what this tool
* writes are touched; anything else in the directory is left alone.
*
* @param {string} outputDir
* @returns {string[]} removed filenames
*/
export function removeStaleCards(outputDir) {
if (!existsSync(outputDir)) {
return [];
}
const stale = readdirSync(outputDir).filter(isOwnedCardFile);
for (const entry of stale) {
rmSync(path.join(outputDir, entry));
}
return stale;
}
/**
* @param {{ feature: object[], hero: object }} plan
* @param {string} version
* @param {string} outputDir
* @returns {Promise<string[]>} written file paths
*/
export async function renderCards(plan, version, outputDir) {
mkdirSync(outputDir, { recursive: true });
removeStaleCards(outputDir);
const written = [];
for (const job of plan.feature) {
const base = sharp(Buffer.from(buildFeatureCardSvg(job, version)));
const composites = job.screenshotPath
? [await prepareShotOverlay(job.screenshotPath)]
: [];
const target = path.join(outputDir, job.fileName);
await base.composite(composites).png().toFile(target);
written.push(target);
}
const heroSvg = Buffer.from(buildHeroCardSvg(plan.hero));
const heroPng = path.join(outputDir, plan.hero.fileName);
const heroJpg = path.join(outputDir, 'hero.jpg');
await sharp(heroSvg).png().toFile(heroPng);
// The blog scaffold's frontmatter references `hero.jpg`.
await sharp(heroSvg).flatten().jpeg({ quality: 92 }).toFile(heroJpg);
written.push(heroPng, heroJpg);
return written;
}
async function main() {
const options = parseCardArguments(process.argv.slice(2));
if (options === null) {
console.error(CLI_USAGE);
process.exit(2);
}
if (options.version === null) {
options.version = JSON.parse(
readFileSync(path.join(workspaceRoot, 'package.json'), 'utf8')
).version;
console.error(`Using version ${options.version} from package.json.`);
}
const notesDir = path.resolve(workspaceRoot, options.dir);
const { notes, errors } = loadNotes(notesDir);
if (errors.length > 0) {
console.error('Invalid release notes:\n');
for (const error of errors) {
console.error(` ${error}`);
}
process.exit(1);
}
// An empty directory is not an internal-only release: it is almost always
// this step running AFTER `--consume` deleted the notes, which is the one
// ordering mistake the whole pipeline warns about. Reporting it as
// "internal-only" would hide the mistake and quietly ship no cards at all.
if (notes.length === 0) {
console.error(
[
`No notes found in ${displayPath(notesDir)}/.`,
'Cards are built from `highlight:`, which exists only in the note',
'files — if `build-release-notes.mjs --consume` already ran, that',
'metadata is gone. Render cards before consuming.',
].join(' ')
);
process.exit(1);
}
const slug = releaseSlug(options.version);
const screenshotsDir = path.join(
workspaceRoot,
'apps/website/public/blog',
slug,
'screenshots'
);
const plan = planHighlightCards(notes, {
version: options.version,
releaseSlug: slug,
screenshotsDir,
theme: options.theme,
});
// Keyed by the exact version, not the minor slug: 0.24.0 and 0.24.1 share
// a blog post but not a card set, and mixing them in one directory invites
// publishing the previous patch's card.
const outputDir = path.resolve(
workspaceRoot,
options.out ??
path.join('dist/release-highlight-cards', `v${options.version}`)
);
// Neither shape below is an error: an internal-only release is legal, and
// a release nobody marked a highlight on still deserves its hero card.
// Failing here would break the documented release sequence.
if (plan.publicNoteCount === 0) {
console.error(
'Internal-only release: no public change to put on a card.'
);
// A release that turned internal-only after cards were already made
// for this exact version must not leave them behind. `--dry-run`
// deletes nothing.
if (options.mode === 'generate') {
const removed = removeStaleCards(outputDir);
if (removed.length > 0) {
console.log(
`Removed ${removed.length} card(s) left by an earlier run of v${options.version}.`
);
}
}
return;
}
if (plan.feature.length === 0) {
console.error(
'No `highlight:` notes found — rendering the hero card only. Mark the headline changes in .changes/ to get feature cards.'
);
}
const missingShots = plan.feature.filter(
(job) => job.screenshotPath && !existsSync(job.screenshotPath)
);
if (missingShots.length > 0) {
console.error('Missing screenshot(s) for highlight card(s):\n');
for (const job of missingShots) {
console.error(
` ${displayPath(job.screenshotPath)}`
);
}
console.error('\nRun `pnpm run release:screenshots` first.');
process.exit(1);
}
console.log(`${plan.feature.length} highlight card(s) + hero for v${options.version}:`);
for (const job of plan.feature) {
const shot = job.screenshotPath
? displayPath(job.screenshotPath)
: 'no screenshot (typographic card)';
console.log(` ${job.fileName} — "${job.headline}" (${shot})`);
}
if (options.mode === 'dry-run') {
console.log(`\nWould write to ${displayPath(outputDir)}/.`);
return;
}
const stale = existsSync(outputDir)
? readdirSync(outputDir).filter(isOwnedCardFile)
: [];
if (stale.length > 0) {
console.log(
`Replacing ${stale.length} card(s) from a previous run in the same directory.`
);
}
const written = await renderCards(plan, options.version, outputDir);
for (const file of written) {
console.log(`Wrote ${displayPath(file)}`);
}
console.log(
`\nReview the images, then copy what you publish (e.g. hero.jpg → apps/website/public/blog/${slug}/hero.jpg).`
);
}
// realpath on both sides: Node resolves symlinks for `import.meta.url` but
// not for argv[1], so reaching this script through a symlinked path would
// otherwise exit 0 having rendered nothing.
const isDirectRun = (() => {
if (!process.argv[1]) {
return false;
}
try {
return (
realpathSync(process.argv[1]) ===
realpathSync(fileURLToPath(import.meta.url))
);
} catch {
return false;
}
})();
if (isDirectRun) {
main().catch((error) => {
console.error(error.message);
process.exit(1);
});
}