// Text shaping for the paper — isomorphic (no Node, no DB, no LLM). // Imported by server routes AND client components, so keep it dependency-free. // // Why this file exists (Daniel, 2026-08-31): the edition used to slice summaries with a // bare `.slice(0, 150)`, which cut copy mid-WORD — "the Department of T", "recently in Q". // A newspaper never breaks a word to fit a box; it breaks at a sentence, then at a word, // then says "more". clipText() encodes that order of preference. const ELLIPSIS = "\u2026"; // … // Sentence-ish endings, including closing quotes/brackets and common abbreviations guard. // With the `$` anchor: "the text ends here" counts as a sentence end (firstSentence). // Without it: used while CLIPPING, where the end of the allowed window is an artifact of // the budget, not a sentence end — including `$` there made clipText believe a cut that // landed mid-word was a finished sentence and print it with no ellipsis at all. // (Caught by tests/text-test.mjs: "…concerns an" — the word "and", severed.) const SENT_END = /(?:[.!?…]["'”’)»]\s|[.!?…]\s|$)/; const SENT_END_CLIP = /(?:[.!?…]["'”’)»]\s|[.!?…]\s)/; const ABBREV = /(?:\b(?:mr|mrs|ms|dr|prof|sr|jr|st|no|vs|etc|e\.g|i\.e|u\.s|u\.k|ph|jan|feb|mar|apr|jun|jul|aug|sep|sept|oct|nov|dec)\.)\s*$/i; // Prefer a sentence boundary, then a word boundary. Never cuts inside a word. export function clipText(s, max = 160, { ellipsis = true } = {}) { const t = String(s || "").replace(/\s+/g, " ").trim(); if (!t) return ""; if (t.length <= max) return t; // Look for a sentence end in the tail of the allowed window — a cut that lands // mid-first-sentence is only acceptable when the first sentence is longer than the box. const window = t.slice(0, max + 1); let cut = -1; const re = new RegExp(SENT_END_CLIP.source, "g"); let m; while ((m = re.exec(window))) { // ignore a terminal that is really an abbreviation ("U.S.", "St.") if (ABBREV.test(window.slice(0, m.index + 1))) continue; cut = m.index + m[0].trimEnd().length; if (cut > max * 0.5) break; // take a boundary in the back half of the window } if (cut > 0) { // We landed on a real sentence end: the teaser is a complete thought, so it gets NO // ellipsis. Hanging "…" off a finished sentence is the same lie as cutting mid-word. return t.slice(0, cut).trim(); } // No sentence end in range: cut at the last word boundary and say so with "…". const sp = window.lastIndexOf(" "); const hard = sp > max * 0.4 ? sp : max; const out = t.slice(0, hard).replace(/[,;:\-–—)]+$/, "").trim(); return ellipsis ? out + " " + ELLIPSIS : out; } // The line under a headline: 1-2 complete sentences when they fit the box. export function dek(summary, max = 180) { return clipText(summary, max); } // First complete sentence, for the "lede" slot. export function firstSentence(s, max = 260) { const t = String(s || "").replace(/\s+/g, " ").trim(); if (!t) return ""; const m = SENT_END.exec(t); const end = m ? m.index + m[0].trimEnd().length : t.length; const one = t.slice(0, Math.max(end, t.indexOf(" ") + 1)).trim(); return one.length <= max ? one : clipText(one, max); } export function wordCount(s) { return String(s || "").trim().split(/\s+/).filter(Boolean).length; } // Does the card's copy actually run out? Drives the "read on" affordance so we never // advertise more text than exists. export function isClipped(s, max = 180) { const t = String(s || "").replace(/\s+/g, " ").trim(); return t.length > max; } export { ELLIPSIS };