← back to Crazy News Channel Shadowman
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);