// Deterministic renderer for the WorldMonitor Brief magazine. // // Pure function: (BriefEnvelope) -> HTML string. No I/O, no LLM calls, // no network, no time-dependent output. The composer writes the // envelope once; any consumer (edge route, dashboard panel preview, // Tauri window) re-renders the same HTML at read time. // // The page sequence is derived from the data, not hardcoded: // 1. Dark cover // 2. Digest · 01 Greeting (always) // 3. Digest · 02 At A Glance (always) // 4. Digest · 03 On The Desk (one page if threads.length <= 6; // else split into 03a + 03b) // 5. Digest · 04 Signals (omitted when signals.length === 0) // 6. Stories (one page per story, alternating // light/dark by index parity) // 7. Dark back cover // // Source references: // - Visual prototype: .claude/worktrees/zany-chasing-boole/digest-magazine.html // - Brainstorm: docs/brainstorms/2026-04-17-worldmonitor-brief-magazine-requirements.md // - Plan: docs/plans/2026-04-17-003-feat-worldmonitor-brief-magazine-plan.md import { BRIEF_ENVELOPE_VERSION, SUPPORTED_ENVELOPE_VERSIONS } from '../../shared/brief-envelope.js'; /** * @typedef {import('../../shared/brief-envelope.js').BriefEnvelope} BriefEnvelope * @typedef {import('../../shared/brief-envelope.js').BriefData} BriefData * @typedef {import('../../shared/brief-envelope.js').BriefStory} BriefStory * @typedef {import('../../shared/brief-envelope.js').BriefThread} BriefThread * @typedef {import('../../shared/brief-envelope.js').BriefThreatLevel} BriefThreatLevel */ // ── Constants ──────────────────────────────────────────────────────────────── const FONTS_HREF = 'https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,700;0,900;1,400&family=Source+Serif+4:ital,wght@0,400;0,600;1,400&family=IBM+Plex+Mono:wght@400;500;600&display=swap'; const MAX_THREADS_PER_PAGE = 6; const DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/; /** @type {Record} */ const THREAT_LABELS = { critical: 'Critical', high: 'High', medium: 'Medium', low: 'Low', }; /** @type {Set} */ const HIGHLIGHTED_LEVELS = new Set(['critical', 'high']); const VALID_THREAT_LEVELS = new Set( /** @type {BriefThreatLevel[]} */ (['critical', 'high', 'medium', 'low']), ); // ── HTML escaping ──────────────────────────────────────────────────────────── const HTML_ESCAPE_MAP = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', }; const HTML_ESCAPE_RE = /[&<>"']/; const HTML_ESCAPE_RE_G = /[&<>"']/g; /** * Text-context HTML escape. Do not use for raw attribute-value * interpolation without extending the map. * @param {string} str */ function escapeHtml(str) { const s = String(str); if (!HTML_ESCAPE_RE.test(s)) return s; return s.replace(HTML_ESCAPE_RE_G, (ch) => HTML_ESCAPE_MAP[ch]); } /** @param {number} n */ function pad2(n) { return String(n).padStart(2, '0'); } // ── Envelope validation ────────────────────────────────────────────────────── /** @param {unknown} v */ function isObject(v) { return typeof v === 'object' && v !== null; } /** @param {unknown} v */ function isNonEmptyString(v) { return typeof v === 'string' && v.length > 0; } /** @param {unknown} v */ function isFiniteNumber(v) { return typeof v === 'number' && Number.isFinite(v); } // Closed key sets for each object in the contract. The validator // rejects extra keys at every level — a producer cannot smuggle // importanceScore, primaryLink, pubDate, briefModel, fetchedAt or any // other forbidden upstream field into a persisted envelope. The // renderer already refuses to interpolate unknown fields (and that is // covered by the sentinel-poisoning test), but unknown fields resident // in Redis still pollute every future consumer (edge route, dashboard // panel preview, carousel, email teaser). Locking the contract at // write time is the only place this invariant can live. const ALLOWED_ENVELOPE_KEYS = new Set(['version', 'issuedAt', 'data']); const ALLOWED_DATA_KEYS = new Set(['user', 'issue', 'date', 'dateLong', 'digest', 'stories']); const ALLOWED_USER_KEYS = new Set(['name', 'tz']); // publicLead / publicSignals / publicThreads: optional v3+ fields. // Hold non-personalised content the public-share renderer uses in // place of the personalised lead/signals/threads. v2 envelopes (no // publicLead) still pass — the validator's optional-key pattern is // "in the allow list, but isString/array check is skipped when // undefined" (see validateBriefDigest below). const ALLOWED_DIGEST_KEYS = new Set([ 'greeting', 'lead', 'numbers', 'threads', 'signals', 'publicLead', 'publicSignals', 'publicThreads', ]); const ALLOWED_NUMBERS_KEYS = new Set(['clusters', 'multiSource', 'surfaced']); const ALLOWED_THREAD_KEYS = new Set(['tag', 'teaser']); const ALLOWED_STORY_KEYS = new Set([ 'category', 'country', 'threatLevel', 'headline', 'description', 'source', 'sourceUrl', 'whyMatters', ]); // Closed list of URL schemes we will interpolate into `href=`. A source // record with an unknown scheme is a composer bug, not something to // render — the story is dropped at envelope-validation time rather than // shipping with an unlinked / broken source. const ALLOWED_SOURCE_URL_SCHEMES = new Set(['https:', 'http:']); /** * Parses and validates a story source URL. Returns the normalised URL * string on success; throws a descriptive error otherwise. The renderer * validator wraps this in a per-story path-prefixed error so composer * bugs are easy to locate. * * @param {unknown} raw * @returns {string} */ function validateSourceUrl(raw) { if (typeof raw !== 'string' || raw.length === 0) { throw new Error('must be a non-empty string'); } let parsed; try { parsed = new URL(raw); } catch { throw new Error(`must be a parseable absolute URL (got ${JSON.stringify(raw)})`); } if (!ALLOWED_SOURCE_URL_SCHEMES.has(parsed.protocol)) { throw new Error(`scheme ${JSON.stringify(parsed.protocol)} is not allowed (http/https only)`); } // Bar `javascript:`-style smuggling via credentials or a Unicode host // that renders like a legitimate outlet. These aren't exploitable // through the renderer (we only emit the URL in an href with // rel=noopener and we escape it), but they're always a composer bug // so flag at write time. if (parsed.username || parsed.password) { throw new Error('must not include userinfo credentials'); } return parsed.toString(); } /** * @param {Record} obj * @param {Set} allowed * @param {string} path */ function assertNoExtraKeys(obj, allowed, path) { for (const key of Object.keys(obj)) { if (!allowed.has(key)) { throw new Error( `${path} has unexpected key ${JSON.stringify(key)}; allowed keys: ${[...allowed].join(', ')}`, ); } } } /** * Throws a descriptive error on the first missing, mis-typed, or * unexpected field. Runs before any HTML interpolation so the renderer * can assume the typed shape after this returns. The renderer is a * shared module with multiple independent producers (Railway composer, * tests, future dev-only fixtures) — a strict runtime contract matters * more than the declaration-file types alone. * * Also enforces the cross-field invariant that * `digest.numbers.surfaced === stories.length`. The renderer uses both * values (surfaced prints on the "at a glance" page; stories.length * drives cover blurb and page count) — allowing them to disagree would * produce a self-contradictory brief. * * @param {unknown} envelope * @returns {asserts envelope is BriefEnvelope} */ export function assertBriefEnvelope(envelope) { if (!isObject(envelope)) { throw new Error('renderBriefMagazine: envelope must be an object'); } const env = /** @type {Record} */ (envelope); assertNoExtraKeys(env, ALLOWED_ENVELOPE_KEYS, 'envelope'); // Accept any version in SUPPORTED_ENVELOPE_VERSIONS. The composer // only ever writes the current BRIEF_ENVELOPE_VERSION; older // versions are tolerated on READ so links issued in the 7-day TTL // window survive a renderer rollout. Unknown versions are still // rejected — an unexpected shape would lead the renderer to // interpolate garbage. if (typeof env.version !== 'number' || !SUPPORTED_ENVELOPE_VERSIONS.has(env.version)) { throw new Error( `renderBriefMagazine: envelope.version=${JSON.stringify(env.version)} is not in supported set [${[...SUPPORTED_ENVELOPE_VERSIONS].join(', ')}]. Deploy a matching renderer before producing envelopes at this version.`, ); } if (!isFiniteNumber(env.issuedAt)) { throw new Error('renderBriefMagazine: envelope.issuedAt must be a finite number'); } if (!isObject(env.data)) { throw new Error('renderBriefMagazine: envelope.data is required'); } const data = /** @type {Record} */ (env.data); assertNoExtraKeys(data, ALLOWED_DATA_KEYS, 'envelope.data'); if (!isObject(data.user)) throw new Error('envelope.data.user is required'); const user = /** @type {Record} */ (data.user); assertNoExtraKeys(user, ALLOWED_USER_KEYS, 'envelope.data.user'); if (!isNonEmptyString(user.name)) throw new Error('envelope.data.user.name must be a non-empty string'); if (!isNonEmptyString(user.tz)) throw new Error('envelope.data.user.tz must be a non-empty string'); if (!isNonEmptyString(data.issue)) throw new Error('envelope.data.issue must be a non-empty string'); if (!isNonEmptyString(data.date)) throw new Error('envelope.data.date must be a non-empty string'); if (!DATE_REGEX.test(/** @type {string} */ (data.date))) { throw new Error('envelope.data.date must match YYYY-MM-DD'); } if (!isNonEmptyString(data.dateLong)) throw new Error('envelope.data.dateLong must be a non-empty string'); if (!isObject(data.digest)) throw new Error('envelope.data.digest is required'); const digest = /** @type {Record} */ (data.digest); assertNoExtraKeys(digest, ALLOWED_DIGEST_KEYS, 'envelope.data.digest'); if (!isNonEmptyString(digest.greeting)) throw new Error('envelope.data.digest.greeting must be a non-empty string'); if (!isNonEmptyString(digest.lead)) throw new Error('envelope.data.digest.lead must be a non-empty string'); // publicLead: optional v3+ field. When present, MUST be a non-empty // string (typed contract enforcement); when absent, the renderer's // public-mode lead block omits the pull-quote entirely (per the // "never fall back to personalised lead" rule). if (digest.publicLead !== undefined && !isNonEmptyString(digest.publicLead)) { throw new Error('envelope.data.digest.publicLead, when present, must be a non-empty string'); } // publicSignals + publicThreads: optional v3+. When present, MUST // match the signals/threads contracts (array of non-empty strings, // array of {tag, teaser}). Absent siblings are OK — public render // path falls back to "omit signals page" / "category-derived // threads stub" rather than serving the personalised version. if (digest.publicSignals !== undefined) { if (!Array.isArray(digest.publicSignals)) { throw new Error('envelope.data.digest.publicSignals, when present, must be an array'); } digest.publicSignals.forEach((s, i) => { if (!isNonEmptyString(s)) throw new Error(`envelope.data.digest.publicSignals[${i}] must be a non-empty string`); }); } if (digest.publicThreads !== undefined) { if (!Array.isArray(digest.publicThreads)) { throw new Error('envelope.data.digest.publicThreads, when present, must be an array'); } digest.publicThreads.forEach((t, i) => { if (!isObject(t)) throw new Error(`envelope.data.digest.publicThreads[${i}] must be an object`); const th = /** @type {Record} */ (t); assertNoExtraKeys(th, ALLOWED_THREAD_KEYS, `envelope.data.digest.publicThreads[${i}]`); if (!isNonEmptyString(th.tag)) throw new Error(`envelope.data.digest.publicThreads[${i}].tag must be a non-empty string`); if (!isNonEmptyString(th.teaser)) throw new Error(`envelope.data.digest.publicThreads[${i}].teaser must be a non-empty string`); }); } if (!isObject(digest.numbers)) throw new Error('envelope.data.digest.numbers is required'); const numbers = /** @type {Record} */ (digest.numbers); assertNoExtraKeys(numbers, ALLOWED_NUMBERS_KEYS, 'envelope.data.digest.numbers'); for (const key of /** @type {const} */ (['clusters', 'multiSource', 'surfaced'])) { if (!isFiniteNumber(numbers[key])) { throw new Error(`envelope.data.digest.numbers.${key} must be a finite number`); } } if (!Array.isArray(digest.threads)) { throw new Error('envelope.data.digest.threads must be an array'); } digest.threads.forEach((t, i) => { if (!isObject(t)) throw new Error(`envelope.data.digest.threads[${i}] must be an object`); const th = /** @type {Record} */ (t); assertNoExtraKeys(th, ALLOWED_THREAD_KEYS, `envelope.data.digest.threads[${i}]`); if (!isNonEmptyString(th.tag)) throw new Error(`envelope.data.digest.threads[${i}].tag must be a non-empty string`); if (!isNonEmptyString(th.teaser)) throw new Error(`envelope.data.digest.threads[${i}].teaser must be a non-empty string`); }); if (!Array.isArray(digest.signals)) { throw new Error('envelope.data.digest.signals must be an array'); } digest.signals.forEach((s, i) => { if (!isNonEmptyString(s)) throw new Error(`envelope.data.digest.signals[${i}] must be a non-empty string`); }); if (!Array.isArray(data.stories) || data.stories.length === 0) { throw new Error('envelope.data.stories must be a non-empty array'); } data.stories.forEach((s, i) => { if (!isObject(s)) throw new Error(`envelope.data.stories[${i}] must be an object`); const st = /** @type {Record} */ (s); assertNoExtraKeys(st, ALLOWED_STORY_KEYS, `envelope.data.stories[${i}]`); for (const field of /** @type {const} */ (['category', 'country', 'headline', 'description', 'source', 'whyMatters'])) { if (!isNonEmptyString(st[field])) { throw new Error(`envelope.data.stories[${i}].${field} must be a non-empty string`); } } if (typeof st.threatLevel !== 'string' || !VALID_THREAT_LEVELS.has(/** @type {BriefThreatLevel} */ (st.threatLevel))) { throw new Error( `envelope.data.stories[${i}].threatLevel must be one of critical|high|medium|low (got ${JSON.stringify(st.threatLevel)})`, ); } // sourceUrl is required on v2 and absent on v1. When present on // either version, it must parse cleanly — a malformed URL would // break the href. On v1 it's expected to be absent; a v1 envelope // that somehow carries a sourceUrl is still validated (cheap // defence against composer regressions). if (env.version === BRIEF_ENVELOPE_VERSION || st.sourceUrl !== undefined) { try { validateSourceUrl(st.sourceUrl); } catch (err) { throw new Error( `envelope.data.stories[${i}].sourceUrl ${/** @type {Error} */ (err).message}`, ); } } }); // Cross-field invariant: surfaced count must match the actual number // of stories surfaced to this reader. Enforced here so cover copy // ("N threads") and the at-a-glance stat can never disagree. if (numbers.surfaced !== data.stories.length) { throw new Error( `envelope.data.digest.numbers.surfaced=${numbers.surfaced} must equal envelope.data.stories.length=${data.stories.length}`, ); } } // ── Logo symbol + references ───────────────────────────────────────────────── /** * The full logo SVG is emitted ONCE per document inside an invisible * block. Every placement then * references the symbol via `` at the desired size. Saves ~7 KB on * a 12-story brief vs. repeating the full SVG per placement. * * Stroke width is baked into the symbol (medium weight). Visual variance * across placements (cover 48px vs story 28px) reads identically at * display size; sub-pixel stroke differences are not perceptible. */ const LOGO_SYMBOL = ( '' ); /** * @param {{ size: number; color?: string }} opts */ function logoRef({ size, color }) { // color is sourced ONLY from a closed enum of theme strings at the // call sites in this file. Never interpolate envelope-derived content // into a style= attribute via this helper. const styleAttr = color ? ` style="color: ${color};"` : ''; return ( `' ); } // ── Running head (shared across digest pages) ──────────────────────────────── /** @param {string} dateShort @param {string} label */ function digestRunningHead(dateShort, label) { return ( '
' + '' + logoRef({ size: 22 }) + ` · WorldMonitor Brief · ${escapeHtml(dateShort)} ·` + '' + `${escapeHtml(label)}` + '
' ); } // ── Page renderers ─────────────────────────────────────────────────────────── /** * Strip the trailing period from envelope.data.digest.greeting * ("Good afternoon." → "Good afternoon") so the cover's mono-cased * salutation stays consistent with the historical no-period style. * Defensive: if the envelope ever produces an unexpected value, fall * back to a generic "Hello" rather than hardcoding a wrong time-of-day. */ function coverGreeting(greeting) { if (typeof greeting !== 'string' || greeting.length === 0) return 'Hello'; return greeting.replace(/\.+$/, '').trim() || 'Hello'; } /** * @param {{ dateLong: string; issue: string; storyCount: number; pageIndex: number; totalPages: number; greeting: string }} opts */ function renderCover({ dateLong, issue, storyCount, pageIndex, totalPages, greeting }) { const blurb = storyCount === 1 ? 'One thread that shaped the world today.' : `${storyCount} threads that shaped the world today.`; return ( '
' + '
' + '' + logoRef({ size: 48 }) + 'WorldMonitor' + '' + `Issue № ${escapeHtml(issue)}` + '
' + '
' + `
${escapeHtml(dateLong)}
` + '

WorldMonitor
Brief.

' + `

${escapeHtml(blurb)}

` + '
' + '
' + `${escapeHtml(coverGreeting(greeting))}` + 'Swipe / ↔ to begin' + '
' + `
${pad2(pageIndex)} / ${pad2(totalPages)}
` + '
' ); } /** * @param {{ greeting: string; lead: string; dateShort: string; pageIndex: number; totalPages: number }} opts */ function renderDigestGreeting({ greeting, lead, dateShort, pageIndex, totalPages }) { // Public-share fail-safe: when `lead` is empty, omit the pull-quote // entirely. Reached via redactForPublic when the envelope lacks a // non-empty `publicLead` — NEVER serve the personalised lead on the // public surface. Page still reads as a complete editorial layout // (greeting + horizontal rule), just without the italic blockquote. // Codex Round-2 High (security on share-URL surface). const blockquote = typeof lead === 'string' && lead.length > 0 ? `
${escapeHtml(lead)}
` : ''; return ( '
' + digestRunningHead(dateShort, 'Digest / 01') + '
' + '
At The Top Of The Hour
' + `

${escapeHtml(greeting)}

` + blockquote + '
' + '
' + `
${pad2(pageIndex)} / ${pad2(totalPages)}
` + '
' ); } /** * @param {{ numbers: import('../../shared/brief-envelope.js').BriefNumbers; date: string; dateShort: string; pageIndex: number; totalPages: number }} opts */ function renderDigestNumbers({ numbers, date, dateShort, pageIndex, totalPages }) { const rows = [ { n: numbers.clusters, label: 'story clusters ingested in the last 24 hours' }, { n: numbers.multiSource, label: 'multi-source confirmed events' }, { n: numbers.surfaced, label: 'threads surfaced in this brief' }, ] .map( (row) => '
' + `
${pad2(row.n)}
` + `
${escapeHtml(row.label)}
` + '
', ) .join(''); return ( '
' + digestRunningHead(dateShort, 'Digest / 02 — At A Glance') + '
' + '
The Numbers Today
' + `
${rows}
` + `` + '
' + `
${pad2(pageIndex)} / ${pad2(totalPages)}
` + '
' ); } /** * @param {{ threads: BriefThread[]; dateShort: string; label: string; heading: string; includeEndMarker: boolean; pageIndex: number; totalPages: number }} opts */ function renderDigestThreadsPage({ threads, dateShort, label, heading, includeEndMarker, pageIndex, totalPages, }) { const rows = threads .map( (t) => '

' + `${escapeHtml(t.tag)} —` + `${escapeHtml(t.teaser)}` + '

', ) .join(''); const endMarker = includeEndMarker ? '

Stories follow →
' : ''; return ( '
' + digestRunningHead(dateShort, label) + '
' + '
Today\u2019s Threads
' + `

${escapeHtml(heading)}

` + `
${rows}
` + endMarker + '
' + `
${pad2(pageIndex)} / ${pad2(totalPages)}
` + '
' ); } /** * @param {{ signals: string[]; dateShort: string; pageIndex: number; totalPages: number }} opts */ function renderDigestSignals({ signals, dateShort, pageIndex, totalPages }) { const paragraphs = signals .map((s) => `

${escapeHtml(s)}

`) .join(''); return ( '
' + digestRunningHead(dateShort, 'Digest / 04 — Signals') + '
' + '
Signals To Watch
' + '

What would change the story.

' + `
${paragraphs}
` + '

End of digest · Stories follow →
' + '
' + `
${pad2(pageIndex)} / ${pad2(totalPages)}
` + '
' ); } /** * Build a tracked outgoing URL for the source line. Adds utm_source / * utm_medium / utm_campaign / utm_content only when absent — if the * upstream feed already embeds UTM (many publisher RSS do), we keep * their attribution intact and just append ours after. * * Returns the original `raw` on URL parse failure. This path is * unreachable in practice because assertBriefEnvelope already proved * the URL parses, but fail-safe is cheap. * * @param {string} raw validated absolute https URL * @param {string} issueDate envelope.data.date (YYYY-MM-DD) * @param {number} rank 1-indexed story rank */ function buildTrackedSourceUrl(raw, issueDate, rank) { try { const u = new URL(raw); if (!u.searchParams.has('utm_source')) u.searchParams.set('utm_source', 'worldmonitor'); if (!u.searchParams.has('utm_medium')) u.searchParams.set('utm_medium', 'brief'); if (!u.searchParams.has('utm_campaign')) u.searchParams.set('utm_campaign', issueDate); if (!u.searchParams.has('utm_content')) u.searchParams.set('utm_content', `story-${pad2(rank)}`); return u.toString(); } catch { return raw; } } /** * @param {{ story: BriefStory; rank: number; palette: 'light' | 'dark'; pageIndex: number; totalPages: number; issueDate: string }} opts */ function renderStoryPage({ story, rank, palette, pageIndex, totalPages, issueDate }) { const threatClass = HIGHLIGHTED_LEVELS.has(story.threatLevel) ? ' crit' : ''; const threatLabel = THREAT_LABELS[story.threatLevel]; // v1 envelopes don't carry sourceUrl — render the source as plain // text (matching pre-v2 appearance). v2 envelopes always have a // validated URL, so we wrap in a UTM-tracked anchor. const sourceBlock = story.sourceUrl ? `${escapeHtml(story.source)}` : escapeHtml(story.source); return ( `
` + '
' + `
${pad2(rank)}
` + '
' + '
' + `${escapeHtml(story.category)}` + `${escapeHtml(story.country)}` + `${escapeHtml(threatLabel)}` + '
' + `

${escapeHtml(story.headline)}

` + `

${escapeHtml(story.description)}

` + `
Source · ${sourceBlock}
` + '
' + '
' + '
' + '
' + '
Why this is important
' + `

${escapeHtml(story.whyMatters)}

` + '
' + '
' + '
' + logoRef({ size: 28 }) + 'WorldMonitor Brief' + '
' + `
${pad2(pageIndex)} / ${pad2(totalPages)}
` + '
' ); } /** * @param {{ * tz: string; * pageIndex: number; * totalPages: number; * publicMode: boolean; * refCode: string; * }} opts */ function renderBackCover({ tz, pageIndex, totalPages, publicMode, refCode }) { const ctaHref = publicMode ? `https://worldmonitor.app/pro${refCode ? `?ref=${encodeURIComponent(refCode)}` : ''}` : 'https://worldmonitor.app'; const kicker = publicMode ? 'You\u2019re reading a shared brief' : 'Thank you for reading'; const headline = publicMode ? 'Get your own
daily brief.' : 'End of
Transmission.'; const metaLeft = publicMode ? `Subscribe \u2192` : 'worldmonitor.app'; const metaRight = publicMode ? 'worldmonitor.app' : `Next brief \u00b7 08:00 ${escapeHtml(tz)}`; return ( '
' + '
' + '' + `
${kicker}
` + `

${headline}

` + '
' + '
' + metaLeft + metaRight + '
' + `
${pad2(pageIndex)} / ${pad2(totalPages)}
` + '
' ); } // ── Shell (document + CSS + JS) ────────────────────────────────────────────── const STYLE_BLOCK = ``; /** * Inline share-button client. The hosted magazine route has already * derived the share URL server-side (it has the userId, issueDate, * and BRIEF_SHARE_SECRET — the same inputs the share-url endpoint * uses) and embedded it as `data-share-url` on the button. At click * time we just invoke navigator.share with a clipboard fallback. * * No network, no auth — the per-user magazine route's HMAC token * check already proved this reader is authorised to share the brief * they are viewing. Deriving the URL at render time instead of click * time also means the button works in a fresh tab with no Clerk * session context (common path: reader opened the magazine from an * email link in a browser they're not signed into). * * Emitted only for non-public views AND only when data-share-url is * present on the button (i.e. BRIEF_SHARE_SECRET was configured). */ const SHARE_SCRIPT = ``; const NAV_SCRIPT = ``; // ── Main entry ─────────────────────────────────────────────────────────────── /** * Replace per-user / personal fields with generic placeholders so a * brief can be rendered on the unauth'd public share mirror without * leaking the recipient's name or the LLM-generated whyMatters (which * is framed as direct advice to that specific reader). * * Runs AFTER assertBriefEnvelope so the full contract is still * enforced on the input — we never loosen validation for the public * path, only redact the output. * * Lead-field handling (v3, 2026-04-25): the personalised `digest.lead` * can carry profile context (watched assets, region preferences) and * MUST NEVER be served on the public surface. v3 envelopes carry * `digest.publicLead` — a non-personalised parallel synthesis from * generateDigestProsePublic — which we substitute into the `lead` * slot so all downstream renderers stay agnostic to the public/ * personalised distinction. When `publicLead` is absent (v2 * envelopes still in the 7-day TTL window, or v3 envelopes where * the publicLead generation failed), we substitute an EMPTY string * — the renderer's pull-quote block reads "no pull-quote" for empty * leads (per renderDigestGreeting), so the page renders without * leaking personalised content. NEVER fall through to the original * `lead`. Codex Round-2 High (security). * * @param {BriefData} data * @returns {BriefData} */ function redactForPublic(data) { const safeLead = typeof data.digest?.publicLead === 'string' && data.digest.publicLead.length > 0 ? data.digest.publicLead : ''; // Public signals: substitute the publicSignals array (also produced // by generateDigestProsePublic with profile=null) when present. // When absent, EMPTY the signals array — the renderer's hasSignals // gate then omits the entire "04 · Signals" page rather than // serving the personalised forward-looking phrases (which can echo // the user's watched assets / regions). const safeSignals = Array.isArray(data.digest?.publicSignals) && data.digest.publicSignals.length > 0 ? data.digest.publicSignals : []; // Public threads: substitute publicThreads when present (preferred // — the public synthesis still produces topic clusters from story // content). When absent, fall back to category-derived stubs so // the threads page still renders without leaking any personalised // phrasing the original `threads` array might carry. const safeThreads = Array.isArray(data.digest?.publicThreads) && data.digest.publicThreads.length > 0 ? data.digest.publicThreads : derivePublicThreadsStub(data.stories); return { ...data, user: { ...data.user, name: 'WorldMonitor' }, digest: { ...data.digest, lead: safeLead, signals: safeSignals, threads: safeThreads, }, stories: data.stories.map((s) => ({ ...s, whyMatters: 'Subscribe to WorldMonitor Brief to see the full editorial on this story.', })), }; } /** * Category-derived threads fallback for the public surface when the * envelope lacks `publicThreads`. Mirrors deriveThreadsFromStories * in shared/brief-filter.js (the composer's stub path) — keeps the * fallback shape identical to what v2 envelopes already render with. * * @param {Array<{ category?: unknown }>} stories * @returns {Array<{ tag: string; teaser: string }>} */ function derivePublicThreadsStub(stories) { if (!Array.isArray(stories) || stories.length === 0) { return [{ tag: 'World', teaser: 'One thread on the desk today.' }]; } const byCategory = new Map(); for (const s of stories) { const tag = typeof s?.category === 'string' && s.category.length > 0 ? s.category : 'World'; byCategory.set(tag, (byCategory.get(tag) ?? 0) + 1); } const sorted = [...byCategory.entries()].sort((a, b) => b[1] - a[1]); return sorted.slice(0, 6).map(([tag, count]) => ({ tag, teaser: count === 1 ? 'One thread on the desk today.' : `${count} threads on the desk today.`, })); } /** * @param {BriefEnvelope} envelope * @param {{ publicMode?: boolean; refCode?: string; shareUrl?: string }} [options] * @returns {string} */ export function renderBriefMagazine(envelope, options = {}) { assertBriefEnvelope(envelope); const publicMode = options.publicMode === true; // refCode shape is validated at the route boundary; the renderer // still HTML-escapes it before interpolation so this is belt-and- // suspenders against any accidental leak through that boundary. const refCode = typeof options.refCode === 'string' ? options.refCode : ''; // shareUrl is expected to be an absolute https URL produced by // buildPublicBriefUrl at the route level. We accept anything // non-empty here and still escape it into the attribute; if the // string is malformed the button's click handler simply fails open // (prompt fallback). Suppressed entirely on publicMode. const shareUrl = !publicMode && typeof options.shareUrl === 'string' && options.shareUrl.length > 0 ? options.shareUrl : ''; const rawData = publicMode ? redactForPublic(envelope.data) : envelope.data; const { user, issue, date, dateLong, digest, stories } = rawData; const [, month, day] = date.split('-'); const dateShort = `${day}.${month}`; const threads = digest.threads; const hasSignals = digest.signals.length > 0; const splitThreads = threads.length > MAX_THREADS_PER_PAGE; // Total page count is fully data-derived, computed up front, so every // page renderer knows its position without a two-pass build. const totalPages = 1 // cover + 1 // digest 01 greeting + 1 // digest 02 numbers + (splitThreads ? 2 : 1) // digest 03 on the desk (split if needed) + (hasSignals ? 1 : 0) // digest 04 signals (conditional) + stories.length + 1; // back cover /** @type {string[]} */ const pagesHtml = []; /** @type {number[]} */ const digestIndexes = []; let p = 0; pagesHtml.push( renderCover({ dateLong, issue, storyCount: stories.length, pageIndex: ++p, totalPages, greeting: digest.greeting, }), ); digestIndexes.push(p); pagesHtml.push( renderDigestGreeting({ greeting: digest.greeting, lead: digest.lead, dateShort, pageIndex: ++p, totalPages, }), ); digestIndexes.push(p); pagesHtml.push( renderDigestNumbers({ numbers: digest.numbers, date, dateShort, pageIndex: ++p, totalPages, }), ); const threadsPages = splitThreads ? [threads.slice(0, Math.ceil(threads.length / 2)), threads.slice(Math.ceil(threads.length / 2))] : [threads]; threadsPages.forEach((slice, i) => { const label = threadsPages.length === 1 ? 'Digest / 03 — On The Desk' : `Digest / 03${i === 0 ? 'a' : 'b'} — On The Desk`; const heading = i === 0 ? 'What the desk is watching.' : '\u2026 continued.'; digestIndexes.push(p); pagesHtml.push( renderDigestThreadsPage({ threads: slice, dateShort, label, heading, includeEndMarker: i === threadsPages.length - 1 && !hasSignals, pageIndex: ++p, totalPages, }), ); }); if (hasSignals) { digestIndexes.push(p); pagesHtml.push( renderDigestSignals({ signals: digest.signals, dateShort, pageIndex: ++p, totalPages, }), ); } stories.forEach((story, i) => { pagesHtml.push( renderStoryPage({ story, rank: i + 1, palette: i % 2 === 0 ? 'light' : 'dark', pageIndex: ++p, totalPages, issueDate: date, }), ); }); pagesHtml.push( renderBackCover({ tz: user.tz, pageIndex: ++p, totalPages, publicMode, refCode, }), ); const title = `WorldMonitor Brief · ${escapeHtml(dateLong)}`; // In public view: the per-hash mirror is noindexed via the HTTP // header AND a meta tag, and we prepend a subscribe strip pointing // at /pro (with optional referral attribution). const publicStripHref = `https://worldmonitor.app/pro${refCode ? `?ref=${encodeURIComponent(refCode)}` : ''}`; const publicStripHtml = publicMode ? '
' + 'WorldMonitor Brief \u00b7 shared issue' // Match renderBackCover's pattern: escapeHtml on the full href // even though encodeURIComponent already handles HTML-special // chars inside refCode — consistency for anyone auditing XSS // hygiene, and a safety net if the route boundary loosens. + `` + 'Subscribe \u2192' + '
' : ''; // Only render the Share button on authenticated (non-public) views // AND only when the route was able to derive a share URL (i.e. // BRIEF_SHARE_SECRET is configured and the pointer write // succeeded). The URL is embedded as data-share-url and read at // click time by SHARE_SCRIPT — no fetch, no auth required // client-side. const shareButtonHtml = shareUrl ? `` : ''; const headMeta = publicMode ? '' : ''; return ( '' + '' + '' + '' + '' + headMeta + `${title}` + '' + '' + `` + STYLE_BLOCK + '' + '' + LOGO_SYMBOL + publicStripHtml + shareButtonHtml + `
` + pagesHtml.join('') + '
' + '' + '
← → / swipe / scroll
' + (shareUrl ? SHARE_SCRIPT : '') + NAV_SCRIPT + '' + '' ); }