Skip to content

Fix eight correctness bugs, then make link previews and SEO work - #1

Merged
vcarl merged 14 commits into
mainfrom
fix/review-findings
Sep 9, 2026
Merged

Fix eight correctness bugs, then make link previews and SEO work#1
vcarl merged 14 commits into
mainfrom
fix/review-findings

Conversation

@vcarl

@vcarl vcarl commented Sep 8, 2026

Copy link
Copy Markdown
Member

Two logical halves on one branch. Review them as such — commits 1–3 are the bug fixes, 4–7 are the metadata work.


Part 1 — eight correctness bugs (24a31f9, ffacdd1, 9e7d21d)

Build, tests and npm run check were green before and after all of these. Every one failed silently.

24a31f9 — ingest / publish-transcript

  • Data loss: fetchDescriptTranscript only checked res.ok. A 200 with an empty body flowed through to replaceTranscript, which wrote the file with the whole committed transcript deleted — git was the only recovery. Now throws before writeFileSync.
  • frontMatter.people = item.people overwrote hand-curated people with [] when a feed item had no <podcast:person> tags.
  • Number("01:09:58") is NaN, so a legal HH:MM:SS <itunes:duration> silently dropped the whole feed item.

ffacdd1 — content parsing

  • A body starting directly with # Transcript never matched "\n# Transcript\n", so the entire transcript was reclassified as outline and the page rendered empty.
  • A missing date became <pubDate>Invalid Date</pubDate> in the feed, and threw RangeError partway through publish-atproto's loop — after earlier files were mutated and records already PUT. Now fails at parse time, naming the episode.
  • SRT cue ends were taken blindly from the next cue's start: 67 zero-length or reversed cues across 27 of 39 episodes, all being pushed to Transistor. Now 0, asserted across the whole corpus.
  • An out-of-order outline item could silently overwrite a heading at the same paragraph index.
  • toSeconds("") returned 0 rather than undefined.

9e7d21d — "Home" and "Episodes" both linked to /. Episodes now points at /#archive.


Part 2 — link previews and SEO (2c0a6e8, d075e18, ce8f2db, 217c1b7)

The site previously had no og:image, no sitemap, no robots.txt and no structured data, and episode <title>s said "This Month in React" twice.

Per-episode Open Graph cards. scripts/og.ts renders a 1200×630 card per episode at build time via satori + @resvg/resvg-js — show artwork on the left, episode title, month, hosts and runtime on the right. Runs in postbuild, offline and deterministic. Inter (OFL, latin subset) is committed under assets/fonts/; the show artwork is committed as public/artwork.jpg.

JPEG, not PNG. The artwork's film grain defeats PNG compression — cards were 677 KB each, 27 MB total. resvg's raw RGBA buffer now goes straight into jpeg-js at q82, skipping PNG encoding entirely: 93 KB each, 4.0 MB total, visually identical.

Full metadata. og:type/url/site_name/title/description/locale, absolute og:image with width/height/type/alt, article:published_time, and twitter:card=summary_large_image (this is what makes Discord render the card full-width instead of a thumbnail) with matching twitter:* tags. theme-color is set to the card's navy so Discord's accent bar matches the image. Episode <title> no longer repeats the site name.

Audio in the preview — best effort. Episode pages emit og:audio, og:audio:secure_url and og:audio:type. Both Discord and Slack gate inline players and neither reliably grants one to an arbitrary site, so treat this as "correct tags emitted", not "confirmed playable". Worth testing with a real link before relying on it. twitter:player was deliberately skipped — it requires Twitter allowlisting.

Discovery and structured data. public/robots.txt, a prerendered /sitemap.xml (42 URLs, /search excluded, <lastmod> per episode), PodcastSeries JSON-LD on the home page and PodcastEpisode on episode pages, with associatedMedia as an AudioObject. JSON-LD escapes every < as < so nothing in a title or description can close the <script> block — there's a test for it.


Verification

npm test 72 pass / 0 fail · tsc --noEmit clean · npm run build succeeds · npm run check 514 assertions, 0 failures. Regression tests added per bug, each confirmed failing against the pre-fix source. Generated cards were inspected visually, not just asserted to exist.

Also cleaned up

  • Episode <h1> no longer single-line-ellipsised (the title= tooltip fallback was unreachable by keyboard and touch); it wraps.
  • Skip-to-content link on every page, off-screen until focused, with id="main" on <main> — asserted on both rendering paths, since the router shell and the static document handlers build their own markup.
  • check-build.mjs: plan scaffolding comments gone, duplicate assertions removed (514 → 428 checks, all 86 accounted for as duplicates), sections renamed to describe what they check. Failure path re-verified — the script still exits non-zero on a missing output.

Still open

24% of outline anchors resolve to no section; @vitejs/plugin-rsc is enabled but unused; a handful of tests can't fail. The repo is not prettier-clean on main (20 files) — and note public/styles.css is deliberately hand-compacted with single-line rules, so running the formatter on it doubles its length and trips the "under 250 lines" build check. It should probably be added to a .prettierignore.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BcUupktNPKyqJcPwCVEhLk

vcarl and others added 5 commits September 8, 2026 16:41
- load.test.ts "sorts by date descending" asserted a one-element array
  equals itself sorted; the fixture dir holds a single episode. The
  ordering contract is already pinned directly by the episodeOrder
  tie-break test below it.
- load.test.ts's tie-break test carried a redundant re-import and a
  trailing assert.ok(await load()) unrelated to what it tests.
- srt.test.ts "the last cue runs five seconds" asserted two substrings
  of the exact SRT string pinned by the test immediately above it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BcUupktNPKyqJcPwCVEhLk
