Files
iptvnator/tools/release/release-announcements.mjs
T

291 lines
9.6 KiB
JavaScript

/**
* Announcement renderers: compact release posts for Telegram and Reddit.
*
* Both formats are built around `highlight:` notes — the two or three changes
* worth leading with — and compress everything else into a counter (Telegram)
* or a collapsed list (Reddit). `internal` notes never appear.
*
* Output goes to stdout so it can be piped or pasted; publishing is a human
* act, these scripts never talk to any social platform.
*/
import { groupNotes, REPO_URL } from './release-notes.mjs';
import { releaseSlug } from './release-notes-render.mjs';
export const WEBSITE_URL = 'https://4gray.github.io/iptvnator';
/**
* Telegram truncates nothing — it rejects messages over 4096 characters, so
* the renderer must guarantee the limit instead of hoping.
*/
export const TELEGRAM_MESSAGE_LIMIT = 4096;
/**
* Reddit rejects a self-post body over this length. It is roomy, but not
* unreachable: this repository's own accumulated notes already render a
* ~36,500-character draft.
*/
export const REDDIT_POST_LIMIT = 40000;
/**
* Reddit caps a post title separately from its body, and far tighter: five
* highlights at the validated 60-character maximum already overshoot it while
* the body stays nowhere near its own limit.
*/
export const REDDIT_TITLE_LIMIT = 300;
/**
* Names as many highlights as the title limit allows, counting the rest.
*
* @param {string} version
* @param {object[]} highlights
* @returns {string}
*/
export function buildRedditTitle(version, highlights) {
const prefix = `IPTVnator v${version}`;
if (highlights.length === 0) {
return `${prefix} released`;
}
const names = highlights.map((note) => note.highlight);
for (let visible = names.length; visible > 0; visible -= 1) {
const hidden = names.length - visible;
const title = `${prefix} — ${names.slice(0, visible).join(', ')}${
hidden > 0 ? `, and ${hidden} more` : ''
}`;
if (title.length <= REDDIT_TITLE_LIMIT) {
return title;
}
}
// Unreachable while the highlight cap stays well under the title limit,
// but a title is never worth failing a release over.
return `${prefix} released`;
}
const TYPE_EMOJI = {
breaking: '⚠️',
feature: '✨',
fix: '🔧',
perf: '⚡',
};
/** Collapses a note body to a single line. */
function oneLine(body) {
return body.replace(/\s+/g, ' ').trim();
}
function releaseUrl(version) {
return `${REPO_URL}/releases/tag/v${version}`;
}
/** One blog post per minor version, same rule as the blog scaffold. */
function blogUrl(version) {
return `${WEBSITE_URL}/blog/${releaseSlug(version)}-release-notes/`;
}
/**
* Splits notes into the announcement-worthy highlights and the remaining
* user-visible changes. Group order (breaking → feature → fix → perf) is
* preserved inside both halves; `internal` is dropped entirely.
*
* @param {object[]} notes
* @returns {{ ordered: object[], highlights: object[], rest: object[] }}
*/
export function splitHighlights(notes) {
const ordered = groupNotes(notes)
.filter((group) => group.type !== 'internal')
.flatMap((group) => group.notes);
return {
ordered,
highlights: ordered.filter((note) => note.highlight),
rest: ordered.filter((note) => !note.highlight),
};
}
function moreLine(count) {
return count === 1
? '…plus 1 more fix or improvement.'
: `…plus ${count} more fixes and improvements.`;
}
/**
* Plain text on purpose: Telegram markdown is a bot-API entity format, and a
* hand-pasted post renders literal `*`/`[` characters. Bare URLs unfurl fine.
*
* @param {object[]} notes
* @param {{ version: string }} options
* @returns {string | null} a post guaranteed to fit TELEGRAM_MESSAGE_LIMIT,
* or null for an internal-only release with nothing public to announce
*/
export function renderTelegramPost(notes, { version }) {
const { ordered, highlights, rest } = splitHighlights(notes);
const leadIsHighlights = highlights.length > 0;
// A breaking change is never folded into the counter, highlighted or not:
// announcing one as "fixes and improvements" is worse than a longer post.
const lead = leadIsHighlights
? ordered.filter((note) => note.highlight || note.type === 'breaking')
: rest;
// An internal-only release is a legal shape — its authored GitHub body is
// empty too — and there is simply nothing to announce publicly.
if (lead.length === 0) {
return null;
}
const footer = [
`⬇️ Download: ${releaseUrl(version)}`,
`📝 Full notes: ${blogUrl(version)}`,
].join('\n');
const buildPost = (visibleLead) => {
const lines = visibleLead.map((note) => {
const emoji = TYPE_EMOJI[note.type] ?? '•';
const body = oneLine(note.body);
return note.highlight
? `${emoji} ${note.highlight} — ${body}`
: `${emoji} ${body}`;
});
// Everything public that this post does not spell out.
const hiddenCount = ordered.length - visibleLead.length;
const more = hiddenCount > 0 ? moreLine(hiddenCount) : null;
return [
`🎉 IPTVnator v${version} is out!`,
lines.join('\n'),
more,
footer,
]
.filter(Boolean)
.join('\n\n');
};
// Drop trailing entries into the counter until the post fits. Two classes
// of entry are never allowed to fall in there: hand-picked highlights
// (the release manager chose too many) and breaking changes (a warning
// silently reported as "fixes and improvements" is worse than no post).
for (let visible = lead.length; visible > 0; visible -= 1) {
const post = buildPost(lead.slice(0, visible));
if (post.length > TELEGRAM_MESSAGE_LIMIT) {
continue;
}
const dropped = lead.slice(visible);
const droppedBreaking = dropped.filter(
(note) => note.type === 'breaking'
).length;
if (droppedBreaking > 0) {
throw new Error(
`${droppedBreaking} breaking change(s) do not fit Telegram's ${TELEGRAM_MESSAGE_LIMIT}-character limit — shorten those notes, or announce this release in several posts`
);
}
if (leadIsHighlights && dropped.length > 0) {
throw new Error(
`the ${lead.length} highlights do not fit Telegram's ${TELEGRAM_MESSAGE_LIMIT}-character limit — pick fewer or shorten their notes`
);
}
return post;
}
throw new Error(
`even a single entry exceeds Telegram's ${TELEGRAM_MESSAGE_LIMIT}-character limit`
);
}
/**
* Reddit post: markdown body plus a suggested title on the first line,
* because Reddit takes the title separately from the body.
*
* @param {object[]} notes
* @param {{ version: string }} options
* @returns {string | null} a post within REDDIT_POST_LIMIT, or null for an
* internal-only release
*/
export function renderRedditPost(notes, { version }) {
const { highlights, rest } = splitHighlights(notes);
if (highlights.length === 0 && rest.length === 0) {
return null;
}
const title = buildRedditTitle(version, highlights);
const buildPost = (visibleRest, droppedCount) => {
const blocks = [`Suggested title: ${title}`, '---'];
if (highlights.length > 0) {
blocks.push('## Highlights');
for (const note of highlights) {
blocks.push(`### ${note.highlight}`, oneLine(note.body));
}
}
if (visibleRest.length > 0) {
blocks.push(
highlights.length > 0
? '## Also in this release'
: "## What's changed"
);
for (const group of groupNotes(visibleRest)) {
const entries = group.notes
.map((note) => `- **${note.area}** — ${oneLine(note.body)}`)
.join('\n');
blocks.push(`**${group.heading}**\n\n${entries}`);
}
}
if (droppedCount > 0) {
blocks.push(
`…and ${droppedCount} more ${droppedCount === 1 ? 'change' : 'changes'} — see the full release notes below.`
);
}
blocks.push(
`[Download](${releaseUrl(version)}) · [Full release notes](${blogUrl(version)}) · [Changelog](${REPO_URL}/blob/master/CHANGELOG.md)`
);
return `${blocks.filter(Boolean).join('\n\n')}\n`;
};
// Reddit rejects a body over its post limit, so the draft has to be bounded
// the way the Telegram one is. Entries are dropped from the tail of the
// grouped list, which is ordered breaking → feature → fix → perf, so the
// least consequential go first.
for (let visible = rest.length; visible >= 0; visible -= 1) {
const post = buildPost(rest.slice(0, visible), rest.length - visible);
if (post.length > REDDIT_POST_LIMIT) {
continue;
}
const droppedBreaking = rest
.slice(visible)
.filter((note) => note.type === 'breaking').length;
if (droppedBreaking > 0) {
throw new Error(
`${droppedBreaking} breaking change(s) do not fit Reddit's ${REDDIT_POST_LIMIT}-character limit — shorten those notes, or announce this release in several posts`
);
}
return post;
}
throw new Error(
`the highlights alone exceed Reddit's ${REDDIT_POST_LIMIT}-character limit — pick fewer or shorten their notes`
);
}