From ce2a54ff4c61a65df1c3b6fca72366a0ad63c0d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 02:16:11 +0000 Subject: [PATCH 1/3] feat(episodes): support per-episode cover art and srt transcripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover art and subtitles are resolved by path convention instead of frontmatter, so an episode picks them up as soon as the files land and renders correctly while they are missing: - src/assets//cover.{jpg,jpeg,png,webp} renders beside the title and feeds JSON-LD `image` plus the RSS . The generated 1200x630 Satori image stays as og:image, since 1:1 is the wrong ratio for social cards. - public/subtitles/.srt is parsed at build time into a collapsible 文字稿 section, a download link, and JSON-LD `transcript`. The SRT parser strips inline tags, collapses repeated cues, and breaks paragraphs on sentence endings or a pause longer than 2.5s so the paragraph timestamps stay meaningful. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019uwgRsSVFa7p57WRC4gqHv --- AGENTS.md | 2 +- CLAUDE.md | 1 + src/components/EpisodeCover.astro | 19 ++++ src/components/Transcript.astro | 51 ++++++++++ src/lib/episode-media.ts | 33 +++++++ src/lib/srt.ts | 149 ++++++++++++++++++++++++++++++ src/pages/posts/[...slug].astro | 44 ++++++++- src/pages/rss.xml.ts | 9 +- 8 files changed, 301 insertions(+), 7 deletions(-) create mode 100644 src/components/EpisodeCover.astro create mode 100644 src/components/Transcript.astro create mode 100644 src/lib/episode-media.ts create mode 100644 src/lib/srt.ts diff --git a/AGENTS.md b/AGENTS.md index 5436c3a..a5e50bb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Project Structure & Module Organization -AsyncTalk is an Astro content site for a Chinese web development podcast. Source code lives in `src/`: pages in `src/pages`, layouts in `src/layouts`, shared UI in `src/components`, global styles in `src/global.css`, and content configuration in `src/content.config.ts`. Podcast episodes are MDX files in `src/content/posts` using names such as `ep59.mdx`. Episode media belongs under `src/assets//` when imported by Astro; public static files live in `public/`. +AsyncTalk is an Astro content site for a Chinese web development podcast. Source code lives in `src/`: pages in `src/pages`, layouts in `src/layouts`, shared UI in `src/components`, global styles in `src/global.css`, and content configuration in `src/content.config.ts`. Podcast episodes are MDX files in `src/content/posts` using names such as `ep59.mdx`. Episode media belongs under `src/assets//` when imported by Astro; public static files live in `public/`. Two pieces of episode media are resolved by path convention rather than frontmatter, so they appear as soon as the file lands and are skipped while it is missing: square cover art at `src/assets//cover.{jpg,jpeg,png,webp}` and subtitles at `public/subtitles/.srt`. ## Build, Test, and Development Commands diff --git a/CLAUDE.md b/CLAUDE.md index 1e14128..e797e3f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,6 +40,7 @@ This is a content-driven static site using Astro's content collections system: - **No Testing Framework**: This project currently has no automated tests. When implementing features, manual testing is required. - **Content Management**: New episodes should be added as MDX files in `src/content/posts/` following the existing naming pattern (epX.mdx). +- **Episode Media**: Cover art and subtitles are picked up by convention, not frontmatter. Drop square cover art at `src/assets//cover.{jpg,jpeg,png,webp}` and it renders beside the title and feeds JSON-LD `image` plus the RSS ``. Drop an `.srt` at `public/subtitles/.srt` and it is parsed at build time into an on-page 文字稿 section, a download link, and JSON-LD `transcript`. Both are optional; nothing breaks when they are absent. - **Build Process**: Always run `pnpm astro check` before building to catch TypeScript errors in Astro components. - **Episode Metadata**: Ensure all required frontmatter fields are properly filled when creating new episodes to maintain RSS feed compatibility. - **Chinese Language**: All content and UI copy should be in Chinese. Pay attention to proper font rendering and character encoding. \ No newline at end of file diff --git a/src/components/EpisodeCover.astro b/src/components/EpisodeCover.astro new file mode 100644 index 0000000..289e09e --- /dev/null +++ b/src/components/EpisodeCover.astro @@ -0,0 +1,19 @@ +--- +import { Image } from "astro:assets"; + +interface Props { + cover: ImageMetadata; + title: string; +} + +const { cover, title } = Astro.props; +--- + +{`${title} diff --git a/src/components/Transcript.astro b/src/components/Transcript.astro new file mode 100644 index 0000000..53c13e9 --- /dev/null +++ b/src/components/Transcript.astro @@ -0,0 +1,51 @@ +--- +import { formatTimestamp, type TranscriptParagraph } from "../lib/srt"; + +interface Props { + paragraphs: TranscriptParagraph[]; + srcUrl: string; +} + +const { paragraphs, srcUrl } = Astro.props; +--- + +
+ + ▶ +

