mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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);
|
|
}
|