Skip to content

Commit 8538be4

Browse files
authored
feat: podcast RSS feed generation (iTunes-compatible) (#46)
* feat: add podcast RSS feed generation (iTunes-compatible) - Add layouts/_default/rss.xml: auto-detects podcast config and renders either iTunes podcast RSS (with itunes: namespace, categories, owner, artwork, per-episode enclosure/tags) or a standard RSS 2.0 feed. - Detection: checks for author, image, or description in [params.podcast]; no toggle flag needed. - Episode front matter: all RSS fields under podcast: key (src, type, duration, season, episode, explicit, author, guid, episodeType). - Local audio auto-resolves enclosure length via Hugo resources. - Add exampleSite podcast config and rss-demo episode with full metadata. - Add copy-to-clipboard buttons on code blocks (assets/js/code-copy.js). - Improve content typography: h2/h3/p/pre/table spacing in baseof.css. - Improve episode nav: SVG chevron arrows, actual episode titles, dashed back-to-program link, mobile stacking layout. - Fix program URL fallback for episodes without a program page. - Add 7 Hugo integration tests for RSS (podcast + standard modes). - Document podcast RSS in README with config examples and front matter reference. * fix: self-contained testdata sites, remove module imports The RSS test fixtures used go.mod files with replace directives to import the wavecast module. This failed on CI because Hugo module resolution in subdirectories differs from local dev. Fix: follow the existing testdata pattern - copy the RSS template locally into each test fixture's layouts/_default/rss.xml and remove the [module] imports and go.mod files. The test sites are now fully self-contained, matching how the shortcode tests work. Also removed --quiet from buildHugoSite to ensure Hugo error output is visible in CI logs when builds fail. * fix: replace .Site.Language.Locale with .Site.LanguageCode for Hugo v0.146+ .Site.Language.Locale was added in Hugo v0.148.0. The CI workflow uses Hugo v0.146.0, so the RSS template crashed with: can't evaluate field Locale in type *langs.Language Replaced with .Site.LanguageCode which is available since Hugo 0.80+.
1 parent e266dfe commit 8538be4

18 files changed

Lines changed: 958 additions & 11 deletions

File tree

README.md

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ Both components are **bidirectionally synced** - pausing the footer pauses all i
3838
- **Keyboard shortcuts**: Space (play/pause), Left/Right (skip), M (mute)
3939
- **Media Session API**: integrates with OS media controls (lock screen, notification centre)
4040
- **Accessible**: ARIA labels, `:focus-visible` rings, semantic controls
41-
- **Well tested**: Go integration tests, JS unit tests (Vitest), Playwright E2E
41+
- **Well tested**: Go integration tests, JS unit tests (Vitest), Playwright E2E, RSS output tests
42+
- **Podcast RSS feed**: iTunes-compatible podcast RSS generation from Hugo content with automatic detection
4243

4344
---
4445

@@ -298,6 +299,83 @@ Multiple players on the same page use separate `sessionStorage` keys (`podcastPl
298299

299300
---
300301

302+
## Podcast RSS Feed
303+
304+
Wavecast provides a built-in podcast RSS template at `layouts/_default/rss.xml`. When podcast metadata is configured in your `hugo.toml`, the template automatically renders an iTunes-compatible podcast feed suitable for submission to Apple Podcasts, Spotify, and other directories. Without podcast config, it falls back to a clean standard RSS 2.0 feed.
305+
306+
### Enabling the podcast feed
307+
308+
Add a `[params.podcast]` section to your site's `hugo.toml`. The template detects at least one of `author`, `image`, or `description` and switches into podcast mode:
309+
310+
```toml
311+
[params.podcast]
312+
description = "A weekly show about open-source and software engineering."
313+
author = "Your Name"
314+
summary = "Longer show description (up to 4000 characters). Shown on the ⓘ info popup in podcast apps."
315+
image = "/podcast-cover.jpg" # ≥1400×1400 px recommended
316+
explicit = false # "true" or "false"
317+
type = "episodic" # "episodic" or "serial"
318+
owner_name = "Your Name"
319+
owner_email = "you@example.com"
320+
language = "en-us" # overrides site.languageCode if set
321+
copyright = "© 2026 Your Name"
322+
323+
[[params.podcast.categories]]
324+
category = "Technology"
325+
[[params.podcast.categories]]
326+
category = "Education"
327+
subcategory = "Courses" # optional
328+
```
329+
330+
### Episode front matter
331+
332+
Add a `podcast:` key to each episode's YAML front matter. Only `src` is required for an item to appear in the feed:
333+
334+
```yaml
335+
---
336+
title: "Episode 42: The Big One"
337+
date: 2026-06-01
338+
podcast:
339+
src: "https://example.com/audio/ep42.mp3"
340+
type: "audio/mpeg" # MIME type (default: audio/mpeg)
341+
duration: "00:45:00" # HH:MM:SS or seconds
342+
season: 2 # season number
343+
episode: 42 # episode number
344+
explicit: false # overrides site-level explicit
345+
author: "Guest Host" # overrides site-level author
346+
guid: "unique-ep-42" # falls back to permalink
347+
episodeType: "full" # "full", "trailer", or "bonus"
348+
subtitle: "A short episode teaser (≤255 chars, appears in Description column)"
349+
summary: "Full episode summary, up to 4000 characters."
350+
---
351+
```
352+
353+
### How it works
354+
355+
| Scenario | RSS output |
356+
|----------|-----------|
357+
| No `[params.podcast]` in config | Standard RSS 2.0 (suitable for blogs) |
358+
| `[params.podcast]` with `author`, `image`, or `description` | Full iTunes podcast RSS with itunes: namespace |
359+
| Episode has `podcast.src` (local file) | Enclosure with `length` auto-resolved from Hugo resources |
360+
| Episode has `podcast.src` (remote URL) | Enclosure with `length="0"` |
361+
| Episode has NO `podcast.src` | Omitted from feed entirely |
362+
363+
### Feed output
364+
365+
The feed is available at `/index.xml` (home page). Hugo automatically enables RSS output for the home page by default. You can also enable it for section pages in your `hugo.toml`:
366+
367+
```toml
368+
[outputs]
369+
home = ["html", "rss"]
370+
section = ["html", "rss"]
371+
```
372+
373+
### Validating
374+
375+
Once your site is live, validate the feed at [https://validator.w3.org/feed/](https://validator.w3.org/feed/).
376+
377+
---
378+
301379
## CSS Theming
302380

303381
The player renders in a Shadow DOM with a default dark theme. The external stylesheet provides a light theme that activates automatically. Both are fully customizable via CSS custom properties defined on `<podcast-player>` and `<podcast-footer>`.
@@ -416,8 +494,10 @@ assets/
416494
js/sources.js # Re-exports for test imports
417495
demo/demo-audio.wav # Demo audio file for the example site
418496
layouts/
497+
_default/
498+
rss.xml # Podcast RSS template (auto-detects podcast config)
419499
_shortcodes/
420-
podcast-player.html # Hugo shortcode template
500+
podcast-player.html # Hugo shortcode template
421501
tests/
422502
hugo/ # Go integration tests (builds Hugo sites per case)
423503
js/ # Vitest unit tests (80)

assets/js/code-copy.js

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// Add copy-to-clipboard buttons to all code blocks on the page.
2+
// Targets Hugo's .highlight wrapper (Chroma syntax highlighting).
3+
// Falls back to document.execCommand for older browsers.
4+
(function () {
5+
'use strict';
6+
7+
document.querySelectorAll('.highlight').forEach(function (el) {
8+
var btn = document.createElement('button');
9+
btn.className = 'code-copy-btn';
10+
btn.type = 'button';
11+
btn.title = 'Copy code';
12+
btn.innerHTML =
13+
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
14+
'<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>' +
15+
'<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>' +
16+
'</svg>';
17+
18+
btn.addEventListener('click', function () {
19+
// Extract text: if Hugo generated line numbers (table layout) the
20+
// code lives in td:last-child, otherwise the first <code> element.
21+
var code = el.querySelector('td:last-child code');
22+
if (!code) code = el.querySelector('code');
23+
var text = code ? code.textContent : el.textContent;
24+
25+
if (navigator.clipboard && navigator.clipboard.writeText) {
26+
navigator.clipboard.writeText(text).then(function () {
27+
showCopied(btn);
28+
}).catch(function () {
29+
fallbackCopy(btn, text, el);
30+
});
31+
} else {
32+
fallbackCopy(btn, text, el);
33+
}
34+
});
35+
36+
el.insertBefore(btn, el.firstChild);
37+
});
38+
39+
function showCopied(btn) {
40+
btn.classList.add('copied');
41+
btn.title = 'Copied!';
42+
setTimeout(function () {
43+
btn.classList.remove('copied');
44+
btn.title = 'Copy code';
45+
}, 2000);
46+
}
47+
48+
function fallbackCopy(btn, text, container) {
49+
var ta = document.createElement('textarea');
50+
ta.value = text;
51+
ta.style.position = 'fixed';
52+
ta.style.left = '-9999px';
53+
container.appendChild(ta);
54+
ta.select();
55+
try { document.execCommand('copy'); } catch (e) {}
56+
container.removeChild(ta);
57+
showCopied(btn);
58+
}
59+
})();

exampleSite/content/_index.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,7 @@ buttons:
1111
- text: "Schedule"
1212
url: "/schedule/"
1313
class: ""
14+
- text: "<svg viewBox=\"0 0 24 24\" width=\"16\" height=\"16\" fill=\"currentColor\" style=\"vertical-align:middle;margin-right:2px\"><path d=\"M6.18 15.64a2.18 2.18 0 0 1 2.18 2.18C8.36 19 7.38 20 6.18 20C5 20 4 19 4 17.82a2.18 2.18 0 0 1 2.18-2.18M4 4.44A15.56 15.56 0 0 1 19.56 20h-2.83A12.73 12.73 0 0 0 4 7.27V4.44m0 5.66a9.9 9.9 0 0 1 9.9 9.9h-2.83A7.07 7.07 0 0 0 4 12.93V10.1Z\"/></svg>RSS Feed"
15+
url: "/index.xml"
16+
class: ""
1417
---
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
---
2+
title: "Introduction to Wavecast RSS Feeds"
3+
program: "Wavecast Demo"
4+
episode: "S1E1"
5+
date: 2026-06-01
6+
tags: [Wavecast, Hugo, Podcasting, RSS]
7+
podcast:
8+
src: "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3"
9+
poster: "https://picsum.photos/seed/wavecast-rss/400/400"
10+
chapters: "00:00:00-Intro,00:00:30-How It Works,00:01:20-Episode Front Matter,00:02:10-Channel Config,00:02:45-Validating the Feed"
11+
type: "audio/mpeg"
12+
duration: "00:03:45"
13+
season: 1
14+
episode: 1
15+
explicit: false
16+
author: "Wavecast Radio"
17+
guid: "wavecast-demo-s1e1"
18+
episodeType: "full"
19+
subtitle: "Learn how Wavecast generates iTunes-compatible podcast RSS feeds from Hugo content."
20+
show_notes:
21+
- "[W3C Feed Validator](https://validator.w3.org/feed/): validate your RSS feed before submitting to directories"
22+
- "[Apple Podcasts Connect](https://podcastsconnect.apple.com/): submit your podcast to Apple Podcasts"
23+
- "[Hugo RSS Templates](https://gohugo.io/templates/rss/): Hugo's official RSS template documentation"
24+
attribution:
25+
text: "Wavecast RSS Demo"
26+
url: "https://github.com/adurrr/wavecast"
27+
---
28+
29+
This episode demonstrates how Wavecast automatically generates
30+
iTunes-compatible podcast RSS feeds when you configure
31+
`[params.podcast]` in your Hugo site config.
32+
33+
## How It Works
34+
35+
Wavecast ships `layouts/_default/rss.xml`, a single template that handles both
36+
standard blogs and podcast feeds. At build time, the template checks your
37+
`[params.podcast]` config:
38+
39+
<div class="info-card" style="margin:1.5rem 0">
40+
41+
**Detection rules:**
42+
43+
| Condition | Behaviour |
44+
|------------------------------------------|------------------------------------------------------|
45+
| `[params.podcast]` has `author`, `image`, or `description` | Renders a **full iTunes podcast RSS** feed (with `itunes:` namespace, categories, owner, artwork) |
46+
| `[params.podcast]` is missing or empty | Renders a **plain RSS 2.0** feed suitable for blogs |
47+
| Page has `podcast.src` in its front matter | Included as an `<item>` with an `<enclosure>` element |
48+
| Page has no `podcast.src` | Skipped entirely (not a podcast episode) |
49+
50+
</div>
51+
52+
There is no toggle or flag to set. If your site config defines podcast metadata,
53+
you get a podcast feed. If it does not, you get a blog feed. Both work from the
54+
same single template.
55+
56+
Episodes with a local audio file (e.g. `src: "audio/episode.mp3"`) get their
57+
`<enclosure>` file size auto-resolved via Hugo resources. Remote URLs default to
58+
`length="0"`, which is valid RSS and accepted by all major podcast directories.
59+
60+
## Episode Front Matter
61+
62+
All iTunes-specific fields live under the `podcast:` key in your episode's
63+
YAML front matter:
64+
65+
```yaml
66+
podcast:
67+
src: "https://example.com/audio/ep42.mp3" # required
68+
type: "audio/mpeg" # MIME type
69+
duration: "00:45:00" # HH:MM:SS or seconds
70+
season: 2 # season number
71+
episode: 42 # episode number
72+
explicit: false # "true" or "false"
73+
author: "Guest Host" # overrides site-level
74+
guid: "unique-ep-42" # falls back to permalink
75+
episodeType: "full" # full|trailer|bonus
76+
subtitle: "A short episode teaser" # max 255 chars
77+
summary: "Full episode description" # max 4000 chars
78+
```
79+
80+
Standard Hugo fields (`title`, `date`, `tags`, `summary`) are pulled from
81+
the regular front matter automatically.
82+
83+
## Channel Configuration
84+
85+
Site-level podcast metadata goes in your `hugo.toml`:
86+
87+
```toml
88+
[params.podcast]
89+
author = "Your Name"
90+
image = "/podcast-cover.jpg" # min 1400x1400px recommended
91+
description = "A weekly show about open-source."
92+
explicit = false
93+
type = "episodic" # "episodic" or "serial"
94+
owner_name = "Your Name"
95+
owner_email = "you@example.com"
96+
97+
[[params.podcast.categories]]
98+
category = "Technology"
99+
```
100+
101+
## Validating the Feed
102+
103+
Once your site is live, validate your feed:
104+
105+
1. Visit [validator.w3.org/feed](https://validator.w3.org/feed/)
106+
2. Enter your site's feed URL (usually `/index.xml`)
107+
3. Fix any warnings before submitting to directories
108+
109+
The feed is ready for **Apple Podcasts**, **Spotify**, **Google Podcasts**,
110+
and any RSS-compatible podcast app.
111+
112+
<div class="nav-buttons" style="margin-top:1.5rem">
113+
<a href="/wavecast/index.xml" class="nav-button nav-button-primary" target="_blank" rel="noopener">
114+
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor" style="vertical-align:middle"><path d="M6.18 15.64a2.18 2.18 0 0 1 2.18 2.18C8.36 19 7.38 20 6.18 20C5 20 4 19 4 17.82a2.18 2.18 0 0 1 2.18-2.18M4 4.44A15.56 15.56 0 0 1 19.56 20h-2.83A12.73 12.73 0 0 0 4 7.27V4.44m0 5.66a9.9 9.9 0 0 1 9.9 9.9h-2.83A7.07 7.07 0 0 0 4 12.93V10.1Z"/></svg>
115+
View RSS Feed
116+
</a>
117+
<a href="https://validator.w3.org/feed/" target="_blank" rel="noopener" class="nav-button">Validate Feed</a>
118+
</div>

exampleSite/hugo.toml

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,29 @@ ignoreLogs = ["warning-goldmark-raw-html"]
1414
[[module.imports]]
1515
path = "github.com/adurrr/wavecast"
1616

17-
# Site-wide description
17+
# ── RSS / Podcast feed ──
18+
# Configuring [params.podcast] enables an iTunes-compatible podcast RSS
19+
# feed. Omit the whole section for a plain RSS 2.0 feed.
20+
[params.podcast]
21+
description = "Wavecast Radio brings you the best in tech podcasting, from Infrastructure as Code to open-source culture and modern DevOps practices."
22+
author = "Wavecast Radio"
23+
summary = "A demo podcast showcasing the Hugo Wavecast theme. Each episode features a full persistent audio player, chapter markers, and episode notes — all powered by standard Markdown front matter."
24+
image = "/wavecast/logo.png"
25+
explicit = false
26+
type = "episodic"
27+
owner_name = "Wavecast Demo"
28+
owner_email = "demo@wavecast.example"
29+
language = "en-us"
30+
copyright = "© 2025 Wavecast Radio"
31+
32+
[[params.podcast.categories]]
33+
category = "Technology"
34+
[[params.podcast.categories]]
35+
category = "Leisure"
36+
subcategory = "Hobbies"
37+
1838
[params]
1939
description = "Wavecast Radio: Tune in to Tech. A demo radio station showcasing the Wavecast persistent podcast-player Web Component for Hugo."
20-
2140
[params.podcastPlayer]
2241
source = "local"
2342
persistent = true

exampleSite/layouts/_default/baseof.html

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@
1414
<link rel="manifest" href="{{ "site.webmanifest" | relURL }}">
1515
{{ $turbolinks := resources.Get "js/vendor/turbolinks.js" | fingerprint }}
1616
<script src="{{ $turbolinks.RelPermalink }}" integrity="{{ $turbolinks.Data.Integrity }}"></script>
17+
{{- $codeCopy := resources.Get "js/code-copy.js" -}}
18+
{{- if $codeCopy }}
19+
<script src="{{ $codeCopy.RelPermalink }}" defer></script>
20+
{{- end }}
1721
{{- /* Always load podcast-player CSS so the persistent footer has theme support on every page */ -}}
1822
{{- $playerCSS := resources.Get "css/podcast-player.css" -}}
1923
{{- if $playerCSS }}
@@ -110,6 +114,26 @@
110114
td a{color:var(--accent);text-decoration:none;font-weight:500}
111115
td a:hover{text-decoration:underline}
112116

117+
/* ── Content typography ── */
118+
main h2{font-size:1.35rem;font-weight:700;margin:1.5rem 0 0.5rem}
119+
main h3{font-size:1.1rem;font-weight:600;margin:1rem 0 0.25rem}
120+
main p{margin-bottom:0.75rem}
121+
main pre{margin:1rem 0;overflow-x:auto;padding-left:0.75rem}
122+
main ul,main ol{margin:0.5rem 0 0.75rem;padding-left:1.25rem}
123+
main li{margin-bottom:0.25rem}
124+
main table{width:100%;border-collapse:collapse;font-size:0.85rem;margin:1rem 0}
125+
main th{font-weight:700;text-align:left;padding:0.4rem 0.6rem;border-bottom:2px solid var(--border)}
126+
main td{padding:0.35rem 0.6rem;border-bottom:1px solid var(--border)}
127+
128+
/* ── Code copy button ── */
129+
.highlight{position:relative}
130+
.highlight .code-copy-btn{visibility:hidden;position:absolute;top:0;right:0;z-index:10;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;cursor:pointer;background:var(--surface);border:1px solid var(--border);border-radius:0 var(--radius) 0 var(--radius);color:var(--text-muted);opacity:0;transition:opacity .15s,visibility .15s}
131+
.highlight:hover .code-copy-btn,.highlight .code-copy-btn:focus-visible{visibility:visible;opacity:1}
132+
.highlight .code-copy-btn:hover{background:var(--accent);border-color:var(--accent);color:#fff}
133+
.highlight .code-copy-btn.copied{background:var(--accent);border-color:var(--accent);color:#fff}
134+
.highlight .code-copy-btn.copied svg{display:none}
135+
.highlight .code-copy-btn.copied::after{content:"Copied!";font-size:0.65rem;font-weight:600;white-space:nowrap}
136+
113137
/* ── Links (content areas) ── */
114138
main a{color:var(--accent);text-decoration:underline;text-decoration-color:color-mix(in srgb,var(--accent) 25%,transparent);text-underline-offset:2px;transition:text-decoration-color .15s}
115139
main a:hover{text-decoration-color:var(--accent)}
@@ -123,6 +147,12 @@
123147
.footer-nav a{color:var(--text-muted);text-decoration:none;font-size:0.85rem;transition:color .15s}
124148
.footer-nav a:hover{color:var(--accent)}
125149

150+
/* ── Episode nav ── */
151+
.episode-nav{gap:0.6rem}
152+
.episode-nav .nav-button{max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
153+
.episode-nav-back{font-size:0.85rem;border-style:dashed}
154+
@media(max-width:640px){.episode-nav{flex-direction:column;align-items:stretch}.episode-nav .nav-button{justify-content:flex-start}}
155+
126156
/* ── Responsive ── */
127157
@media(max-width:640px){
128158
.hero h1{font-size:1.5rem}
@@ -169,6 +199,7 @@
169199
<a href="{{ "schedule.md" | relref . }}">Schedule</a>
170200
<a href="{{ "about.md" | relref . }}">About</a>
171201
<a href="{{ "contact.md" | relref . }}">Contact</a>
202+
<a href="{{ "index.xml" | relURL }}"><svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor" style="vertical-align:middle;margin-right:3px"><path d="M6.18 15.64a2.18 2.18 0 0 1 2.18 2.18C8.36 19 7.38 20 6.18 20C5 20 4 19 4 17.82a2.18 2.18 0 0 1 2.18-2.18M4 4.44A15.56 15.56 0 0 1 19.56 20h-2.83A12.73 12.73 0 0 0 4 7.27V4.44m0 5.66a9.9 9.9 0 0 1 9.9 9.9h-2.83A7.07 7.07 0 0 0 4 12.93V10.1Z"/></svg>RSS</a>
172203
<a href="https://github.com/adurrr/wavecast" target="_blank" rel="noopener">GitHub</a>
173204
</div>
174205
<p>Wavecast Radio: Tune in to Tech. Powered by <a href="https://github.com/adurrr/wavecast" target="_blank" rel="noopener">Wavecast</a>, built with Hugo.</p>

exampleSite/layouts/episodes/single.html

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@
1515

1616
{{- $program := .Params.program -}}
1717
{{- $programPage := .Site.GetPage (printf "programs/%s" ($program | urlize)) -}}
18-
{{- $programUrl := cond (ne $programPage nil) $programPage.RelPermalink .Site.Home.RelPermalink -}}
18+
{{- $programUrl := .Site.Home.RelPermalink -}}
19+
{{- if and $programPage $programPage.RelPermalink }}{{ $programUrl = $programPage.RelPermalink }}{{ end -}}
1920

2021
<section class="hero">
2122
<h1>{{ .Title }}</h1>

0 commit comments

Comments
 (0)