scripts/migrate.ts converted the historical reactiflux.com transcripts
into content/episodes/. That conversion has run; the output is committed
and is now the source of truth. Nothing imports the script, and it has no
package.json entry.

Removes the script, tests/migrate.test.ts, and the seven legacy-*.md
fixtures used only by it. src/content/headings.ts and
scripts/publish-transcript.ts keep insertHeadings, which
publish-transcript still uses; their doc comments no longer point at
migrate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BcUupktNPKyqJcPwCVEhLk
- publish-transcript: fetchDescriptTranscript now rejects a 200 response
  whose body is under 500 characters, so a degenerate export can no longer
  canonicalize to "" and wipe the committed transcript before writeFileSync.
- ingest: applyFeedItem only assigns `people` when the feed item actually
  carries <podcast:person> tags, matching the !== undefined guards on its
  sibling fields, instead of clobbering curated front matter with [].
- ingest: parse <itunes:duration> with toSeconds so the RFC-legal
  HH:MM:SS / MM:SS form is accepted instead of yielding NaN and silently
  dropping the feed item; the remaining skip path now warns.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BcUupktNPKyqJcPwCVEhLk
- parse.ts: detect the `# Transcript` marker at position 0, not just after a
  newline, so a body that opens with it no longer parses to zero sections.
- parse.ts: reject a missing or unparseable front matter `date` at parse time,
  naming the episode, instead of propagating "" into `<pubDate>Invalid Date</pubDate>`
  and into a mid-loop throw in publish-atproto after records are already written.
- srt.ts: clamp every cue to at least one second and never end it before its
  start; 67 zero-length/reversed cues across the 39 episodes are now zero.
- headings.ts: first item to claim a paragraph keeps it, so an out-of-order
  outline can no longer overwrite (and drop) a heading already placed.
- time.ts: `toSeconds("")`/blank returns undefined instead of 0, and more than
  three colon-separated parts is rejected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BcUupktNPKyqJcPwCVEhLk
"Home" and "Episodes" both linked to "/", making Episodes a dead nav item
on every page. Point it at /#archive and give the homepage's archive
section a matching id.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BcUupktNPKyqJcPwCVEhLk
@netlify

netlify Bot commented Sep 8, 2026

Copy link
Copy Markdown

Deploy Preview for jazzy-clafoutis-b6a109 ready!

Name Link
🔨 Latest commit d230782
🔍 Latest deploy log https://app.netlify.com/projects/jazzy-clafoutis-b6a109/deploys/6aa0ccfdd87a3e0008b096b2
😎 Deploy Preview https://deploy-preview-1--jazzy-clafoutis-b6a109.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

vcarl and others added 4 commits September 8, 2026 18:12
satori + resvg render one 1200x630 PNG per episode into
dist/client/og/<slug>.png (plus og/default.png) from the show artwork and
a committed latin-subset Inter, so the build stays offline. Wired into
postbuild ahead of pagefind; check-build asserts every card exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BcUupktNPKyqJcPwCVEhLk
Absolute og:image pointing at the generated cards, og:type/url/locale,
image dimensions, twitter:card=summary_large_image and theme-color so
Discord and Slack render a full-width embed. Episodes also carry
article:published_time and best-effort og:audio tags. cardTitle moved to
src/content/slug.ts so the episode <title> stops doubling the site name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BcUupktNPKyqJcPwCVEhLk
The artwork's film grain defeats PNG compression. Encoding resvg's RGBA
buffer straight to JPEG (quality 82) via jpeg-js drops each card from
~693 KB to ~95 KB, and dist/client/og/ from 27 MB to 4.0 MB.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BcUupktNPKyqJcPwCVEhLk
Sitemap is a prerendered server route alongside feed.xml; JSON-LD emits a
PodcastSeries on the home page and a PodcastEpisode on each episode, with
`<` escaped to < so content can never close the script block.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BcUupktNPKyqJcPwCVEhLk
@vcarl vcarl changed the title Fix eight correctness bugs found in review Fix eight correctness bugs, then make link previews and SEO work Sep 8, 2026
vcarl and others added 5 commits September 8, 2026 21:29
The episode <h1> was single-line ellipsised with only a title= tooltip as
the fallback, which is unreachable by keyboard or touch. It now wraps.

Every page also gains a skip link, off-screen until focused, so keyboard
users can jump the five-item nav; <main> gets the matching id.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BcUupktNPKyqJcPwCVEhLk
Section markers referenced an implementation plan that no longer
exists; headers now name what each section checks. Drops the
newest-episode transcript and bundled-JS checks (the per-episode loops
cover both) and the per-path existence loop in prerender coverage
(every path is already asserted by its own section).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BcUupktNPKyqJcPwCVEhLk
The router shell and the static document handlers build their own markup,
so a regression could hit one path and not the other.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BcUupktNPKyqJcPwCVEhLk
The 250-line ceiling made the stylesheet un-formattable: running Prettier
on it expands the hand-compacted single-line rules past the limit and
fails the build check. Formatting consistently is worth more than the
size ceiling, which measured lines rather than anything that matters.

.prettierignore excludes build output, the vendored fonts, and
tests/fixtures — those are golden files compared byte for byte.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BcUupktNPKyqJcPwCVEhLk
Pure formatting, no behaviour change. Verified: the built episode pages
are byte-identical before and after, and the only diff on router pages is
the Date.now() TanStack bakes into its serialized match state, which
differs between any two builds.

The markdown changes are four stray leading spaces before transcript
timestamps; tests, typecheck and the build check all pass unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BcUupktNPKyqJcPwCVEhLk
@vcarl
vcarl merged commit 4c52913 into main Sep 9, 2026
4 checks passed
@vcarl
vcarl deleted the fix/review-findings branch September 9, 2026 14:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant