This plan is for Open Invitation — a podcast site about Open Access efforts at the University of Idaho, hosted at https://thecdil.github.io/open-invitation/. The site is built with CollectionBuilder-CSV (Jekyll-based, using the Oral History as Data template). It currently has 3 episodes with transcripts, standard CB browse/subjects/map/timeline/data pages, and a homepage with studio chair artwork.
The GitHub repo is at https://github.com/thecdil/open-invitation (deployed via GitHub Pages). The site uses Jekyll with Liquid templating, data stored in CSV metadata files in _data/, and plain JavaScript. No Ruby plugins beyond what GitHub Pages supports natively.
Goals:
- Generate a valid RSS 2.0 podcast feed from existing metadata so the podcast can be submitted to Apple Podcasts, Spotify, and other directories — without using a third-party podcast host
- Improve the site design to feel more like a proper podcast platform while preserving the CollectionBuilder foundation
- Add features that make the site a compelling standalone listening experience
- Audio files are already hosted at
cdil.lib.uidaho.eduand referenced in the metadata CSV'sobject_locationfield. Example URL:https://cdil.lib.uidaho.edu/open-invitation/objects/OI-S1_Bland-Ep-final.mp3 objectidserves as the RSS<guid>— no separate guid column needed.- Episode item pages already have an MP3 player at the top (via the transcript display template). Do NOT add another player — just enhance the template with new metadata display.
- Subject tags are already displayed on episode pages.
- The browse page (
/browse.html) should be left as-is.
Add a podcast block to _config.yml:
# Podcast Feed Settings
podcast:
title: "Open Invitation"
description: "Open Invitation seeks to highlight Open Access efforts from across the University of Idaho. Interviews feature U of I faculty, staff, and students discussing their cutting-edge research, as well as the benefits and challenges of navigating the open access landscape."
author: "University of Idaho Library"
email: "[email protected]" # MUST be a real email — Apple and Spotify send verification codes here
language: "en-us"
explicit: "false"
type: "episodic" # or "serial"
category: "Education" # Apple Podcasts category
subcategory: "Higher Education" # Apple Podcasts subcategory
image: "/assets/img/podcast-cover.jpg" # 3000x3000 recommended, 1400x1400 minimum
link: "https://thecdil.github.io/open-invitation/"
copyright: "University of Idaho Library"
apple_url: "" # fill in after Apple approval
spotify_url: "" # fill in after Spotify approval
amazon_url: "" # fill in after Amazon approvalThe existing object_location field already contains the MP3 URL — do not add a separate audio_url column. The feed template reads directly from object_location.
Add these new columns to the metadata CSV:
| New Column | Example Value | Notes |
|---|---|---|
audio_length |
34567890 |
File size in bytes (required by RSS <enclosure>). Get via curl -sI <url> | grep -i content-length |
duration |
00:42:15 |
Episode length in HH:MM:SS format |
episode_number |
1 |
Integer episode number |
season_number |
1 |
Integer season number (leave blank if not using seasons) |
episode_type |
full |
full, trailer, or bonus |
guest |
Dr. Jane Bland |
Guest name(s) for display on episode pages and homepage |
Create feed.xml in the project root:
---
layout: null
---
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"
xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"
xmlns:podcast="https://podcastindex.org/namespace/1.0"
xmlns:atom="http://www.w3.org/2005/Atom"
xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
<title>{{ site.podcast.title | xml_escape }}</title>
<link>{{ site.podcast.link }}</link>
<description>{{ site.podcast.description | xml_escape }}</description>
<language>{{ site.podcast.language }}</language>
<copyright>{{ site.podcast.copyright | xml_escape }}</copyright>
<atom:link href="{{ site.url }}{{ site.baseurl }}/feed.xml" rel="self" type="application/rss+xml"/>
<lastBuildDate>{{ site.time | date: "%a, %d %b %Y %H:%M:%S %z" }}</lastBuildDate>
<itunes:author>{{ site.podcast.author | xml_escape }}</itunes:author>
<itunes:summary>{{ site.podcast.description | xml_escape }}</itunes:summary>
<itunes:type>{{ site.podcast.type }}</itunes:type>
<itunes:owner>
<itunes:name>{{ site.podcast.author | xml_escape }}</itunes:name>
<itunes:email>{{ site.podcast.email }}</itunes:email>
</itunes:owner>
<itunes:explicit>{{ site.podcast.explicit }}</itunes:explicit>
<itunes:category text="{{ site.podcast.category }}">
{% if site.podcast.subcategory %}<itunes:category text="{{ site.podcast.subcategory }}"/>{% endif %}
</itunes:category>
<itunes:image href="{{ site.url }}{{ site.baseurl }}{{ site.podcast.image }}"/>
<image>
<url>{{ site.url }}{{ site.baseurl }}{{ site.podcast.image }}</url>
<title>{{ site.podcast.title | xml_escape }}</title>
<link>{{ site.podcast.link }}</link>
</image>
<podcast:locked>no</podcast:locked>
{% assign episodes = site.data[site.metadata] | where_exp: "item", "item.object_location != nil and item.object_location != ''" | sort: "date" | reverse %}
{% for item in episodes %}
<item>
<title>{{ item.title | xml_escape }}</title>
<description><![CDATA[{{ item.description }}]]></description>
<link>{{ site.url }}{{ site.baseurl }}/items/{{ item.objectid }}.html</link>
<guid isPermaLink="false">{{ item.objectid }}</guid>
<pubDate>{{ item.date | date: "%a, %d %b %Y 12:00:00 %z" }}</pubDate>
<enclosure url="{{ item.object_location }}" length="{{ item.audio_length }}" type="audio/mpeg"/>
<itunes:title>{{ item.title | xml_escape }}</itunes:title>
<itunes:author>{{ site.podcast.author | xml_escape }}</itunes:author>
<itunes:summary>{{ item.description | xml_escape }}</itunes:summary>
<itunes:explicit>{{ site.podcast.explicit }}</itunes:explicit>
<itunes:duration>{{ item.duration }}</itunes:duration>
{% if item.episode_number %}<itunes:episode>{{ item.episode_number }}</itunes:episode>{% endif %}
{% if item.season_number %}<itunes:season>{{ item.season_number }}</itunes:season>{% endif %}
<itunes:episodeType>{{ item.episode_type | default: "full" }}</itunes:episodeType>
{% if item.image_small %}
<itunes:image href="{{ site.url }}{{ site.baseurl }}{{ item.image_small }}"/>
{% endif %}
</item>
{% endfor %}
</channel>
</rss>Key points:
- Filters on
object_location(not a new column) to find episodes with audio - Uses
objectiddirectly as the<guid> <enclosure>points to theobject_locationURL oncdil.lib.uidaho.edu
Add to the site's <head>. Check for an existing CB hook file first — CB often provides _includes/head/extra-head.html for custom head content. If that exists, add to it. If not, copy CB's head include locally and add:
<link rel="alternate" type="application/rss+xml" title="{{ site.podcast.title }}" href="{{ site.url }}{{ site.baseurl }}/feed.xml">After building locally (bundle exec jekyll serve), validate at:
Common issues: missing/blank audio_length values, dates not in RFC 822 format, artwork below 1400×1400.
Square cover art required by all directories:
- Minimum: 1400 × 1400 pixels
- Recommended: 3000 × 3000 pixels
- Format: JPEG or PNG, RGB color space
- Max file size: 512KB recommended
Save as /assets/img/podcast-cover.jpg. Referenced in _config.yml under podcast.image. The current studio chair images could be adapted. This is a manual design step.
The transcript template already has the MP3 player and subject tags. Modify _layouts/item/transcript.html to add three things:
A. Prominent guest name — below title, above player:
{% if item.guest %}
<p class="episode-guest h5 text-muted mb-3">
with <strong>{{ item.guest }}</strong>
</p>
{% endif %}B. Episode metadata line — below the player:
<div class="episode-meta text-muted mb-3">
{% if item.season_number %}Season {{ item.season_number }}, {% endif %}
{% if item.episode_number %}Episode {{ item.episode_number }}{% endif %}
{% if item.duration %} · {{ item.duration }}{% endif %}
{% if item.date %} · {{ item.date | date: "%B %d, %Y" }}{% endif %}
</div>C. Share button — copy-link using vanilla JS:
<div class="episode-share mb-4">
<button class="btn btn-outline-secondary btn-sm" onclick="copyEpisodeLink(this)"
data-url="{{ site.url }}{{ site.baseurl }}/items/{{ item.objectid }}.html">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-link-45deg" viewBox="0 0 16 16">
<path d="M4.715 6.542 3.343 7.914a3 3 0 1 0 4.243 4.243l1.828-1.829A3 3 0 0 0 8.586 5.5L8 6.086a1 1 0 0 0-.154.199 2 2 0 0 1 .861 3.337L6.88 11.45a2 2 0 1 1-2.83-2.83l.793-.792a4 4 0 0 1-.128-1.287z"/>
<path d="M6.586 4.672A3 3 0 0 0 7.414 9.5l.775-.776a2 2 0 0 1-.896-3.346L9.12 3.55a2 2 0 1 1 2.83 2.83l-.793.792c.112.42.155.855.128 1.287l1.372-1.372a3 3 0 1 0-4.243-4.243z"/>
</svg>
Share Episode
</button>
</div>
<script>
function copyEpisodeLink(btn) {
var url = btn.getAttribute('data-url');
navigator.clipboard.writeText(url).then(function() {
btn.innerHTML = '✓ Link Copied!';
setTimeout(function() {
btn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-link-45deg" viewBox="0 0 16 16"><path d="M4.715 6.542 3.343 7.914a3 3 0 1 0 4.243 4.243l1.828-1.829A3 3 0 0 0 8.586 5.5L8 6.086a1 1 0 0 0-.154.199 2 2 0 0 1 .861 3.337L6.88 11.45a2 2 0 1 1-2.83-2.83l.793-.792a4 4 0 0 1-.128-1.287z"/><path d="M6.586 4.672A3 3 0 0 0 7.414 9.5l.775-.776a2 2 0 0 1-.896-3.346L9.12 3.55a2 2 0 1 1 2.83 2.83l-.793.792c.112.42.155.855.128 1.287l1.372-1.372a3 3 0 1 0-4.243-4.243z"/></svg> Share Episode';
}, 2000);
});
}
</script>Element order at top of the episode content area should be:
- Episode title (already exists)
- Guest name (new)
- Audio player (already exists)
- Episode meta line (new)
- Share button (new)
- Transcript content (already exists)
- Subject tags (already exist)
Important: Before modifying _layouts/item/transcript.html, check if it already exists locally. If not, copy it from the CB theme/gem first, then modify.
A. Latest Episode card. Create _includes/feature/latest-episode.html:
{% assign latest = site.data[site.metadata] | where_exp: "item", "item.object_location != nil and item.object_location != ''" | sort: "date" | reverse | first %}
{% if latest %}
<div class="card mb-4 shadow-sm">
<div class="card-body">
<span class="badge bg-primary mb-2">Latest Episode</span>
{% if latest.episode_number %}
<span class="badge bg-secondary mb-2">Episode {{ latest.episode_number }}</span>
{% endif %}
<h4 class="card-title">{{ latest.title }}</h4>
{% if latest.guest %}
<p class="text-muted mb-2">with <strong>{{ latest.guest }}</strong></p>
{% endif %}
<p class="card-text">{{ latest.description | truncatewords: 40 }}</p>
<audio controls preload="none" style="width:100%;">
<source src="{{ latest.object_location }}" type="audio/mpeg">
</audio>
<div class="mt-3">
<a href="{{ '/items/' | append: latest.objectid | append: '.html' | relative_url }}" class="btn btn-primary">
Full Episode & Transcript
</a>
{% if latest.duration %}
<span class="text-muted ms-2">{{ latest.duration }}</span>
{% endif %}
</div>
</div>
</div>
{% endif %}B. Subscribe badges. Create _includes/feature/subscribe-badges.html:
<div class="subscribe-section mb-4">
<h5>Listen & Subscribe</h5>
<div class="subscribe-grid">
{% if site.podcast.apple_url and site.podcast.apple_url != "" %}
<a href="{{ site.podcast.apple_url }}" class="subscribe-badge" target="_blank" rel="noopener">
Apple Podcasts
</a>
{% endif %}
{% if site.podcast.spotify_url and site.podcast.spotify_url != "" %}
<a href="{{ site.podcast.spotify_url }}" class="subscribe-badge" target="_blank" rel="noopener">
Spotify
</a>
{% endif %}
{% if site.podcast.amazon_url and site.podcast.amazon_url != "" %}
<a href="{{ site.podcast.amazon_url }}" class="subscribe-badge" target="_blank" rel="noopener">
Amazon Music
</a>
{% endif %}
<a href="{{ site.url }}{{ site.baseurl }}/feed.xml" class="subscribe-badge">
RSS Feed
</a>
</div>
</div>On the home page:
- Replace the single "Listen and Subscribe on Spotify" link with
{% include feature/subscribe-badges.html %} - Add
{% include feature/latest-episode.html %}below the hero description, above or replacing the "Top Subjects" section
Create listen.md (or pages/listen.md per CB convention):
---
title: Listen & Subscribe
layout: page
permalink: /listen.html
---
## Subscribe to Open Invitation
Listen on your favorite podcast app:
{% include feature/subscribe-badges.html %}
## RSS Feed
Copy this URL into any podcast app to subscribe directly:
<div class="input-group mb-3" style="max-width:600px;">
<input type="text" class="form-control" value="{{ site.url }}{{ site.baseurl }}/feed.xml" id="rss-url" readonly>
<button class="btn btn-outline-secondary" onclick="document.getElementById('rss-url').select(); navigator.clipboard.writeText(document.getElementById('rss-url').value); this.textContent='Copied!'; setTimeout(()=>this.textContent='Copy',2000);">Copy</button>
</div>
## All Episodes
{% assign episodes = site.data[site.metadata] | where_exp: "item", "item.object_location != nil and item.object_location != ''" | sort: "date" | reverse %}
{% for item in episodes %}
### {{ item.title }}
{% if item.guest %}*with {{ item.guest }}* {% endif %}
{{ item.date | date: "%B %d, %Y" }}{% if item.duration %} · {{ item.duration }}{% endif %}
{{ item.description | truncatewords: 30 }}
[Listen →]({{ '/items/' | append: item.objectid | append: '.html' | relative_url }})
---
{% endfor %}Add "Listen" to navigation in _data/config-nav.csv (check existing format first).
Add to assets/css/custom.scss (create if needed — empty YAML front matter required):
---
---
// Episode guest name
.episode-guest { font-style: italic; }
// Episode metadata line
.episode-meta { font-size: 0.9rem; }
// Subscribe badges
.subscribe-grid {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.subscribe-badge {
display: inline-flex;
align-items: center;
padding: 0.5rem 1rem;
border: 1px solid #dee2e6;
border-radius: 0.5rem;
text-decoration: none;
color: #212529;
font-weight: 500;
transition: background 0.2s, border-color 0.2s;
}
.subscribe-badge:hover {
background: #f8f9fa;
border-color: #adb5bd;
color: #212529;
text-decoration: none;
}After feed is live and validated:
| Directory | URL | Notes |
|---|---|---|
| Apple Podcasts | https://podcasters.apple.com/ | Submit RSS URL, verify via Apple ID. 24hrs–2wks approval. |
| Spotify | https://creators.spotify.com/ | Select "I have a podcast" → "Somewhere else". Verifies via email in feed. Hours to days. |
| Amazon Music | https://podcasters.amazon.com/ | RSS URL + Amazon account |
| Pocket Casts | https://pocketcasts.com/submit/ | RSS URL only |
| Podcast Index | https://podcastindex.org/add | Good for indie app discoverability |
After each approval, add the platform URL to _config.yml under podcast.apple_url, podcast.spotify_url, etc. so the subscribe badges auto-populate.
- Add
podcast:config block to_config.yml - Add new columns to metadata CSV (
audio_length,duration,episode_number,season_number,episode_type,guest) - Create
/feed.xml - Add RSS
<link>tag to site head (find CB's head include hook first) - Note: podcast cover art at
/assets/img/podcast-cover.jpgis a manual design step
- Modify
_layouts/item/transcript.html— add guest name, episode meta line, share button (player and subject tags already exist)
- Create
_includes/feature/latest-episode.html - Create
_includes/feature/subscribe-badges.html - Add both to home page, replace single Spotify link with badges
- Create
listen.md - Add "Listen" to
_data/config-nav.csv
- Add CSS to
assets/css/custom.scss
- Build locally, validate
/feed.xmlat castfeedvalidator.com - Test audio, check mobile layout
open-invitation/
├── _config.yml # MODIFY: add podcast: block
├── _data/
│ ├── metadata.csv # MODIFY: add audio_length, duration,
│ │ # episode_number, season_number,
│ │ # episode_type, guest columns
│ └── config-nav.csv # MODIFY: add Listen page
├── _includes/
│ ├── head/
│ │ └── (head.html or extra-head.html) # MODIFY: add RSS <link> tag
│ └── feature/
│ ├── latest-episode.html # CREATE
│ └── subscribe-badges.html # CREATE
├── _layouts/
│ └── item/
│ └── transcript.html # MODIFY: add guest, meta, share
├── assets/
│ ├── css/
│ │ └── custom.scss # CREATE or MODIFY
│ └── img/
│ └── podcast-cover.jpg # CREATE (manual, 3000x3000)
├── feed.xml # CREATE
└── listen.md # CREATE
object_locationis the existing metadata field with MP3 URLs (e.g.,https://cdil.lib.uidaho.edu/open-invitation/objects/OI-S1_Bland-Ep-final.mp3). The feed uses this — no new audio URL column.objectid= RSS<guid>. Must never change after feed submission.site.data[site.metadata]accesses the metadata CSV.site.metadatais set in_config.yml.- Item pages generate at
/items/{objectid}.html. Thedisplay_templatefield picks the layout from_layouts/item/. - To override a CB include/layout: copy from CB theme into the project's local directory, then modify. Jekyll resolves local files first.
- Check for
_includes/head/extra-head.htmlfirst — it's CB's convention for custom head additions. If absent, look at_includes/head.htmlor_includes/head/head.html. - GitHub Pages has no custom plugin support — the pure-Liquid feed.xml approach is essential.
| xml_escapeis critical in the feed to prevent XML parsing errors.- Date format:
"%a, %d %b %Y %H:%M:%S %z"for RFC 822. If%zdoesn't produce a proper offset on GitHub Pages, hardcode+0000. audio_lengthmust be a non-blank integer (bytes). Get values withcurl -sI <url> | grep -i content-length.