From f15bddc5d3ff3a6d36bb1c2b847e1456f8c27e07 Mon Sep 17 00:00:00 2001 From: Compleet Date: Tue, 14 Jul 2026 14:10:22 +0100 Subject: [PATCH] Build out the podcast: live feed, subscribe surface, episode pages - /podcast/feed.xml: valid podcast RSS with iTunes tags and audio enclosures, populated only by episodes that have real audio - Podcast index: subscribe row with the feed address; episode rows gain guests and duration; empty state points at the live feed - Episode pages: guests/duration line, framed player with download link - Front page: compact podcast rail appears once the first episode exists - Nav shows Podcast when the feed is configured; footer links the feed Co-Authored-By: Claude Fable 5 --- src/components/Footer.astro | 1 + src/components/Header.astro | 2 +- src/content.config.ts | 1 + src/content/episodes/episode-template.md | 4 +- src/pages/index.astro | 91 ++++++++++++++++++++ src/pages/podcast/[id].astro | 43 +++++++++- src/pages/podcast/feed.xml.ts | 54 ++++++++++++ src/pages/podcast/index.astro | 105 +++++++++++++++++++++-- src/site.config.ts | 2 +- 9 files changed, 290 insertions(+), 13 deletions(-) create mode 100644 src/pages/podcast/feed.xml.ts diff --git a/src/components/Footer.astro b/src/components/Footer.astro index ee6a242..ff7cc87 100644 --- a/src/components/Footer.astro +++ b/src/components/Footer.astro @@ -11,6 +11,7 @@ import { site } from '../site.config';
RSS feed
+ Podcast feed
Public repository
diff --git a/src/components/Header.astro b/src/components/Header.astro index a624cd0..b12b018 100644 --- a/src/components/Header.astro +++ b/src/components/Header.astro @@ -12,7 +12,7 @@ const [stories, briefings, episodes, guides] = await Promise.all([ const visibleNavigation = navigation.filter((item) => { if (item.href === '/stories') return stories.length > 0; if (item.href === '/briefing') return briefings.length > 0; - if (item.href === '/podcast') return episodes.length > 0; + if (item.href === '/podcast') return episodes.length > 0 || site.podcastFeedUrl !== ''; if (item.href === '/guides') return guides.length > 0; if (item.href === '/book') return site.book.enabled; return true; diff --git a/src/content.config.ts b/src/content.config.ts index 5dbfc76..1e8d5d0 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -70,6 +70,7 @@ const episodes = defineCollection({ published: z.coerce.date(), duration: z.string().optional(), audioUrl: z.url().optional(), + audioBytes: z.number().int().positive().optional(), guests: z.array(z.string()).default([]), ...maintenance, }), diff --git a/src/content/episodes/episode-template.md b/src/content/episodes/episode-template.md index bbdbabb..926023e 100644 --- a/src/content/episodes/episode-template.md +++ b/src/content/episodes/episode-template.md @@ -11,9 +11,11 @@ status: developing draft: true duration: "00:00" guests: [] +# audioUrl: https://…/episode-001.mp3 (durable location only) +# audioBytes: 0 (file size in bytes, for the feed enclosure) --- -> Draft episode template. Add the audio URL in frontmatter only after the durable feed location is known. +> Draft episode template. Add the audio URL in frontmatter only after the durable feed location is known. Episodes without an audio URL never enter the podcast feed. ## Listen diff --git a/src/pages/index.astro b/src/pages/index.astro index cca25d7..67d9c53 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -18,6 +18,9 @@ interface FrontPageItem { const stories = (await getCollection('stories', ({ data }) => !data.draft)) .sort((a, b) => a.data.editorialOrder - b.data.editorialOrder); +const episodes = (await getCollection('episodes', ({ data }) => !data.draft)) + .sort((a, b) => b.data.published.getTime() - a.data.published.getTime()); +const latestEpisode = episodes[0]; const toneCycle = ['blue', 'paper', 'ink', 'soft', 'amend', 'paper'] as const; const frontPageItems: FrontPageItem[] = stories .filter(({ data }) => data.homepage) @@ -55,6 +58,21 @@ const sizeCycle = ['wide', 'standard', 'compact', 'compact', 'compact'] as const /> ))} + {latestEpisode && ( +
+ +
+

Podcast · Episode {latestEpisode.data.episode}

+

{latestEpisode.data.title}

+
+ +
+ )} @@ -70,6 +88,79 @@ const sizeCycle = ['wide', 'standard', 'compact', 'compact', 'compact'] as const background: var(--rule); } + .podcast-rail { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: clamp(1.25rem, 3vw, 2.5rem); + align-items: center; + margin-top: clamp(1.5rem, 3vw, 2.5rem); + padding: clamp(1.1rem, 2.5vw, 1.75rem) clamp(1.1rem, 2.5vw, 2rem); + background: var(--ink); + color: var(--paper); + } + + .rail-wave { + display: flex; + align-items: center; + gap: 4px; + height: 44px; + } + + .rail-wave i { + display: block; + width: 2px; + height: calc(25% + var(--n) * 0.7%); + background: color-mix(in srgb, var(--paper) calc(30% + var(--n) * .5%), var(--amend)); + } + + .podcast-rail .eyebrow { + margin-bottom: 0.3rem; + color: #9fb8d4; + } + + .podcast-rail h2 { + margin: 0; + font-family: 'Newsreader Variable', Georgia, serif; + font-size: clamp(1.4rem, 2.6vw, 2rem); + font-weight: 600; + letter-spacing: -0.03em; + line-height: 1.02; + } + + .podcast-rail h2 a { + text-decoration: none; + } + + .podcast-rail h2 a:hover { + color: #9fb8d4; + } + + .rail-links { + display: flex; + gap: 1.25rem; + font-size: 0.8rem; + font-weight: 600; + white-space: nowrap; + } + + .rail-links a { + text-decoration: none; + } + + .rail-links a:hover { + color: #9fb8d4; + } + + @media (max-width: 620px) { + .podcast-rail { + grid-template-columns: 1fr; + } + + .rail-wave { + display: none; + } + } + .more-stories { display: flex; justify-content: center; diff --git a/src/pages/podcast/[id].astro b/src/pages/podcast/[id].astro index 05a0f13..12658bc 100644 --- a/src/pages/podcast/[id].astro +++ b/src/pages/podcast/[id].astro @@ -23,10 +23,49 @@ const { Content } = await render(entry); status={entry.data.status} sourcePath={`src/content/episodes/${entry.id}.md`} > - {entry.data.audioUrl && } + {(entry.data.guests.length > 0 || entry.data.duration) && ( +

+ {entry.data.guests.length > 0 && With {entry.data.guests.join(', ')}} + {entry.data.duration && {entry.data.duration}} +

+ )} + {entry.data.audioUrl && ( +
+ + Download audio ↓ +
+ )} diff --git a/src/pages/podcast/feed.xml.ts b/src/pages/podcast/feed.xml.ts new file mode 100644 index 0000000..2d28282 --- /dev/null +++ b/src/pages/podcast/feed.xml.ts @@ -0,0 +1,54 @@ +import rss from '@astrojs/rss'; +import type { APIContext } from 'astro'; +import { getCollection } from 'astro:content'; +import { site } from '../../site.config'; + +const AUDIO_TYPES: Record = { + mp3: 'audio/mpeg', + m4a: 'audio/mp4', + ogg: 'audio/ogg', + opus: 'audio/opus', + wav: 'audio/wav', +}; + +function audioType(url: string): string { + const extension = new URL(url).pathname.split('.').pop()?.toLowerCase() ?? ''; + return AUDIO_TYPES[extension] ?? 'audio/mpeg'; +} + +export async function GET(context: APIContext) { + const episodes = ( + await getCollection('episodes', ({ data }) => !data.draft && Boolean(data.audioUrl)) + ).sort((a, b) => b.data.published.getTime() - a.data.published.getTime()); + + return rss({ + title: `${site.title} — the podcast`, + description: + 'Who gets to act in our name? Conversations about authority, delegation, recovery, and responsibility, with full transcripts published beside every episode.', + site: new URL('/podcast', context.site).href, + xmlns: { + itunes: 'http://www.itunes.com/dtds/podcast-1.0.dtd', + }, + customData: [ + 'en', + `${site.author.name}`, + 'false', + `${new URL('/podcast', context.site).href}`, + ].join(''), + items: episodes.map((entry) => ({ + title: entry.data.title, + description: entry.data.description, + link: `/podcast/${entry.id}`, + pubDate: entry.data.published, + enclosure: { + url: entry.data.audioUrl!, + length: entry.data.audioBytes ?? 0, + type: audioType(entry.data.audioUrl!), + }, + customData: [ + `${entry.data.episode}`, + entry.data.duration ? `${entry.data.duration}` : '', + ].join(''), + })), + }); +} diff --git a/src/pages/podcast/index.astro b/src/pages/podcast/index.astro index 741b4a2..314fa62 100644 --- a/src/pages/podcast/index.astro +++ b/src/pages/podcast/index.astro @@ -2,8 +2,10 @@ import { getCollection } from 'astro:content'; import BaseLayout from '../../layouts/BaseLayout.astro'; import { formatDate, newestFirst } from '../../lib/content'; +import { site } from '../../site.config'; const entries = newestFirst(await getCollection('episodes', ({ data }) => !data.draft)); +const feedUrl = new URL(site.podcastFeedUrl, Astro.site).href; --- @@ -19,21 +21,47 @@ const entries = newestFirst(await getCollection('episodes', ({ data }) => !data. {Array.from({ length: 21 }).map((_, index) => )}
+ + +

Episodes

- RSS → + Podcast feed →
{entries.length > 0 ? entries.map((entry) => (
- {String(entry.data.episode).padStart(2, '0')} + {String(entry.data.episode).padStart(2, '0')}

{entry.data.title}

{entry.data.description}

+ {entry.data.guests.length > 0 &&

With {entry.data.guests.join(', ')}

} +
+
+ + {entry.data.duration && {entry.data.duration}}
-
- )) :

The archive is ready for real episodes and transcripts. No synthetic placeholder episode has been published.

} + )) : ( +
+

+ The archive is empty because no episode has been recorded yet — this publication does not ship + synthetic placeholders. The feed above is already live: subscribe now and episode one will simply appear. +

+
+ )}
@@ -44,11 +72,72 @@ const entries = newestFirst(await getCollection('episodes', ({ data }) => !data. .podcast-grid > p { color: #bcb8af; } .wave { display: flex; height: 210px; align-items: center; justify-content: space-between; margin-top: 4rem; overflow: hidden; } .wave i { display: block; width: 2px; height: calc(20% + var(--n) * .75%); background: color-mix(in srgb, var(--paper) calc(25% + var(--n) * .5%), var(--amend)); } - .episode-list { margin-top: 6rem; } - .episode-list article { display: grid; grid-template-columns: 4rem 1fr 8rem; gap: 2rem; padding: 2rem 0; border-bottom: 1px solid var(--rule); } - .episode-list article > span, time { color: var(--muted); font-family: 'IBM Plex Mono', monospace; font-size: .7rem; } + + .subscribe { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1.1fr); + gap: clamp(1.5rem, 4vw, 4rem); + align-items: center; + margin-top: clamp(3rem, 6vw, 5rem); + padding: clamp(1.5rem, 3vw, 2.5rem); + border: 1px solid var(--rule); + background: var(--paper-raised); + } + + .subscribe-copy { max-width: 44ch; margin: 0; color: var(--muted); font-size: 0.92rem; } + + .feed-box { + display: flex; + align-items: center; + gap: 1rem; + min-width: 0; + padding: 0.9rem 1.1rem; + border: 1px solid var(--ink); + background: var(--paper); + font-family: 'IBM Plex Mono', monospace; + font-size: 0.78rem; + } + + .feed-label { + color: var(--amend); + font-size: 0.62rem; + font-weight: 500; + letter-spacing: 0.08em; + text-transform: uppercase; + } + + .feed-box a { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-decoration: none; + } + + .episode-list { margin-top: clamp(3.5rem, 7vw, 6rem); } + .episode-list article { display: grid; grid-template-columns: 4rem 1fr 9rem; gap: 2rem; padding: 2rem 0; border-bottom: 1px solid var(--rule); } + .episode-number { color: var(--muted); font-family: 'IBM Plex Mono', monospace; font-size: .7rem; } h3 { margin: 0; font-family: 'Newsreader Variable', Georgia, serif; font-size: 2.3rem; line-height: 1; } h3 a { text-decoration: none; } .episode-list p { color: var(--muted); } - @media (max-width: 700px) { .podcast-grid { grid-template-columns: 1fr; } .episode-list article { grid-template-columns: 2rem 1fr; } time { grid-column: 2; } } + .guests { font-size: 0.82rem; } + + .episode-facts { + display: flex; + flex-direction: column; + gap: 0.4rem; + color: var(--muted); + font-family: 'IBM Plex Mono', monospace; + font-size: .7rem; + text-align: right; + } + + .empty-state { margin-top: 2rem; } + .empty-state p { max-width: 60ch; margin: 0; } + + @media (max-width: 700px) { + .podcast-grid { grid-template-columns: 1fr; } + .subscribe { grid-template-columns: 1fr; } + .episode-list article { grid-template-columns: 2rem 1fr; } + .episode-facts { flex-direction: row; grid-column: 2; text-align: left; } + } diff --git a/src/site.config.ts b/src/site.config.ts index 3b9f3a9..7e7cc91 100644 --- a/src/site.config.ts +++ b/src/site.config.ts @@ -10,7 +10,7 @@ export const site = { }, repositoryUrl: 'https://git.frrn.life/ana/trust-issues', newsletterUrl: '', - podcastFeedUrl: '', + podcastFeedUrl: '/podcast/feed.xml', contactUrl: '', book: { enabled: false,