From 582bd6f371377bae2df18fa1b5de4fab94e25cba Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 31 Jul 2026 14:45:45 -0700 Subject: [PATCH 1/3] Announce new posts on deploy instead of on merge Deploys are manual now, so a push to main no longer means the post is live. notify-new-post.yml was mailing subscribers a link to a page that would not exist until someone ran `just deploy live`. Move the notification into that deploy. There is no push event to diff against, so both sides of the diff are RSS feeds: snapshot the live moq.dev/rss.xml before wrangler uploads, then mail every post in the freshly built dist/rss.xml that was not in the snapshot. Reading the build rather than src/pages/blog means we announce what actually shipped, with the title and description straight from the feed. Sending is unrecallable, so every uncertain case skips instead of guessing: unreachable feed, empty feed, missing snapshot, stale snapshot, or missing build. Staging never announces. Requires RESEND_API_KEY and RESEND_SEGMENT_ID in the shell, since .env.live is committed and cannot hold secrets. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QnCscaFDaj6TaBahqxXGqb --- .github/workflows/notify-new-post.yml | 42 ----- CLAUDE.md | 9 +- justfile | 4 + scripts/notify-subscribers.ts | 261 +++++++++++++++++++------- 4 files changed, 208 insertions(+), 108 deletions(-) delete mode 100644 .github/workflows/notify-new-post.yml diff --git a/.github/workflows/notify-new-post.yml b/.github/workflows/notify-new-post.yml deleted file mode 100644 index ceb3061..0000000 --- a/.github/workflows/notify-new-post.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: notify-new-post - -on: - push: - branches: ["main"] - paths: ["src/pages/blog/**/*.mdx"] - -permissions: - contents: read - -jobs: - notify: - runs-on: ubuntu-latest - timeout-minutes: 5 - - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 2 - - - uses: oven-sh/setup-bun@v2 - with: - bun-version: 1.3.4 - - - name: Find newly added blog posts - id: detect - env: - BEFORE_SHA: ${{ github.event.before }} - AFTER_SHA: ${{ github.sha }} - run: | - git diff --name-only --diff-filter=A "$BEFORE_SHA" "$AFTER_SHA" -- 'src/pages/blog/**/*.mdx' > new_posts.txt - if [ ! -s new_posts.txt ]; then - echo "skip=true" >> "$GITHUB_OUTPUT" - fi - cat new_posts.txt - - - name: Send broadcast(s) - if: steps.detect.outputs.skip != 'true' - env: - RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }} - RESEND_SEGMENT_ID: ${{ secrets.RESEND_SEGMENT_ID }} - run: bun scripts/notify-subscribers.ts diff --git a/CLAUDE.md b/CLAUDE.md index ded683e..2db1957 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,7 +15,7 @@ just dev # Start dev server with auto-open # Build & Deploy just build # Production build just deploy # Deploy to Cloudflare Pages (staging by default) -just deploy live # Deploy to production +just deploy live # Deploy to production, and email subscribers about new posts just prod # Build and preview production locally # Code Quality @@ -52,6 +52,13 @@ just fix # Auto-fix code formatting/lint issues - Cloudflare Pages via Wrangler - `just deploy` for staging, `just deploy live` for production +- Deploys are manual; nothing ships on merge to `main` + +**`just deploy live` mails the subscriber list.** `scripts/notify-subscribers.ts` snapshots the slugs in `https://moq.dev/rss.xml` before the upload, then sends a Resend broadcast for every post in the freshly built `dist/rss.xml` that wasn't in that snapshot. Subject and body come from the feed's `title` and `description`. A deploy that adds no posts sends nothing. + +This needs `RESEND_API_KEY` and `RESEND_SEGMENT_ID` in the shell. They are secrets, so they cannot live in the committed `.env.live`; use `.dev.vars` or your shell profile. Without them the deploy still succeeds and the script exits non-zero to say the announcement did not go out. + +Broadcasts cannot be recalled, so the script refuses to guess: an unreachable or empty live feed, a missing snapshot, or a missing build all skip sending rather than risk mailing the back catalogue. Any local `.mdx` under `src/pages/blog/` ships on the next `just deploy live` and gets announced, drafts included. ## Development Tips diff --git a/justfile b/justfile index 3d3211b..6238588 100644 --- a/justfile +++ b/justfile @@ -41,8 +41,12 @@ build mode="live": bun astro build --mode {{mode}} # Deploy the site to Cloudflare Pages +# On `live`, any post that wasn't already on moq.dev gets mailed to subscribers. deploy env="staging": (build env) + # Record what's live before we replace it, so we can tell what the deploy added. + bun scripts/notify-subscribers.ts snapshot --env {{env}} bun wrangler deploy --env {{env}} + bun scripts/notify-subscribers.ts send --env {{env}} dev: bun i diff --git a/scripts/notify-subscribers.ts b/scripts/notify-subscribers.ts index ff87421..b75501d 100644 --- a/scripts/notify-subscribers.ts +++ b/scripts/notify-subscribers.ts @@ -1,90 +1,221 @@ #!/usr/bin/env bun -// Sends a Resend broadcast for each newly added blog post listed in new_posts.txt -// (paths relative to repo root, one per line). Invoked by the GitHub Action -// .github/workflows/notify-new-post.yml on push to main. - -import { readFileSync } from "node:fs"; -import { basename } from "node:path"; +// Sends a Resend broadcast for each blog post that goes live in a deploy. +// +// Invoked by `just deploy live` in two phases, around the wrangler upload: +// +// snapshot - records which posts the live site already serves +// send - mails every post in the new build that the snapshot didn't have +// +// Deploys are manual, so there is no push event to diff against. Both sides of +// the diff are RSS feeds instead: the live one for what subscribers have already +// been told about, and the freshly built dist/rss.xml for what this deploy puts +// up. Reading the build rather than src/pages/blog keeps the mail honest, since +// it announces what actually shipped and reuses the exact title and description +// the feed carries. +// +// Only the `live` env notifies; staging is a no-op. Anything that leaves us +// unsure which posts are new (unreachable feed, missing snapshot, missing build) +// skips sending rather than guessing, because a broadcast cannot be recalled. + +import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; const SITE = "https://moq.dev"; const FROM = "Media over QUIC "; +const BUILT_FEED = "dist/rss.xml"; +const SNAPSHOT = join(tmpdir(), "moq-dev-published-slugs.json"); + +// A snapshot only describes the feed at the moment it was taken. Past this, assume +// the site moved on under us and refuse to treat it as "what was already live". +const SNAPSHOT_MAX_AGE_MS = 60 * 60 * 1000; -const apiKey = requireEnv("RESEND_API_KEY"); -const segmentId = requireEnv("RESEND_SEGMENT_ID"); +const FETCH_TIMEOUT_MS = 15000; -const newPostsList = readFileSync("new_posts.txt", "utf8").trim(); -if (!newPostsList) { - console.log("No new posts. Exiting."); - process.exit(0); +interface Post { + slug: string; + title: string; + description: string; + url: string; } -const paths = newPostsList.split("\n").filter(Boolean); -console.log(`Found ${paths.length} new post(s): ${paths.join(", ")}`); - -for (const path of paths) { - const rawSlug = basename(path, ".mdx"); - const slug = encodeURIComponent(rawSlug); - const fm = parseFrontmatter(readFileSync(path, "utf8")); - const title = fm.title ?? rawSlug; - const description = fm.description ?? ""; - const url = `${SITE}/blog/${slug}`; - - console.log(`Creating broadcast for "${title}" → ${url}`); - - const create = await fetch("https://api.resend.com/broadcasts", { - signal: AbortSignal.timeout(15000), - method: "POST", - headers: { - Authorization: `Bearer ${apiKey}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - segment_id: segmentId, - from: FROM, - subject: title, - html: renderHtml({ title, description, url }), - }), - }); +const [phase, ...rest] = process.argv.slice(2); +const env = flag(rest, "--env") ?? "staging"; + +if (phase === "snapshot") { + await snapshot(); +} else if (phase === "send") { + await send(); +} else { + console.error("Usage: notify-subscribers.ts --env "); + process.exit(2); +} + +// Record the slugs the live site serves right now, before the deploy replaces it. +async function snapshot() { + // Leave nothing behind for `send` to misread as this deploy's baseline. + rmSync(SNAPSHOT, { force: true }); - if (!create.ok) { - const err = await create.text(); - throw new Error(`Resend broadcast create failed (${create.status}): ${err}`); + if (env !== "live") { + console.log(`[notify] env=${env}, skipping (only live announces).`); + return; } - const { id } = (await create.json()) as { id: string }; + let slugs: string[]; + try { + const res = await fetch(`${SITE}/rss.xml`, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + slugs = parseFeed(await res.text()).map((p) => p.slug); + } catch (err) { + // Deploying is still the right thing to do; we just can't safely say what's new. + console.warn(`[notify] could not read ${SITE}/rss.xml: ${err instanceof Error ? err.message : err}`); + console.warn("[notify] no announcement will be sent for this deploy."); + return; + } - const send = await fetch(`https://api.resend.com/broadcasts/${id}/send`, { - signal: AbortSignal.timeout(15000), - method: "POST", - headers: { Authorization: `Bearer ${apiKey}` }, - }); + // An empty feed is far more likely to be a broken site than a blog with no posts, + // and treating it as "nothing is live" would mail the entire back catalogue. + if (slugs.length === 0) { + console.warn("[notify] live feed listed no posts, refusing to treat that as an empty blog."); + console.warn("[notify] no announcement will be sent for this deploy."); + return; + } + + writeFileSync(SNAPSHOT, JSON.stringify({ at: Date.now(), slugs })); + console.log(`[notify] ${slugs.length} post(s) already live.`); +} + +// Mail every post this deploy added. +async function send() { + if (env !== "live") return; + + if (!existsSync(SNAPSHOT)) { + console.warn("[notify] no snapshot from this deploy, skipping the announcement."); + return; + } + + const { at, slugs } = JSON.parse(readFileSync(SNAPSHOT, "utf8")) as { at: number; slugs: string[] }; + + // Consume it either way: a snapshot must never outlive the deploy that took it. + rmSync(SNAPSHOT, { force: true }); + + if (Date.now() - at > SNAPSHOT_MAX_AGE_MS) { + console.warn("[notify] snapshot is stale, skipping the announcement."); + return; + } + + if (!existsSync(BUILT_FEED)) { + console.warn(`[notify] ${BUILT_FEED} is missing, skipping the announcement.`); + return; + } + + const published = new Set(slugs); + const added = parseFeed(readFileSync(BUILT_FEED, "utf8")).filter((p) => !published.has(p.slug)); - if (!send.ok) { - const err = await send.text(); - throw new Error(`Resend broadcast send failed (${send.status}): ${err}`); + if (added.length === 0) { + console.log("[notify] no new posts."); + return; } - console.log(`✓ Sent broadcast ${id} for "${title}"`); + console.log(`[notify] announcing ${added.length} new post(s): ${added.map((p) => p.slug).join(", ")}`); + + // Deploy already succeeded, so surface a missing key loudly instead of failing quietly. + const apiKey = requireEnv("RESEND_API_KEY"); + const segmentId = requireEnv("RESEND_SEGMENT_ID"); + + for (const post of added) { + console.log(`Creating broadcast for "${post.title}" → ${post.url}`); + + const create = await fetch("https://api.resend.com/broadcasts", { + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + segment_id: segmentId, + from: FROM, + subject: post.title, + html: renderHtml(post), + }), + }); + + if (!create.ok) { + const err = await create.text(); + throw new Error(`Resend broadcast create failed (${create.status}): ${err}`); + } + + const { id } = (await create.json()) as { id: string }; + + const sent = await fetch(`https://api.resend.com/broadcasts/${id}/send`, { + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + method: "POST", + headers: { Authorization: `Bearer ${apiKey}` }, + }); + + if (!sent.ok) { + const err = await sent.text(); + throw new Error(`Resend broadcast send failed (${sent.status}): ${err}`); + } + + console.log(`✓ Sent broadcast ${id} for "${post.title}"`); + } +} + +// Pull the blog items out of an RSS feed. Both the live feed and the built one are +// generated by src/pages/rss.xml.js, so the same shape parses either. +function parseFeed(xml: string): Post[] { + const posts: Post[] = []; + + for (const item of xml.matchAll(/([\s\S]*?)<\/item>/g)) { + const body = item[1]; + const url = tag(body, "link"); + if (!url) continue; + + const slug = url.match(/\/blog\/([^/]+)\/?$/)?.[1]; + if (!slug) continue; + + posts.push({ + slug: decodeURIComponent(slug), + title: unescapeXml(tag(body, "title") ?? slug), + description: unescapeXml(tag(body, "description") ?? ""), + url, + }); + } + + return posts; +} + +function tag(xml: string, name: string): string | undefined { + // CDATA is not emitted today, but @astrojs/rss switches to it whenever a value + // contains markup, so handle both rather than silently dropping such a post. + const match = xml.match(new RegExp(`<${name}>(?:|([\\s\\S]*?))`)); + return match ? (match[1] ?? match[2]) : undefined; +} + +function unescapeXml(s: string): string { + return s + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/'/g, "'") + .replace(/&/g, "&"); +} + +function flag(argv: string[], name: string): string | undefined { + const i = argv.indexOf(name); + return i === -1 ? undefined : argv[i + 1]; } function requireEnv(name: string): string { const v = process.env[name]; - if (!v) throw new Error(`Missing env var: ${name}`); + if (!v) throw new Error(`Missing env var: ${name} (the deploy succeeded; the announcement did not go out)`); return v; } -function parseFrontmatter(source: string): Record { - const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---/); - if (!match) return {}; - const out: Record = {}; - for (const line of match[1].split(/\r?\n/)) { - const m = line.match(/^([A-Za-z_][\w-]*):\s*(.*)$/); - if (m) out[m[1]] = m[2].trim().replace(/^["']|["']$/g, ""); - } - return out; -} - -function renderHtml({ title, description, url }: { title: string; description: string; url: string }): string { +function renderHtml({ title, description, url }: Post): string { const safeTitle = escapeHtml(title); const safeDescription = escapeHtml(description); const safeUrl = escapeHtml(url); From 1b5011c159ea787fac86ab03cc0eb0ff94eb3ab7 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 31 Jul 2026 16:27:03 -0700 Subject: [PATCH 2/3] Pull the Resend key from 1Password on deploy Deploying had to be preceded by exporting RESEND_API_KEY, which meant a live API key sitting in a shell profile or .dev.vars. Reference it from 1Password instead and let `op run` supply it for the length of the command. op.env is committed deliberately: it maps the variable to an op:// URI and carries the (non-secret) segment id, so it holds pointers rather than values. Staging never announces, so it does not depend on 1Password at all, and a missing CLI falls back to the ambient environment rather than failing a deploy that has already gone out. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QnCscaFDaj6TaBahqxXGqb --- CLAUDE.md | 4 +++- justfile | 23 ++++++++++++++++++++++- op.env | 14 ++++++++++++++ 3 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 op.env diff --git a/CLAUDE.md b/CLAUDE.md index 2db1957..8707c95 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,7 +56,9 @@ just fix # Auto-fix code formatting/lint issues **`just deploy live` mails the subscriber list.** `scripts/notify-subscribers.ts` snapshots the slugs in `https://moq.dev/rss.xml` before the upload, then sends a Resend broadcast for every post in the freshly built `dist/rss.xml` that wasn't in that snapshot. Subject and body come from the feed's `title` and `description`. A deploy that adds no posts sends nothing. -This needs `RESEND_API_KEY` and `RESEND_SEGMENT_ID` in the shell. They are secrets, so they cannot live in the committed `.env.live`; use `.dev.vars` or your shell profile. Without them the deploy still succeeds and the script exits non-zero to say the announcement did not go out. +Credentials come from 1Password, so no secret has to sit on disk. `op.env` maps `RESEND_API_KEY` to `op://Corp/Resend/credential` and `op run` resolves it for the duration of the command. That file is committed on purpose: it holds references, not values. Install and sign in once with `brew install 1password-cli && op signin`. + +Without the 1Password CLI the recipe falls back to `RESEND_API_KEY` and `RESEND_SEGMENT_ID` from the ambient environment, and if those are missing too the deploy still succeeds while the script exits non-zero to say the announcement did not go out. `just deploy staging` never announces and never touches 1Password. Broadcasts cannot be recalled, so the script refuses to guess: an unreachable or empty live feed, a missing snapshot, or a missing build all skip sending rather than risk mailing the back catalogue. Any local `.mdx` under `src/pages/blog/` ships on the next `just deploy live` and gets announced, drafts included. diff --git a/justfile b/justfile index 6238588..19e8359 100644 --- a/justfile +++ b/justfile @@ -46,7 +46,28 @@ deploy env="staging": (build env) # Record what's live before we replace it, so we can tell what the deploy added. bun scripts/notify-subscribers.ts snapshot --env {{env}} bun wrangler deploy --env {{env}} - bun scripts/notify-subscribers.ts send --env {{env}} + just _announce {{env}} + +# Mail subscribers about anything this deploy published. +# Credentials come from 1Password (see op.env) so no secret has to live on disk. +[private] +_announce env: + #!/usr/bin/env bash + set -euo pipefail + + # Staging never announces, so don't make it depend on 1Password. + if [ "{{env}}" != "live" ]; then + exec bun scripts/notify-subscribers.ts send --env {{env}} + fi + + # Fall back to the ambient environment rather than failing outright: the deploy + # has already happened by now, and the script reports a missing key itself. + if ! command -v op >/dev/null 2>&1; then + echo "[notify] 1Password CLI not found, falling back to the ambient environment." >&2 + exec bun scripts/notify-subscribers.ts send --env {{env}} + fi + + exec op run --env-file=op.env -- bun scripts/notify-subscribers.ts send --env {{env}} dev: bun i diff --git a/op.env b/op.env new file mode 100644 index 0000000..d6d8a85 --- /dev/null +++ b/op.env @@ -0,0 +1,14 @@ +# Secret references for `just deploy live`, resolved by `op run`. +# +# This file is committed on purpose: it holds pointers, not secrets. `op run` +# swaps each op:// reference for the real value at run time and masks it in the +# output. Nothing here is sensitive on its own. +# +# Requires the 1Password CLI, signed in to an account with the Corp vault: +# brew install 1password-cli && op signin + +RESEND_API_KEY=op://Corp/Resend/credential + +# The "Blog" segment. Not a secret, just an id, so keep it inline rather than +# minting a 1Password item for it. +RESEND_SEGMENT_ID=7bfda95d-9eb6-4f29-b1f8-1c2a748ce2ff From e172d3f7b62051baaf4a1039d50aa68c3f1c7f0f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 31 Jul 2026 16:41:22 -0700 Subject: [PATCH 3/3] Keep the snapshot until every broadcast lands The snapshot was deleted the moment it was read, before any Resend call. A failed send therefore destroyed the only record that a post still needed announcing: rerunning `send` found no snapshot, and rerunning the deploy re-snapshotted the already-updated live feed, so the post looked like it had always been there. It could never be announced. Treat the snapshot as the run's to-do list instead. It survives a missing build, a missing credential, and a failed broadcast, and is removed only once there is nothing left to send. Each successful broadcast checkpoints it so a retry mails only the remainder, carrying the original timestamp so checkpointing cannot extend the staleness window. The snapshot phase now resumes an unfinished snapshot rather than overwriting it, since the current feed already contains the posts it still owes. Staging leaves it alone entirely. Reading it is also total now: unparseable and expired files both read as absent rather than throwing. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QnCscaFDaj6TaBahqxXGqb --- scripts/notify-subscribers.ts | 67 ++++++++++++++++++++++++++--------- 1 file changed, 51 insertions(+), 16 deletions(-) diff --git a/scripts/notify-subscribers.ts b/scripts/notify-subscribers.ts index b75501d..0599180 100644 --- a/scripts/notify-subscribers.ts +++ b/scripts/notify-subscribers.ts @@ -53,14 +53,24 @@ if (phase === "snapshot") { // Record the slugs the live site serves right now, before the deploy replaces it. async function snapshot() { - // Leave nothing behind for `send` to misread as this deploy's baseline. - rmSync(SNAPSHOT, { force: true }); - if (env !== "live") { + // Don't touch a pending live snapshot; a staging deploy is unrelated to it. console.log(`[notify] env=${env}, skipping (only live announces).`); return; } + // A surviving snapshot means the last deploy died partway through announcing. + // Its baseline predates those posts going live, so it is the only thing that + // still knows they are unannounced. Overwriting it with the current feed, + // which now contains them, would bury them permanently. + const pending = readSnapshot(); + if (pending) { + console.warn(`[notify] resuming the unfinished snapshot from a previous deploy (${pending.slugs.length} posts).`); + return; + } + + rmSync(SNAPSHOT, { force: true }); + let slugs: string[]; try { const res = await fetch(`${SITE}/rss.xml`, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); @@ -89,30 +99,28 @@ async function snapshot() { async function send() { if (env !== "live") return; - if (!existsSync(SNAPSHOT)) { - console.warn("[notify] no snapshot from this deploy, skipping the announcement."); - return; - } - - const { at, slugs } = JSON.parse(readFileSync(SNAPSHOT, "utf8")) as { at: number; slugs: string[] }; - - // Consume it either way: a snapshot must never outlive the deploy that took it. - rmSync(SNAPSHOT, { force: true }); - - if (Date.now() - at > SNAPSHOT_MAX_AGE_MS) { - console.warn("[notify] snapshot is stale, skipping the announcement."); + // The snapshot is this run's to-do list, so it survives anything that leaves + // work outstanding and is removed only once there is nothing left to send. + const snap = readSnapshot(); + if (!snap) { + // Either nothing was recorded, or what was there is too old to trust: + // another deploy may have announced since, and reusing it would send twice. + rmSync(SNAPSHOT, { force: true }); + console.warn("[notify] no usable snapshot from this deploy, skipping the announcement."); return; } if (!existsSync(BUILT_FEED)) { + // Keep the snapshot: a rerun with a real build can still announce these. console.warn(`[notify] ${BUILT_FEED} is missing, skipping the announcement.`); return; } - const published = new Set(slugs); + const published = new Set(snap.slugs); const added = parseFeed(readFileSync(BUILT_FEED, "utf8")).filter((p) => !published.has(p.slug)); if (added.length === 0) { + rmSync(SNAPSHOT, { force: true }); console.log("[notify] no new posts."); return; } @@ -159,8 +167,35 @@ async function send() { throw new Error(`Resend broadcast send failed (${sent.status}): ${err}`); } + // Checkpoint before the next one. If a later send dies, the retry sees this + // post as already published and mails only the remainder. `at` is carried + // over so checkpointing can't extend the staleness window indefinitely. + published.add(post.slug); + writeFileSync(SNAPSHOT, JSON.stringify({ at: snap.at, slugs: [...published] })); + console.log(`✓ Sent broadcast ${id} for "${post.title}"`); } + + // Everything landed, so there is nothing for a rerun to pick up. + rmSync(SNAPSHOT, { force: true }); +} + +// The snapshot, or undefined if there isn't a usable one. Unreadable and expired +// files both read as absent; the caller decides whether to discard or replace. +function readSnapshot(): { at: number; slugs: string[] } | undefined { + if (!existsSync(SNAPSHOT)) return undefined; + + let snap: { at: number; slugs: string[] }; + try { + snap = JSON.parse(readFileSync(SNAPSHOT, "utf8")); + } catch { + return undefined; + } + + if (typeof snap?.at !== "number" || !Array.isArray(snap.slugs)) return undefined; + if (Date.now() - snap.at > SNAPSHOT_MAX_AGE_MS) return undefined; + + return snap; } // Pull the blog items out of an RSS feed. Both the live feed and the built one are