← back to Crazy News Channel

tools/wire-stories.mjs

125 lines

#!/usr/bin/env node
// One-shot patch (TK-12110): wire stories-data.js + images/ into index.html.
//   node tools/wire-stories.mjs [path/to/index.html]
// Every edit is an exact-anchor replacement that must match exactly once, so a
// file that has drifted from what this was written against fails loudly instead
// of being half-patched.
import fs from "node:fs";

const file = process.argv[2] || new URL("../index.html", import.meta.url).pathname;
let src = fs.readFileSync(file, "utf8");

function edit(name, find, replace) {
  const n = src.split(find).length - 1;
  if (n !== 1) throw new Error(`${name}: anchor matched ${n} times (expected 1)`);
  src = src.replace(find, () => replace);
}

// 1. Load the seed data before the main script.
edit("data script", "\n<script>\n", '\n<script src="stories-data.js"></script>\n<script>\n');

// 2. Append the 37 seed stories to the built-ins, give the built-ins their photos,
//    and bump the storage key (migrating v1 so admin-made stories survive).
edit("defaults + storage key",
  'const STORAGE_KEY = "pandemonium24.breakingStories.v1";',
  `DEFAULT_STORIES.forEach((s) => { s.image = s.image || \`images/\${s.id}.jpg\`; });
DEFAULT_STORIES.push(...(window.P24_EXTRA_STORIES || []));

// v2 = the 40-story channel. A browser that saved the old 3-story set under v1
// keeps its stories (including admin-created ones) and gains the new ones.
const STORAGE_KEY = "pandemonium24.breakingStories.v2";
function migrateV1() {
  try {
    const old = JSON.parse(localStorage.getItem("pandemonium24.breakingStories.v1"));
    if (!Array.isArray(old)) return null;
    const have = new Set(old.map((s) => s && s.id));
    const merged = [...old, ...DEFAULT_STORIES.filter((s) => !have.has(s.id))];
    merged.forEach((s) => { if (!s.image && DEFAULT_STORIES.some((d) => d.id === s.id)) s.image = \`images/\${s.id}.jpg\`; });
    return JSON.stringify(merged);
  } catch (err) {
    return null;
  }
}`);
edit("load uses migration",
  "    const raw = localStorage.getItem(STORAGE_KEY);\n    if (!raw) return cloneStories(DEFAULT_STORIES);",
  "    const raw = localStorage.getItem(STORAGE_KEY) || migrateV1();\n    if (!raw) return cloneStories(DEFAULT_STORIES);");

// 3. Lead = the most escalated story that is still developing; concluded ones sink.
edit("computeLead",
  "  return list.reduce((lead, s) => (s.stageIndex > lead.stageIndex ? s : lead), list[0]);",
  `  const live = list.filter((s) => s.stageIndex < s.stages.length - 1);
  if (!live.length) return list[0];
  return live.reduce((lead, s) => (s.stageIndex > lead.stageIndex ? s : lead), live[0]);`);
edit("render order",
  "  els.storyGrid.innerHTML = list\n    .map((s) => storyCardHTML(s, s === lead))",
  `  const done = (s) => s.stageIndex === s.stages.length - 1;
  const ordered = [lead, ...list.filter((s) => s !== lead && !done(s)), ...list.filter((s) => s !== lead && done(s))];
  els.storyGrid.innerHTML = ordered
    .map((s) => storyCardHTML(s, s === lead))`);

// 4. Photo on every card that has one (admin-made stories don't).
edit("card photo",
  '      <div class="kicker">\n        <span class="badge-cat">${escapeHTML(story.categoryLabel)}</span>',
  '      ${story.image ? `<img class="story-photo" src="${escapeHTML(story.image)}" alt="" width="800" height="457" ${isLead ? \'fetchpriority="high"\' : \'loading="lazy"\'} onerror="this.remove()">` : ""}\n      <div class="kicker">\n        <span class="badge-cat">${escapeHTML(story.categoryLabel)}</span>');

// 5. Article page uses the real photo when there is one, else the SVG placeholder.
edit("article photo",
  "        ${photoSVG(source.id)}",
  '        ${source.image ? `<img src="${escapeHTML(source.image)}" alt="${escapeHTML(art.photoCaption || headline)}" width="800" height="457">` : photoSVG(source.id)}');

// 6. With 40 stories, screen readers get one summary at most every 8s instead of
//    one announcement per story per tick.
edit("collect updates",
  "      announce(`Update — ${story.categoryLabel}: ${story.stages[story.stageIndex].headline}`);",
  "      state.pendingUpdates = (state.pendingUpdates || []).concat(story);");
edit("throttled announce",
  "  if (anyStoryAdvanced) {\n    persistStories();",
  `  const queued = state.pendingUpdates || [];
  if (queued.length && state.elapsedSec - (state.lastAnnounceSec || -99) >= 8) {
    const latest = queued[queued.length - 1];
    const lead = \`\${latest.categoryLabel}: \${latest.stages[latest.stageIndex].headline}\`;
    announce(queued.length === 1 ? \`Update — \${lead}\` : \`\${queued.length} stories updated. Latest — \${lead}\`);
    state.pendingUpdates = [];
    state.lastAnnounceSec = state.elapsedSec;
  }

  if (anyStoryAdvanced) {
    persistStories();`);

// 7. Layout: photo styling, and a 3-up grid under a full-width lead on desktop
//    (the old 2fr/1fr layout stacked every non-lead story in one narrow column).
edit("photo css",
  ".story-card.lead { border-color: var(--accent);",
  `.story-photo {
  display: block; width: calc(100% + 2rem); max-width: none; height: auto;
  aspect-ratio: 800 / 457; object-fit: cover;
  margin: -1rem -1rem .2rem; border-radius: .6rem .6rem 0 0;
  background: var(--panel-border);
}
.story-card.concluded .story-photo { filter: grayscale(.6); }
.article-photo img { width: 100%; height: auto; border-radius: .5rem; display: block; }
.story-card.lead { border-color: var(--accent);`);
edit("desktop grid",
  `  .story-grid {
    grid-template-columns: 2fr 1fr;
    grid-template-rows: auto auto;
    align-items: start;
  }
  .story-card.lead { grid-column: 1; grid-row: 1; }
  .story-card:not(.lead) { grid-column: 2; }`,
  `  .story-grid { grid-template-columns: repeat(3, 1fr); align-items: stretch; }
  .story-card.lead {
    grid-column: 1 / -1;
    display: grid; grid-template-columns: 3fr 2fr; column-gap: 1.5rem; row-gap: .55rem;
    align-content: start;
  }
  .story-card.lead > * { grid-column: 2; }
  .story-card.lead > .story-photo {
    grid-column: 1; grid-row: 1 / span 8;
    width: calc(100% + 1rem); margin: -1rem 0 -1rem -1rem;
    height: calc(100% + 2rem); border-radius: .6rem 0 0 .6rem;
  }`);

fs.writeFileSync(file, src);
console.log("patched", file);