From 3857fb0af8c0bc5d79fff315452f66fb4ead0ef0 Mon Sep 17 00:00:00 2001 From: Robert Wagner Date: Wed, 22 Jul 2026 12:06:35 -0400 Subject: [PATCH 1/2] Make timestamps in transcripts clickable, use RSS transcripts --- CLAUDE.md | 9 +- astro.config.mjs | 6 + src/components/Player.tsx | 26 ++- src/components/episode/MarkdownTranscript.tsx | 39 ++++ src/components/episode/Transcript.tsx | 54 +++++ src/components/state.ts | 17 ++ src/lib/rehype-transcript-timestamps.mjs | 101 +++++++++ src/lib/rss.ts | 67 +++++- src/lib/transcript.ts | 164 ++++++++++++++ src/pages/[episode].astro | 42 +++- src/pages/[episode].html.md.ts | 7 + tests/e2e/episode.spec.ts | 71 +++++- .../unit/rehype-transcript-timestamps.test.ts | 89 ++++++++ tests/unit/transcript.test.ts | 202 ++++++++++++++++++ 14 files changed, 881 insertions(+), 13 deletions(-) create mode 100644 src/components/episode/MarkdownTranscript.tsx create mode 100644 src/components/episode/Transcript.tsx create mode 100644 src/lib/rehype-transcript-timestamps.mjs create mode 100644 src/lib/transcript.ts create mode 100644 tests/unit/rehype-transcript-timestamps.test.ts create mode 100644 tests/unit/transcript.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 151d6c2b..aec94e15 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,7 +64,14 @@ connection is configured in `db/index.ts`. - `src/lib/` — Core utilities: RSS fetching, image optimization, LLM content generation. - `src/content/transcripts/` — Markdown transcript files named by episode - number. + number. When one is absent, the site falls back to the transcript referenced + by the feed's `` tag (fetched/parsed in + `src/lib/transcript.ts`). Both sources render with clickable timestamps that + seek the player: RSS paragraphs via the `episode/Transcript` island, and + markdown `[HH:MM:SS]` timestamps via the `rehype-transcript-timestamps` + plugin (registered in `astro.config.mjs`) plus the `episode/MarkdownTranscript` + island. Note: changing that rehype plugin needs a dev server restart to take + effect, since Astro's content render cache doesn't reload it on hot-reload. - `src/layouts/Layout.astro` — Single shared layout. - `db/` — Database schema (`schema.ts`), connection (`index.ts`), seed script (`seed.ts`), and static data files (`data/`). diff --git a/astro.config.mjs b/astro.config.mjs index 998dcd48..cc4fb4f1 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -4,6 +4,8 @@ import sitemap from '@astrojs/sitemap'; import tailwindcss from '@tailwindcss/vite'; import vercel from '@astrojs/vercel'; +import rehypeTranscriptTimestamps from './src/lib/rehype-transcript-timestamps.mjs'; + // https://astro.build/config export default defineConfig({ output: 'static', @@ -29,6 +31,10 @@ export default defineConfig({ build: { inlineStylesheets: 'always' }, + markdown: { + // Makes bracketed timestamps in markdown transcripts clickable for seeking. + rehypePlugins: [rehypeTranscriptTimestamps] + }, experimental: { clientPrerender: true }, diff --git a/src/components/Player.tsx b/src/components/Player.tsx index 84f74ef7..2885b087 100644 --- a/src/components/Player.tsx +++ b/src/components/Player.tsx @@ -1,6 +1,6 @@ import { useEffect, useState, useRef } from 'preact/hooks'; -import { currentEpisode, isMuted, isPlaying } from '../components/state'; +import { currentEpisode, isMuted, isPlaying, seekTo } from '../components/state'; import MuteButton from './player/MuteButton'; import PlayButton from './player/PlayButton'; import PlaybackRateButton from './player/PlaybackRateButton'; @@ -64,6 +64,30 @@ export default function Player() { } }, [isPlaying.value]); + useEffect(() => { + const target = seekTo.value; + const player = audioPlayer.current; + if (target === null || !player) { + return; + } + + const applySeek = () => { + player.currentTime = target; + isPlaying.value = true; + player.play(); + seekTo.value = null; + }; + + // If the episode was just switched, its metadata isn't loaded yet and the + // seek wouldn't stick — wait for it. Otherwise seek immediately. + if (player.readyState >= 1 /* HAVE_METADATA */) { + applySeek(); + } else { + player.addEventListener('loadedmetadata', applySeek, { once: true }); + return () => player.removeEventListener('loadedmetadata', applySeek); + } + }, [seekTo.value]); + useEffect(() => { const duration = audioPlayer.current?.duration ?? 0; if (duration > 0 && currentTime >= duration - 0.01) { diff --git a/src/components/episode/MarkdownTranscript.tsx b/src/components/episode/MarkdownTranscript.tsx new file mode 100644 index 00000000..7f9dd8fe --- /dev/null +++ b/src/components/episode/MarkdownTranscript.tsx @@ -0,0 +1,39 @@ +import type { ComponentChildren, JSX } from 'preact'; + +import { currentEpisode, seekToEpisode } from '../state'; + +type Props = { + episode: NonNullable<(typeof currentEpisode)['value']>; + children: ComponentChildren; +}; + +/** + * Wraps a server-rendered markdown transcript and makes its timestamp buttons + * (injected by the `rehype-transcript-timestamps` plugin) seek the player. + * + * The markdown HTML is rendered by Astro and passed in as children, so a single + * delegated click handler on the container drives every `[data-seek]` button + * rather than hydrating each one. + */ +export default function MarkdownTranscript({ episode, children }: Props) { + function handleClick(event: JSX.TargetedMouseEvent) { + const button = (event.target as HTMLElement).closest('[data-seek]'); + if (!button) { + return; + } + + const seconds = Number(button.getAttribute('data-seek')); + if (Number.isFinite(seconds)) { + seekToEpisode(episode, seconds); + } + } + + return ( +
+ {children} +
+ ); +} diff --git a/src/components/episode/Transcript.tsx b/src/components/episode/Transcript.tsx new file mode 100644 index 00000000..f5fd53d9 --- /dev/null +++ b/src/components/episode/Transcript.tsx @@ -0,0 +1,54 @@ +import { currentEpisode, seekToEpisode } from '../state'; +import type { TranscriptParagraph } from '../../lib/transcript'; + +type Props = { + episode: NonNullable<(typeof currentEpisode)['value']>; + paragraphs: Array; +}; + +function formatTimestamp(seconds: number): string { + const total = Math.floor(seconds); + const hours = Math.floor(total / 3600); + const minutes = Math.floor((total % 3600) / 60); + const secs = total % 60; + const pad = (value: number) => value.toString().padStart(2, '0'); + + return hours > 0 + ? `${hours}:${pad(minutes)}:${pad(secs)}` + : `${minutes}:${pad(secs)}`; +} + +/** + * Renders an RSS-sourced transcript as paragraphs, each prefixed with a + * clickable timestamp that jumps the audio player to that moment (starting the + * episode first if it isn't the one currently loaded). + */ +export default function Transcript({ episode, paragraphs }: Props) { + function jumpTo(start: number) { + seekToEpisode(episode, start); + } + + return ( +
+ {paragraphs.map((paragraph, index) => { + const start = paragraph.start; + + return ( +

+ {start !== undefined && ( + + )} + {paragraph.text} +

+ ); + })} +
+ ); +} diff --git a/src/components/state.ts b/src/components/state.ts index bbd2212c..c9920b17 100644 --- a/src/components/state.ts +++ b/src/components/state.ts @@ -8,5 +8,22 @@ export const currentEpisode = signal(null); + +// Load the given episode (if it isn't already current) and ask the player to +// seek to `seconds`. Shared by the RSS and markdown transcript timestamps. +export function seekToEpisode( + episode: NonNullable<(typeof currentEpisode)['value']>, + seconds: number +) { + if (currentEpisode.value?.id !== episode.id) { + currentEpisode.value = { ...episode }; + } + seekTo.value = seconds; +} + // Search state export const isSearchOpen = signal(false); diff --git a/src/lib/rehype-transcript-timestamps.mjs b/src/lib/rehype-transcript-timestamps.mjs new file mode 100644 index 00000000..df414784 --- /dev/null +++ b/src/lib/rehype-transcript-timestamps.mjs @@ -0,0 +1,101 @@ +// Rehype plugin that turns bracketed transcript timestamps like `[00:01:23]` +// or `[04:12]` into clickable buttons that seek the audio player. Only the +// markdown transcripts in `src/content/transcripts` contain this pattern, so +// this plugin effectively only affects them. +// +// The button keeps the original bracketed label as its text and carries the +// resolved offset (in seconds) in `data-seek`; the client-side markdown +// transcript island reads that attribute to drive the player. The class list +// mirrors the RSS transcript timestamps (`Transcript.tsx`) so both look the +// same. Those utilities are already emitted by Tailwind from that component, +// so listing them here adds no new CSS. +const TIMESTAMP = /\[(\d{1,2}):(\d{2})(?::(\d{2}))?\]/g; + +const TIMESTAMP_CLASS = [ + 'transcript-timestamp', + 'mr-1', + 'align-baseline', + 'font-mono', + 'text-sm', + 'font-medium', + 'text-violet-600', + 'tabular-nums', + 'no-underline', + 'transition-colors', + 'hover:text-violet-500', + 'dark:text-cyan-400', + 'dark:hover:text-cyan-300' +]; + +function toSeconds(hours, minutes, seconds) { + // `[MM:SS]` arrives as (minutes, seconds) with `seconds` undefined. + return seconds === undefined + ? Number(hours) * 60 + Number(minutes) + : Number(hours) * 3600 + Number(minutes) * 60 + Number(seconds); +} + +function timestampButton(label, seconds) { + return { + type: 'element', + tagName: 'button', + properties: { + type: 'button', + className: [...TIMESTAMP_CLASS], + 'data-seek': String(seconds), + 'aria-label': `Play from ${label}` + }, + children: [{ type: 'text', value: label }] + }; +} + +function splitTextNode(value) { + const nodes = []; + let lastIndex = 0; + let match; + + TIMESTAMP.lastIndex = 0; + while ((match = TIMESTAMP.exec(value)) !== null) { + const [label, a, b, c] = match; + if (match.index > lastIndex) { + nodes.push({ type: 'text', value: value.slice(lastIndex, match.index) }); + } + nodes.push(timestampButton(label, toSeconds(a, b, c))); + lastIndex = match.index + label.length; + } + + if (lastIndex < value.length) { + nodes.push({ type: 'text', value: value.slice(lastIndex) }); + } + + return nodes; +} + +function transform(node) { + if (!node.children || node.children.length === 0) { + return; + } + + const nextChildren = []; + for (const child of node.children) { + if ( + child.type === 'text' && + // Cheap guard so we only rebuild text nodes that actually contain a + // timestamp. + child.value.includes('[') && + TIMESTAMP.test(child.value) + ) { + nextChildren.push(...splitTextNode(child.value)); + } else { + transform(child); + nextChildren.push(child); + } + } + + node.children = nextChildren; +} + +export default function rehypeTranscriptTimestamps() { + return (tree) => { + transform(tree); + }; +} diff --git a/src/lib/rss.ts b/src/lib/rss.ts index c5ffa719..8b50cc41 100644 --- a/src/lib/rss.ts +++ b/src/lib/rss.ts @@ -1,6 +1,14 @@ import { htmlToText } from 'html-to-text'; import parseFeed from 'rss-to-json'; -import { array, number, object, optional, parse, string } from 'valibot'; +import { + array, + number, + object, + optional, + parse, + string, + union +} from 'valibot'; import { optimizeImage } from './optimize-episode-image'; import { dasherize } from '../utils/dasherize'; @@ -29,6 +37,40 @@ export interface Episode { src: string; type: string; }; + // A transcript referenced by the feed's `` tag, if any. + // Used as a fallback when no explicit markdown transcript is provided for the + // episode. + transcriptUrl?: string; + transcriptType?: string; +} + +// A single `` entry from the RSS feed. +const TranscriptSchema = object({ + url: string(), + type: optional(string()), + language: optional(string()), + rel: optional(string()) +}); + +// A feed may list zero, one, or several transcripts per episode. Prefer a JSON +// transcript (Flightcast's format), then VTT, then whatever comes first. +function pickTranscript( + transcript: + | { url: string; type?: string } + | Array<{ url: string; type?: string }> + | undefined +) { + if (!transcript) { + return undefined; + } + + const list = Array.isArray(transcript) ? transcript : [transcript]; + + return ( + list.find((t) => t.type?.toLowerCase().includes('json')) ?? + list.find((t) => t.type?.toLowerCase().includes('vtt')) ?? + list[0] + ); } let showInfoCache: Show | null = null; @@ -63,14 +105,19 @@ export async function getAllEpisodes() { published: number(), description: string(), content_encoded: optional(string()), + podcast_transcript: optional( + union([TranscriptSchema, array(TranscriptSchema)]) + ), itunes_duration: number(), itunes_episode: optional(number()), itunes_episodeType: string(), itunes_image: optional(object({ href: optional(string()) })), + // A feed may list several enclosures (e.g. the audio file plus a + // generated cover image); the image enclosure has no `type`. enclosures: array( object({ url: string(), - type: string() + type: optional(string()) }) ) }) @@ -92,6 +139,7 @@ export async function getAllEpisodes() { title, enclosures, published, + podcast_transcript, itunes_duration, itunes_episode, itunes_episodeType, @@ -101,6 +149,11 @@ export async function getAllEpisodes() { itunes_episodeType === 'bonus' ? 'Bonus' : `${itunes_episode}`; const episodeSlug = dasherize(title); const episodeContent = content_encoded || description; + const transcript = pickTranscript(podcast_transcript); + const audioEnclosure = + enclosures.find((enclosure) => + enclosure.type?.startsWith('audio') + ) ?? enclosures[0]; return { id, @@ -113,10 +166,12 @@ export async function getAllEpisodes() { episodeSlug, episodeThumbnail: await optimizeImage(itunes_image?.href), published, - audio: enclosures.map((enclosure) => ({ - src: enclosure.url, - type: enclosure.type - }))[0] + transcriptUrl: transcript?.url, + transcriptType: transcript?.type, + audio: { + src: audioEnclosure.url, + type: audioEnclosure.type ?? 'audio/mpeg' + } }; } ) diff --git a/src/lib/transcript.ts b/src/lib/transcript.ts new file mode 100644 index 00000000..e08a413f --- /dev/null +++ b/src/lib/transcript.ts @@ -0,0 +1,164 @@ +import type { Episode } from './rss'; + +// Roughly how many characters to accumulate before starting a new paragraph +// when grouping transcript segments. Auto-generated transcripts have no real +// paragraph boundaries, so we group short segments into readable chunks. +const PARAGRAPH_TARGET_CHARS = 600; + +// A paragraph of transcript text, optionally tagged with the time (in seconds) +// at which it begins so the UI can offer a "jump to this moment" control. +export interface TranscriptParagraph { + start?: number; + text: string; +} + +interface JsonTranscript { + text?: string; + segments?: Array<{ text?: string; start?: number }>; +} + +// Cache parsed transcripts by URL so the episode page and its `.html.md` +// counterpart don't fetch the same transcript twice during a build. +const cache = new Map | null>(); + +/** + * Fetch the transcript referenced by the episode's RSS `` + * tag and return it as an array of paragraphs (each with an optional start + * time). Returns `null` when the episode has no RSS transcript, or when it + * can't be fetched or parsed. + */ +export async function getRssTranscriptParagraphs( + episode: Episode +): Promise | null> { + const url = episode.transcriptUrl; + if (!url) { + return null; + } + + const cached = cache.get(url); + if (cached !== undefined) { + return cached; + } + + let paragraphs: Array | null = null; + + try { + const response = await fetch(url); + if (response.ok) { + const raw = await response.text(); + const type = + episode.transcriptType || response.headers.get('content-type') || ''; + paragraphs = parseTranscript(raw, type); + } + } catch { + paragraphs = null; + } + + cache.set(url, paragraphs); + return paragraphs; +} + +/** + * Same as `getRssTranscriptParagraphs`, but joined into a single markdown/plain + * text string (paragraphs separated by blank lines). Returns `null` when no + * usable transcript is available. + */ +export async function getRssTranscriptText( + episode: Episode +): Promise { + const paragraphs = await getRssTranscriptParagraphs(episode); + return paragraphs?.length + ? paragraphs.map((paragraph) => paragraph.text).join('\n\n') + : null; +} + +function parseTranscript( + raw: string, + type: string +): Array | null { + const lower = type.toLowerCase(); + + if (lower.includes('json')) { + let data: JsonTranscript; + try { + data = JSON.parse(raw); + } catch { + return null; + } + + if (Array.isArray(data.segments) && data.segments.length > 0) { + const segments: Array<{ text: string; start?: number }> = []; + for (const segment of data.segments) { + const text = segment.text?.trim(); + if (text) { + segments.push({ text, start: segment.start }); + } + } + if (segments.length > 0) { + return groupSegments(segments); + } + } + + if (typeof data.text === 'string' && data.text.trim()) { + return splitParagraphs(data.text); + } + + return null; + } + + // VTT, SRT, or plain text: strip cue metadata and fall back to paragraph + // splitting on blank lines. Timing information isn't recovered here. + const stripped = stripCues(raw); + return stripped ? splitParagraphs(stripped) : null; +} + +/** + * Group fine-grained transcript segments into readable paragraphs, breaking + * once a paragraph reaches roughly `PARAGRAPH_TARGET_CHARS`. Each paragraph is + * tagged with the start time of its first segment. + */ +function groupSegments( + segments: Array<{ text: string; start?: number }> +): Array { + const paragraphs: Array = []; + let current = ''; + let start: number | undefined; + + for (const segment of segments) { + if (!current) { + start = segment.start; + } + current = current ? `${current} ${segment.text}` : segment.text; + if (current.length >= PARAGRAPH_TARGET_CHARS) { + paragraphs.push({ start, text: current }); + current = ''; + } + } + + if (current) { + paragraphs.push({ start, text: current }); + } + + return paragraphs; +} + +function splitParagraphs(text: string): Array { + return text + .split(/\n{2,}/) + .map((paragraph) => paragraph.replace(/\s+/g, ' ').trim()) + .filter(Boolean) + .map((paragraph) => ({ text: paragraph })); +} + +/** + * Remove WebVTT/SRT scaffolding (the `WEBVTT` header, numeric cue indexes, and + * `00:00:00.000 --> 00:00:00.000` timing lines), leaving just the spoken text. + */ +function stripCues(raw: string): string { + return raw + .replace(/^WEBVTT.*$/gim, '') + .replace(/^\d+\s*$/gm, '') + .replace(/^.*-->.*$/gm, '') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} diff --git a/src/pages/[episode].astro b/src/pages/[episode].astro index ab634278..c00087ea 100644 --- a/src/pages/[episode].astro +++ b/src/pages/[episode].astro @@ -16,6 +16,8 @@ import { Schema } from 'astro-seo-schema'; import FormattedDate from '../components/FormattedDate'; import CreatorsAndGuests from '../components/episode/CreatorsAndGuests.astro'; import Sponsors from '../components/episode/Sponsors.astro'; +import MarkdownTranscript from '../components/episode/MarkdownTranscript'; +import RssTranscript from '../components/episode/Transcript'; import PlayButton from '../components/player/PlayButton'; import FullPlayButton from '../components/FullPlayButton'; import UFOIllustration from '../components/illustrations/UFOIllustration.astro'; @@ -23,6 +25,10 @@ import { generateDocumentLinkTag } from '@bryanguffey/astro-standard-site'; import Layout from '../layouts/Layout.astro'; import { getAllEpisodes, getShowInfo } from '../lib/rss'; +import { + getRssTranscriptParagraphs, + type TranscriptParagraph +} from '../lib/transcript'; import { getDocumentRkeys } from '../lib/standardSite'; import { dasherize } from '../utils/dasherize'; @@ -48,6 +54,7 @@ export async function getStaticPaths() { const { episode } = Astro.props; let Transcript; +let rssTranscript: Array | null = null; if (episode.episodeNumber && episode.episodeNumber !== 'Bonus') { Transcript = await getEntry('transcripts', episode.episodeNumber); @@ -57,6 +64,23 @@ if (episode.episodeNumber && episode.episodeNumber !== 'Bonus') { } } +// Fall back to the transcript referenced by the RSS feed's +// `` tag when no explicit markdown transcript was provided. +if (!Transcript) { + rssTranscript = await getRssTranscriptParagraphs(episode); +} + +const hasTranscript = Boolean(Transcript) || Boolean(rssTranscript?.length); + +// The subset of episode fields the player needs, shared by both transcript +// timestamp variants so clicking one can load this episode. +const playerEpisode = { + audio: episode.audio, + episodeNumber: episode.episodeNumber, + id: episode.id, + title: episode.title +}; + const canonicalURL = new URL(`/${episode.episodeSlug}`, Astro.url); const db = createDb( @@ -212,7 +236,7 @@ const documentLinkTag = { - Transcript ? ( + hasTranscript ? (
-
- -
+ { + Transcript ? ( + + + + ) : ( + + ) + }