mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 01:16:15 -08:00
340 lines
10 KiB
JavaScript
340 lines
10 KiB
JavaScript
/**
|
|
* Renderers turning grouped `.changes/*.md` notes into the three release
|
|
* surfaces: the GitHub release body, the CHANGELOG.md section, and the
|
|
* website blog scaffold.
|
|
*/
|
|
|
|
import { extractSection } from './extract-changelog-section.mjs';
|
|
import { groupNotes, REPO_URL } from './release-notes.mjs';
|
|
|
|
const MONTHS = [
|
|
'January',
|
|
'February',
|
|
'March',
|
|
'April',
|
|
'May',
|
|
'June',
|
|
'July',
|
|
'August',
|
|
'September',
|
|
'October',
|
|
'November',
|
|
'December',
|
|
];
|
|
|
|
/**
|
|
* @param {string} isoDate `YYYY-MM-DD`
|
|
* @returns {string} e.g. `August 1, 2026`
|
|
*/
|
|
export function formatLongDate(isoDate) {
|
|
const [year, month, day] = isoDate.split('-').map(Number);
|
|
|
|
return `${MONTHS[month - 1]} ${day}, ${year}`;
|
|
}
|
|
|
|
/**
|
|
* Deliberately minor-scoped: the website publishes one release post per minor
|
|
* version (`v0-18` … `v0-22`), and its screenshot assets live under the same
|
|
* directory. A patch release therefore reuses its minor's post rather than
|
|
* starting a new one.
|
|
*
|
|
* @param {string} version
|
|
* @returns {string} e.g. `v0-24` for both `0.24.0` and `0.24.1`
|
|
*/
|
|
export function releaseSlug(version) {
|
|
const [major, minor] = version.split('.');
|
|
|
|
return `v${major}-${minor}`;
|
|
}
|
|
|
|
/** Collapses a note body to a single line for list entries. */
|
|
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) {
|
|
if (text.length <= max) {
|
|
return text;
|
|
}
|
|
|
|
const cut = text.slice(0, max);
|
|
const lastSpace = cut.lastIndexOf(' ');
|
|
|
|
return `${(lastSpace > max / 2 ? cut.slice(0, lastSpace) : cut).trimEnd()}…`;
|
|
}
|
|
|
|
/**
|
|
* MDX parses `<` and `{` as markup. Note bodies are plain prose written by
|
|
* humans and agents, so escape them rather than letting a stray character
|
|
* 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) {
|
|
return text
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/\{/g, '{')
|
|
.replace(/\}/g, '}');
|
|
}
|
|
|
|
/**
|
|
* @param {object} note
|
|
* @param {Map<string, { pr?: number, commit?: string }>} links
|
|
* @returns {string} trailing `([#123](url), closes [#45](url))` or ''
|
|
*/
|
|
function formatReferences(note, links) {
|
|
const parts = [];
|
|
const link = links.get(note.sourcePath);
|
|
|
|
if (link?.pr) {
|
|
parts.push(`[#${link.pr}](${REPO_URL}/pull/${link.pr})`);
|
|
} else if (link?.commit) {
|
|
parts.push(
|
|
`[${link.commit.slice(0, 7)}](${REPO_URL}/commit/${link.commit})`
|
|
);
|
|
}
|
|
|
|
for (const issue of note.issues) {
|
|
parts.push(`closes [#${issue}](${REPO_URL}/issues/${issue})`);
|
|
}
|
|
|
|
return parts.length > 0 ? ` (${parts.join(', ')})` : '';
|
|
}
|
|
|
|
function formatEntry(note, links) {
|
|
return `- **${note.area}** — ${oneLine(note.body)}${formatReferences(note, links)}`;
|
|
}
|
|
|
|
/**
|
|
* GitHub release body. `internal` notes are omitted: the release page is read
|
|
* by users, and GitHub still appends its own full commit list below.
|
|
*
|
|
* @param {object[]} notes
|
|
* @param {{ links?: Map<string, object> }} [options]
|
|
* @returns {string}
|
|
*/
|
|
export function renderGithubBody(notes, { links = new Map() } = {}) {
|
|
const sections = groupNotes(notes)
|
|
.filter((group) => group.type !== 'internal')
|
|
.map((group) => {
|
|
const entries = group.notes
|
|
.map((note) => formatEntry(note, links))
|
|
.join('\n');
|
|
|
|
return `## ${group.heading}\n\n${entries}`;
|
|
});
|
|
|
|
return sections.join('\n\n');
|
|
}
|
|
|
|
/**
|
|
* CHANGELOG.md section. Unlike the release body this keeps `internal` notes,
|
|
* collapsed, so the file stays a complete record.
|
|
*
|
|
* @param {object[]} notes
|
|
* @param {{ version: string, date: string, previousVersion?: string | null, links?: Map<string, object> }} options
|
|
* @returns {string}
|
|
*/
|
|
export function renderChangelogSection(
|
|
notes,
|
|
{ version, date, previousVersion = null, links = new Map() }
|
|
) {
|
|
const heading = previousVersion
|
|
? `# [${version}](${REPO_URL}/compare/v${previousVersion}...v${version}) (${date})`
|
|
: `# ${version} (${date})`;
|
|
|
|
const blocks = [heading];
|
|
|
|
for (const group of groupNotes(notes)) {
|
|
const entries = group.notes
|
|
.map((note) => formatEntry(note, links))
|
|
.join('\n');
|
|
|
|
if (group.type === 'internal') {
|
|
blocks.push(
|
|
`<details>\n<summary>Internal changes</summary>\n\n${entries}\n\n</details>`
|
|
);
|
|
continue;
|
|
}
|
|
|
|
blocks.push(`### ${group.heading}\n\n${entries}`);
|
|
}
|
|
|
|
return `${blocks.join('\n\n')}\n`;
|
|
}
|
|
|
|
/**
|
|
* Inserts a version section below the marker, replacing any existing section
|
|
* for the same version — rerunning `--format changelog` after correcting a
|
|
* note must not prepend a duplicate.
|
|
*
|
|
* @param {string} changelog full CHANGELOG.md content
|
|
* @param {string} section rendered section (from renderChangelogSection)
|
|
* @param {string} version bare semver the section describes
|
|
* @param {string} marker insertion marker line
|
|
* @returns {{ content: string, replaced: boolean }}
|
|
*/
|
|
export function upsertChangelogSection(changelog, section, version, marker) {
|
|
if (!changelog.includes(marker)) {
|
|
throw new Error(
|
|
`changelog is missing the \`${marker}\` marker that new sections are inserted below`
|
|
);
|
|
}
|
|
|
|
let current = changelog;
|
|
const replaced = extractSection(current, version) !== null;
|
|
|
|
if (replaced) {
|
|
const lines = current.split('\n');
|
|
const headingIndex = lines.findIndex(
|
|
(line) =>
|
|
line.startsWith(`# [${version}]`) ||
|
|
line.startsWith(`# ${version} `)
|
|
);
|
|
let end = lines.length;
|
|
|
|
for (let index = headingIndex + 1; index < lines.length; index += 1) {
|
|
if (/^#\s/.test(lines[index])) {
|
|
end = index;
|
|
break;
|
|
}
|
|
}
|
|
|
|
current = [...lines.slice(0, headingIndex), ...lines.slice(end)].join(
|
|
'\n'
|
|
);
|
|
}
|
|
|
|
// Rebuild around the marker instead of string-replacing into it, so the
|
|
// blank-line count on both sides of the section stays exact regardless of
|
|
// whether a removal just happened.
|
|
const markerIndex = current.indexOf(marker);
|
|
const before = current.slice(0, markerIndex);
|
|
const after = current
|
|
.slice(markerIndex + marker.length)
|
|
.replace(/^\n+/, '');
|
|
|
|
return {
|
|
content: `${before}${marker}\n\n${section.trim()}\n\n${after}`,
|
|
replaced,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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');
|
|
}
|