📝 本期文字稿

+ ({paragraphs.length} 段) +
+ + + 下载 .srt 字幕文件 ↓ + + +
+ { + paragraphs.map((paragraph) => ( +

+ + [{formatTimestamp(paragraph.start)}] + + {paragraph.text} +

+ )) + } +
+
+ + diff --git a/src/lib/episode-media.ts b/src/lib/episode-media.ts new file mode 100644 index 0000000..5c75ba4 --- /dev/null +++ b/src/lib/episode-media.ts @@ -0,0 +1,33 @@ +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; + +const covers = import.meta.glob( + "/src/assets/*/cover.{jpg,jpeg,png,webp}", + { eager: true, import: "default" }, +); + +/** + * Per-episode media follows a path convention instead of frontmatter, so an + * episode lights up as soon as the file lands and stays valid while it is + * missing: + * + * - cover art: src/assets//cover.{jpg,jpeg,png,webp} + * - subtitles: public/subtitles/.srt + */ +export function getEpisodeCover(episode: string) { + const match = Object.entries(covers).find( + ([file]) => file.split("/").at(-2) === episode, + ); + + return match?.[1]; +} + +export function episodeSubtitlePath(episode: string) { + return `/subtitles/${episode}.srt`; +} + +export function readEpisodeSubtitles(episode: string) { + const file = path.join(process.cwd(), "public", "subtitles", `${episode}.srt`); + + return existsSync(file) ? readFileSync(file, "utf8") : undefined; +} diff --git a/src/lib/srt.ts b/src/lib/srt.ts new file mode 100644 index 0000000..652875e --- /dev/null +++ b/src/lib/srt.ts @@ -0,0 +1,149 @@ +export type SrtCue = { + start: number; + end: number; + text: string; +}; + +export type TranscriptParagraph = { + start: number; + text: string; +}; + +const TIMECODE = /(\d+):(\d{2}):(\d{2})[,.](\d{1,3})\s*-->\s*(\d+):(\d{2}):(\d{2})[,.](\d{1,3})/; +const PARAGRAPH_MIN_LENGTH = 60; +const PARAGRAPH_MAX_CUES = 12; +/** A silence this long reads as a new thought, so it always breaks a paragraph. */ +const PARAGRAPH_GAP = 2500; +const SENTENCE_ENDINGS = /[。!?…?!]["'”’))】」』]*$/; +const CJK = /[\u3000-\u303f\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uff00-\uffef]/; + +function toMilliseconds( + hours: string, + minutes: string, + seconds: string, + fraction: string, +) { + return ( + Number(hours) * 3600000 + + Number(minutes) * 60000 + + Number(seconds) * 1000 + + Number(fraction.padEnd(3, "0")) + ); +} + +function cleanCueText(lines: string[]) { + return lines + .join(" ") + .replace(/<\/?[a-z][^>]*>/gi, "") + .replace(/\{\\[^}]*\}/g, "") + .replace(/\s+/g, " ") + .trim(); +} + +export function parseSrt(raw: string): SrtCue[] { + const normalized = raw.replace(/^/, "").replace(/\r\n?/g, "\n"); + const cues: SrtCue[] = []; + + for (const block of normalized.split(/\n{2,}/)) { + const lines = block.split("\n").filter((line) => line.trim().length > 0); + + if (lines.length === 0) { + continue; + } + + const timeIndex = lines.findIndex((line) => TIMECODE.test(line)); + + if (timeIndex === -1) { + continue; + } + + const matched = lines[timeIndex].match(TIMECODE); + + if (!matched) { + continue; + } + + const text = cleanCueText(lines.slice(timeIndex + 1)); + + if (text.length === 0) { + continue; + } + + const previous = cues.at(-1); + + if (previous?.text === text) { + previous.end = toMilliseconds(matched[5], matched[6], matched[7], matched[8]); + continue; + } + + cues.push({ + start: toMilliseconds(matched[1], matched[2], matched[3], matched[4]), + end: toMilliseconds(matched[5], matched[6], matched[7], matched[8]), + text, + }); + } + + return cues; +} + +function joinCueText(previous: string, next: string) { + if (previous.length === 0) { + return next; + } + + const needsSpace = !CJK.test(previous.at(-1) ?? "") && !CJK.test(next.at(0) ?? ""); + + return needsSpace ? `${previous} ${next}` : previous + next; +} + +export function groupCues(cues: SrtCue[]): TranscriptParagraph[] { + const paragraphs: TranscriptParagraph[] = []; + let start = 0; + let end = 0; + let count = 0; + let text = ""; + + const flush = () => { + if (count === 0) { + return; + } + + paragraphs.push({ start, text }); + count = 0; + text = ""; + }; + + for (const cue of cues) { + if (count > 0 && cue.start - end > PARAGRAPH_GAP) { + flush(); + } + + if (count === 0) { + start = cue.start; + } + + text = joinCueText(text, cue.text); + end = cue.end; + count += 1; + + const complete = SENTENCE_ENDINGS.test(text) && text.length >= PARAGRAPH_MIN_LENGTH; + + if (complete || count >= PARAGRAPH_MAX_CUES) { + flush(); + } + } + + flush(); + + return paragraphs; +} + +export function formatTimestamp(milliseconds: number) { + const total = Math.max(0, Math.floor(milliseconds / 1000)); + const hours = Math.floor(total / 3600); + const minutes = Math.floor((total % 3600) / 60); + const seconds = total % 60; + const padded = `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`; + + return hours > 0 ? `${hours}:${padded}` : padded; +} diff --git a/src/pages/posts/[...slug].astro b/src/pages/posts/[...slug].astro index a839194..e584f65 100644 --- a/src/pages/posts/[...slug].astro +++ b/src/pages/posts/[...slug].astro @@ -21,7 +21,15 @@ import Player from "../../components/Player.astro"; import AdSense from "../../components/AdSense.astro"; import VideoEmbed from "../../components/VideoEmbed.astro"; import SlidesEmbed from "../../components/SlidesEmbed.astro"; +import EpisodeCover from "../../components/EpisodeCover.astro"; +import Transcript from "../../components/Transcript.astro"; import { SLIDES_BASE_URL } from "../../constants"; +import { + episodeSubtitlePath, + getEpisodeCover, + readEpisodeSubtitles, +} from "../../lib/episode-media"; +import { groupCues, parseSrt } from "../../lib/srt"; import { SITE_URL, bilibiliUrl as getBilibiliUrl, @@ -76,6 +84,14 @@ const sameAs = compactUrls([ getBilibiliUrl(frontData.biliUrl), ]); const title = `${frontData.title}|AsyncTalk`; + +const cover = getEpisodeCover(slug); +const coverUrl = cover + ? new URL(cover.src, Astro.site ?? SITE_URL).toString() + : undefined; +const subtitles = readEpisodeSubtitles(slug); +const transcript = subtitles ? groupCues(parseSrt(subtitles)) : []; +const transcriptText = transcript.map((paragraph) => paragraph.text).join("\n"); --- @@ -105,8 +121,9 @@ const title = `${frontData.title}|AsyncTalk`; datePublished: frontData.publicationDate.toISOString(), inLanguage: "zh-CN", episodeNumber: frontData.episodeNumber, - image: imgUrl, + image: coverUrl ? [coverUrl, imgUrl] : imgUrl, isAccessibleForFree: true, + ...(transcriptText ? { transcript: transcriptText } : {}), keywords: tags, author: authorEntities, publisher: { "@id": organizationId }, @@ -135,10 +152,15 @@ const title = `${frontData.title}|AsyncTalk`; /> ) } -

- {frontData.title} -

-

{frontData.excerpt}

+
+ {cover && } +
+

+ {frontData.title} +

+

{frontData.excerpt}

+
+