Forked from Forthena (PERSONAL/forthena) and retargeted: - NO login, NO LLM: the edition is public and the ranking is deterministic (stories that name Sen. BAM lead; freshest first) — Daniel: no like/dislike or store profile for visitors. - Crawler scoped to BAM: Bing news keyword queries + Inquirer/Rappler fallback wires, with a two-tier relevance gate (named-BAM always; Aquino+legislative-context only from fallback) so op-eds and non-Bam senators are dropped. - Desks: BAM (lead), Gaming, Education, Politics, Energy, PH, Business. - Community board /board: 'Thank you Senator BAM' — nickname-only posts, HTML-stripped, URL-only rejected, IP rate-limited (3/10min, 20/day). - Yellow-gamer theme (Orbitron/Rajdhani/JetBrains Mono, dark + gold, HUD corners), supporter disclaimer in header and footer. - Tests: category/date/relevance/text suites + Mongo board suite + full HTTP e2e (all passing). Infra: 66.181.46.132 (bidaangmamamayan.com), local Mongo 7.0, nginx + certbot TLS, PM2 bama (:3000) + 15-min crawl cron.
79 lines
3.5 KiB
JavaScript
79 lines
3.5 KiB
JavaScript
// 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 };
|