Build out the podcast: live feed, subscribe surface, episode pages
validate / validate (push) Failing after 16s

- /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 <noreply@anthropic.com>
This commit is contained in:
Compleet
2026-07-14 14:10:22 +01:00
parent eaf9a614d9
commit f15bddc5d3
9 changed files with 290 additions and 13 deletions
+1
View File
@@ -11,6 +11,7 @@ import { site } from '../site.config';
<div>
<p class="footer-label">Read independently</p>
<a href="/rss.xml">RSS feed</a><br />
<a href={site.podcastFeedUrl}>Podcast feed</a><br />
<a href={site.repositoryUrl}>Public repository</a>
</div>
<div>
+1 -1
View File
@@ -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;
+1
View File
@@ -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,
}),
+3 -1
View File
@@ -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
+91
View File
@@ -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
/>
))}
</section>
{latestEpisode && (
<section class="podcast-rail shell" aria-label="The podcast">
<div class="rail-wave" aria-hidden="true">
{Array.from({ length: 9 }).map((_, index) => <i style={`--n:${(index * 41) % 100}`} />)}
</div>
<div class="rail-copy">
<p class="eyebrow">Podcast · Episode {latestEpisode.data.episode}</p>
<h2><a href={`/podcast/${latestEpisode.id}`}>{latestEpisode.data.title}</a></h2>
</div>
<div class="rail-links">
<a href={`/podcast/${latestEpisode.id}`}>Listen →</a>
<a href={site.podcastFeedUrl}>Subscribe</a>
</div>
</section>
)}
<div class="more-stories shell">
<a href="/stories">Read more <span>{Math.max(0, stories.length - frontPageItems.length)} more stories</span></a>
</div>
@@ -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;
+41 -2
View File
@@ -23,10 +23,49 @@ const { Content } = await render(entry);
status={entry.data.status}
sourcePath={`src/content/episodes/${entry.id}.md`}
>
{entry.data.audioUrl && <audio controls preload="metadata" src={entry.data.audioUrl}>Your browser does not support embedded audio.</audio>}
{(entry.data.guests.length > 0 || entry.data.duration) && (
<p class="episode-facts">
{entry.data.guests.length > 0 && <span>With {entry.data.guests.join(', ')}</span>}
{entry.data.duration && <span>{entry.data.duration}</span>}
</p>
)}
{entry.data.audioUrl && (
<div class="player">
<audio controls preload="metadata" src={entry.data.audioUrl}>Your browser does not support embedded audio.</audio>
<a href={entry.data.audioUrl} download>Download audio ↓</a>
</div>
)}
<Content />
</ArticleLayout>
<style>
audio { width: 100%; margin-bottom: 2.5rem; }
.episode-facts {
display: flex;
justify-content: space-between;
gap: 1rem;
margin: 0 0 1.5rem;
color: var(--muted);
font-family: 'IBM Plex Mono', monospace;
font-size: 0.72rem;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.player {
margin-bottom: 2.5rem;
padding: 1.1rem;
border: 1px solid var(--rule);
background: var(--paper-raised);
}
.player audio { width: 100%; }
.player a {
display: inline-block;
margin-top: 0.7rem;
font-family: 'IBM Plex Mono', monospace;
font-size: 0.7rem;
letter-spacing: 0.05em;
text-transform: uppercase;
}
</style>
+54
View File
@@ -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<string, string> = {
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: [
'<language>en</language>',
`<itunes:author>${site.author.name}</itunes:author>`,
'<itunes:explicit>false</itunes:explicit>',
`<link>${new URL('/podcast', context.site).href}</link>`,
].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: [
`<itunes:episode>${entry.data.episode}</itunes:episode>`,
entry.data.duration ? `<itunes:duration>${entry.data.duration}</itunes:duration>` : '',
].join(''),
})),
});
}
+96 -7
View File
@@ -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;
---
<BaseLayout title="Podcast" description="Conversations about authority, delegation, recovery, and responsibility.">
@@ -19,21 +21,47 @@ const entries = newestFirst(await getCollection('episodes', ({ data }) => !data.
{Array.from({ length: 21 }).map((_, index) => <i style={`--n:${(index * 37) % 100}`} />)}
</div>
</section>
<section class="shell subscribe" aria-label="Subscribe to the podcast">
<div>
<p class="eyebrow">Subscribe</p>
<p class="subscribe-copy">
Paste the feed into any podcast app. New episodes arrive there the moment they are published here —
no platform account between you and the audio.
</p>
</div>
<div class="feed-box">
<span class="feed-label">Feed</span>
<a href={site.podcastFeedUrl}>{feedUrl}</a>
</div>
</section>
<section class="shell episode-list">
<div class="section-rule">
<h2 class="section-title">Episodes</h2>
<a href="/rss.xml">RSS →</a>
<a href={site.podcastFeedUrl}>Podcast feed →</a>
</div>
{entries.length > 0 ? entries.map((entry) => (
<article>
<span>{String(entry.data.episode).padStart(2, '0')}</span>
<span class="episode-number">{String(entry.data.episode).padStart(2, '0')}</span>
<div>
<h3><a href={`/podcast/${entry.id}`}>{entry.data.title}</a></h3>
<p>{entry.data.description}</p>
{entry.data.guests.length > 0 && <p class="guests">With {entry.data.guests.join(', ')}</p>}
</div>
<div class="episode-facts">
<time datetime={entry.data.published.toISOString()}>{formatDate(entry.data.published)}</time>
{entry.data.duration && <span>{entry.data.duration}</span>}
</div>
</article>
)) : <p class="empty-state">The archive is ready for real episodes and transcripts. No synthetic placeholder episode has been published.</p>}
)) : (
<div class="empty-state">
<p>
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.
</p>
</div>
)}
</section>
</BaseLayout>
@@ -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; }
}
</style>
+1 -1
View File
@@ -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,