mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 01:56:16 -08:00
Seven defects found by an adversarial pass over the new tooling, each with a regression test: - `highlight:` was silently truncated at the first ` #`. The comment strip was written when every field was a slug, enum or number; it now applies only to those closed-vocabulary keys. `highlight: Sources #N chip` kept parsing as "Sources" and shipped that to all four surfaces. - Quote stripping removed a leading or trailing quote independently, so `highlight: "Up Next" rail` lost its opening quote. Both ends must now carry the same quote character. - A whitespace-only quoted value passed validation and then crashed the card generator mid-run, after PNGs were already written. Values are trimmed after unquoting, so it fails validation instead; the hero card also no longer indexes wrapText's empty result blindly. - Feature cards painted the opaque screenshot frame over the last body line whenever the headline wrapped to two lines. The body's line budget is now derived from the space actually left above the frame. - Un-highlighted breaking changes were folded into "…plus N more fixes and improvements" on Telegram and never shown. They are always spelled out. - Both CLI guards compared a non-realpath'd argv[1] against a realpath'd import.meta.url, so reaching either script through a symlinked path (on macOS, anything under /tmp or /var) made it a silent exit-0 no-op — which for the publication gate reads exactly like a pass. - spawnSync reports a missing gh binary and a signalled child as `status: null`, both of which were blamed on the build. The highlight cap drops from 80 to 60 characters: 60 is the hero card's single-line budget, so a valid highlight now always renders in full rather than being silently ellipsized. Docs: new docs/architecture/release-pipeline.md carries the full contract — surfaces, the ordering constraint around --consume, highlight semantics, card layout rules, and the 27-asset draft verification table. release-cut now references it instead of restating the asset list (474/500 words), and CLAUDE.md, AGENTS.md and .changes/README.md point at it; the README's release sequence no longer omits the announcement and card steps. 172 tests passing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
320 lines
9.6 KiB
JavaScript
320 lines
9.6 KiB
JavaScript
/**
|
|
* Parsing and validation for `.changes/*.md` release notes.
|
|
*
|
|
* One file per user-visible change, written by the PR author while the
|
|
* context is still fresh. The generator (build-release-notes.mjs) turns the
|
|
* accumulated files into the GitHub release body, the CHANGELOG.md section,
|
|
* the website blog scaffold, and the Telegram/Reddit announcement drafts.
|
|
*
|
|
* Deliberately dependency-free: a hand-rolled parser for this tiny, closed
|
|
* schema is more predictable than a YAML engine, and it can reject unknown
|
|
* keys — which is what catches agent typos.
|
|
*/
|
|
|
|
import { readdirSync, readFileSync } from 'node:fs';
|
|
import path from 'node:path';
|
|
|
|
export const REPO_URL = 'https://github.com/4gray/iptvnator';
|
|
|
|
/** Render order. `internal` is last and is excluded from user-facing output. */
|
|
export const NOTE_TYPES = ['breaking', 'feature', 'fix', 'perf', 'internal'];
|
|
|
|
export const TYPE_HEADINGS = {
|
|
breaking: 'Breaking changes',
|
|
feature: 'Features',
|
|
fix: 'Fixes',
|
|
perf: 'Performance',
|
|
internal: 'Internal',
|
|
};
|
|
|
|
const KNOWN_KEYS = new Set([
|
|
'type',
|
|
'area',
|
|
'issues',
|
|
'screenshot',
|
|
'highlight',
|
|
]);
|
|
const SLUG_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
|
const DELIMITER = '---';
|
|
|
|
/** Keeps entries to a sentence or three; essays belong in the blog post. */
|
|
const MAX_BODY_LENGTH = 400;
|
|
|
|
/**
|
|
* A highlight is a headline, not a paragraph — and the limit is the hero
|
|
* card's single-line budget (`wrapText(headline, 60, 1)`). Anything longer is
|
|
* silently ellipsized there, so it is rejected at the source instead.
|
|
*/
|
|
const MAX_HIGHLIGHT_LENGTH = 60;
|
|
|
|
/**
|
|
* Splits a note into its frontmatter lines and body.
|
|
*
|
|
* @param {string} content raw file content
|
|
* @returns {{ frontmatterLines: string[], body: string }}
|
|
*/
|
|
function splitFrontmatter(content) {
|
|
const lines = content.replace(/\r\n/g, '\n').split('\n');
|
|
|
|
if (lines[0]?.trim() !== DELIMITER) {
|
|
throw new Error('missing opening `---` frontmatter delimiter');
|
|
}
|
|
|
|
const closingIndex = lines.findIndex(
|
|
(line, index) => index > 0 && line.trim() === DELIMITER
|
|
);
|
|
|
|
if (closingIndex === -1) {
|
|
throw new Error('missing closing `---` frontmatter delimiter');
|
|
}
|
|
|
|
return {
|
|
frontmatterLines: lines.slice(1, closingIndex),
|
|
body: lines.slice(closingIndex + 1).join('\n').trim(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Free-form fields, where `#` is ordinary punctuation rather than a comment
|
|
* marker. Everything else in this schema is a closed vocabulary — enum, slug,
|
|
* issue numbers — whose values can never contain one.
|
|
*/
|
|
const PROSE_KEYS = new Set(['highlight']);
|
|
|
|
/**
|
|
* Strips a surrounding quote pair. Both ends must carry the SAME quote
|
|
* character: `"Up Next" rail` is prose containing quotes, and stripping its
|
|
* ends independently would silently eat the opening one.
|
|
*
|
|
* @param {string} value
|
|
* @returns {string}
|
|
*/
|
|
function unquote(value) {
|
|
const quote = value[0];
|
|
const isQuoted =
|
|
value.length >= 2 &&
|
|
(quote === "'" || quote === '"') &&
|
|
value.endsWith(quote);
|
|
|
|
return (isQuoted ? value.slice(1, -1) : value).trim();
|
|
}
|
|
|
|
/**
|
|
* Parses a single `key: value` frontmatter line.
|
|
*
|
|
* Trailing `# comment` text is stripped from closed-vocabulary fields, whose
|
|
* documented examples carry explanatory comments. Prose fields keep it —
|
|
* `highlight: Sources #N chip` is a headline, not a commented-out value, and
|
|
* stripping there truncated the headline on every announcement surface.
|
|
*
|
|
* @param {string} line
|
|
* @returns {[string, string] | null} key/value pair, or null for blank and
|
|
* whole-line comment lines
|
|
*/
|
|
function parseFrontmatterLine(line) {
|
|
const trimmed = line.trim();
|
|
|
|
if (!trimmed || trimmed.startsWith('#')) {
|
|
return null;
|
|
}
|
|
|
|
const separatorIndex = trimmed.indexOf(':');
|
|
|
|
if (separatorIndex === -1) {
|
|
throw new Error(`frontmatter line is not \`key: value\`: "${trimmed}"`);
|
|
}
|
|
|
|
const key = trimmed.slice(0, separatorIndex).trim();
|
|
const rawValue = trimmed.slice(separatorIndex + 1);
|
|
const value = PROSE_KEYS.has(key)
|
|
? rawValue
|
|
: rawValue.replace(/\s+#.*$/, '');
|
|
|
|
return [key, unquote(value.trim())];
|
|
}
|
|
|
|
/**
|
|
* @param {string} value e.g. `[1187, 1188]` or `1187`
|
|
* @returns {number[]}
|
|
*/
|
|
function parseIssueList(value) {
|
|
const inner = value.startsWith('[') ? value.replace(/^\[|\]$/g, '') : value;
|
|
|
|
return inner
|
|
.split(',')
|
|
.map((entry) => entry.trim().replace(/^#/, ''))
|
|
.filter(Boolean)
|
|
.map((entry) => Number(entry));
|
|
}
|
|
|
|
/**
|
|
* Parses a note file into a structured record. Shape errors throw; semantic
|
|
* errors are reported by validateNote() so a run can list every problem at
|
|
* once instead of failing on the first one.
|
|
*
|
|
* @param {string} content
|
|
* @param {string} sourcePath path used in error messages and PR resolution
|
|
* @returns {{ type: string, area: string, issues: number[], screenshot: string | null, highlight: string | null, body: string, sourcePath: string }}
|
|
*/
|
|
export function parseNote(content, sourcePath) {
|
|
const { frontmatterLines, body } = splitFrontmatter(content);
|
|
const fields = new Map();
|
|
|
|
for (const line of frontmatterLines) {
|
|
const parsed = parseFrontmatterLine(line);
|
|
|
|
if (!parsed) {
|
|
continue;
|
|
}
|
|
|
|
const [key, value] = parsed;
|
|
|
|
if (fields.has(key)) {
|
|
throw new Error(`duplicate frontmatter key: \`${key}\``);
|
|
}
|
|
|
|
fields.set(key, value);
|
|
}
|
|
|
|
return {
|
|
type: fields.get('type') ?? '',
|
|
area: fields.get('area') ?? '',
|
|
issues: fields.has('issues') ? parseIssueList(fields.get('issues')) : [],
|
|
screenshot: fields.get('screenshot') ?? null,
|
|
highlight: fields.get('highlight') ?? null,
|
|
unknownKeys: [...fields.keys()].filter((key) => !KNOWN_KEYS.has(key)),
|
|
body,
|
|
sourcePath,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param {ReturnType<typeof parseNote>} note
|
|
* @returns {string[]} human-readable problems, empty when the note is valid
|
|
*/
|
|
export function validateNote(note) {
|
|
const errors = [];
|
|
|
|
if (!note.type) {
|
|
errors.push('`type` is required');
|
|
} else if (!NOTE_TYPES.includes(note.type)) {
|
|
errors.push(
|
|
`unknown \`type\`: "${note.type}" (expected one of ${NOTE_TYPES.join(', ')})`
|
|
);
|
|
}
|
|
|
|
if (!note.area) {
|
|
errors.push('`area` is required (use the conventional-commit scope)');
|
|
} else if (!SLUG_PATTERN.test(note.area)) {
|
|
errors.push(`\`area\` must be a lowercase slug, got "${note.area}"`);
|
|
}
|
|
|
|
if (!note.body) {
|
|
errors.push('body is empty — describe the change for a user');
|
|
} else if (note.body.length > MAX_BODY_LENGTH) {
|
|
errors.push(
|
|
`body is ${note.body.length} characters, max ${MAX_BODY_LENGTH} — keep it to a few sentences and put detail in the blog post`
|
|
);
|
|
}
|
|
|
|
for (const issue of note.issues) {
|
|
if (!Number.isInteger(issue) || issue <= 0) {
|
|
errors.push(`\`issues\` must be positive integers, got "${issue}"`);
|
|
}
|
|
}
|
|
|
|
if (note.screenshot !== null && !SLUG_PATTERN.test(note.screenshot)) {
|
|
errors.push(
|
|
`\`screenshot\` must be a lowercase slug from the screenshot manifest, got "${note.screenshot}"`
|
|
);
|
|
}
|
|
|
|
if (note.highlight !== null) {
|
|
if (!note.highlight) {
|
|
errors.push(
|
|
'`highlight` is present but empty — give the feature a short headline or drop the key'
|
|
);
|
|
} else if (note.highlight.length > MAX_HIGHLIGHT_LENGTH) {
|
|
errors.push(
|
|
`\`highlight\` is ${note.highlight.length} characters, max ${MAX_HIGHLIGHT_LENGTH} — it is a headline, the body carries the detail`
|
|
);
|
|
}
|
|
|
|
if (note.type === 'internal') {
|
|
errors.push(
|
|
'`highlight` is not allowed on `type: internal` — internal notes never reach announcements'
|
|
);
|
|
}
|
|
}
|
|
|
|
for (const key of note.unknownKeys) {
|
|
errors.push(
|
|
`unknown frontmatter key: \`${key}\` (expected ${[...KNOWN_KEYS].join(', ')})`
|
|
);
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
/**
|
|
* Reads and parses every note in a directory, sorted by filename so output
|
|
* ordering is stable across machines.
|
|
*
|
|
* @param {string} directory
|
|
* @returns {{ notes: object[], errors: string[] }}
|
|
*/
|
|
export function loadNotes(directory) {
|
|
let entries = [];
|
|
|
|
try {
|
|
entries = readdirSync(directory);
|
|
} catch (error) {
|
|
if (error.code === 'ENOENT') {
|
|
return { notes: [], errors: [] };
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
|
|
const files = entries
|
|
.filter((name) => name.endsWith('.md') && name !== 'README.md')
|
|
.sort();
|
|
|
|
const notes = [];
|
|
const errors = [];
|
|
|
|
for (const name of files) {
|
|
const sourcePath = path.join(directory, name);
|
|
|
|
try {
|
|
const note = parseNote(readFileSync(sourcePath, 'utf8'), sourcePath);
|
|
const problems = validateNote(note);
|
|
|
|
if (problems.length > 0) {
|
|
errors.push(...problems.map((problem) => `${name}: ${problem}`));
|
|
continue;
|
|
}
|
|
|
|
notes.push(note);
|
|
} catch (error) {
|
|
errors.push(`${name}: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
return { notes, errors };
|
|
}
|
|
|
|
/**
|
|
* Groups notes by type in render order, dropping empty groups.
|
|
*
|
|
* @param {object[]} notes
|
|
* @returns {{ type: string, heading: string, notes: object[] }[]}
|
|
*/
|
|
export function groupNotes(notes) {
|
|
return NOTE_TYPES.map((type) => ({
|
|
type,
|
|
heading: TYPE_HEADINGS[type],
|
|
notes: notes.filter((note) => note.type === type),
|
|
})).filter((group) => group.notes.length > 0);
|
|
}
|