/** * 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` ); }