Ryan 6b06e08c08 BidaAngMamayan: public supporter tribute news desk for Senator BAM Aquino
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.
2026-09-15 08:37:03 +00:00

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 };