Point internal links at clean episode URLs and retire the JS page
This commit is contained in:
+3
-32
@@ -974,34 +974,6 @@ def sync_episode_media_to_bunny(episode_id: int, already_uploaded: set):
|
|||||||
Path(tmp_path).unlink(missing_ok=True)
|
Path(tmp_path).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
def add_episode_to_sitemap(slug: str):
|
|
||||||
"""Add episode transcript page to sitemap.xml."""
|
|
||||||
sitemap_path = Path(__file__).parent / "website" / "sitemap.xml"
|
|
||||||
if not sitemap_path.exists():
|
|
||||||
return
|
|
||||||
|
|
||||||
url = f"https://lukeattheroost.com/episode.html?slug={slug}"
|
|
||||||
content = sitemap_path.read_text()
|
|
||||||
|
|
||||||
if url in content:
|
|
||||||
print(f" Episode already in sitemap")
|
|
||||||
return
|
|
||||||
|
|
||||||
today = datetime.now().strftime("%Y-%m-%d")
|
|
||||||
new_entry = f""" <url>
|
|
||||||
<loc>{url}</loc>
|
|
||||||
<lastmod>{today}</lastmod>
|
|
||||||
<changefreq>never</changefreq>
|
|
||||||
<priority>0.7</priority>
|
|
||||||
</url>
|
|
||||||
</urlset>"""
|
|
||||||
|
|
||||||
content = content.replace("</urlset>", new_entry)
|
|
||||||
sitemap_path.write_text(content)
|
|
||||||
print(f" Added episode to sitemap.xml")
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def generate_social_image(episode_number: int, description: str, output_path: str) -> str:
|
def generate_social_image(episode_number: int, description: str, output_path: str) -> str:
|
||||||
"""Generate a social media image with cover art, episode number, and description."""
|
"""Generate a social media image with cover art, episode number, and description."""
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
@@ -1266,7 +1238,7 @@ def post_to_social(metadata: dict, episode_slug: str, image_path: str = None,
|
|||||||
if media and media.get("id"):
|
if media and media.get("id"):
|
||||||
image_ids = [{"id": media["id"], "path": media.get("path", "")}]
|
image_ids = [{"id": media["id"], "path": media.get("path", "")}]
|
||||||
|
|
||||||
episode_url = f"https://lukeattheroost.com/episode.html?slug={episode_slug}"
|
episode_url = f"https://lukeattheroost.com/episode/{episode_slug}/"
|
||||||
yt_url = f"https://youtube.com/watch?v={yt_video_id}" if yt_video_id else None
|
yt_url = f"https://youtube.com/watch?v={yt_video_id}" if yt_video_id else None
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
@@ -1468,7 +1440,7 @@ def upload_to_youtube(audio_path: str, metadata: dict, chapters: list,
|
|||||||
ts = f"{h}:{m:02d}:{s:02d}" if h > 0 else f"{m}:{s:02d}"
|
ts = f"{h}:{m:02d}:{s:02d}" if h > 0 else f"{m}:{s:02d}"
|
||||||
chapter_lines.append(f"{ts} {ch['title']}")
|
chapter_lines.append(f"{ts} {ch['title']}")
|
||||||
|
|
||||||
episode_url = f"https://lukeattheroost.com/episode.html?slug={episode_slug}"
|
episode_url = f"https://lukeattheroost.com/episode/{episode_slug}/"
|
||||||
description = (
|
description = (
|
||||||
f"{metadata['description']}\n\n"
|
f"{metadata['description']}\n\n"
|
||||||
+ "\n".join(chapter_lines) + "\n\n"
|
+ "\n".join(chapter_lines) + "\n\n"
|
||||||
@@ -1845,8 +1817,7 @@ def main():
|
|||||||
shutil.copy2(str(transcript_path), str(website_transcript_path))
|
shutil.copy2(str(transcript_path), str(website_transcript_path))
|
||||||
print(f" Transcript copied to website/transcripts/")
|
print(f" Transcript copied to website/transcripts/")
|
||||||
|
|
||||||
# Add to sitemap
|
# Sitemap is regenerated wholesale by generate_episode_pages.py --sitemap
|
||||||
add_episode_to_sitemap(episode["slug"])
|
|
||||||
|
|
||||||
# Sync any remaining episode media to BunnyCDN (cover art, etc.)
|
# Sync any remaining episode media to BunnyCDN (cover art, etc.)
|
||||||
print(" Syncing remaining episode media to CDN...")
|
print(" Syncing remaining episode media to CDN...")
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
LEGACY = "episode.html?slug="
|
||||||
|
|
||||||
|
|
||||||
|
def _files():
|
||||||
|
for pattern in ("website/*.html", "website/js/*.js", "website/llms.txt", "*.py"):
|
||||||
|
yield from ROOT.glob(pattern)
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_source_file_builds_a_legacy_episode_url():
|
||||||
|
offenders = []
|
||||||
|
for f in _files():
|
||||||
|
if f.name == "_worker.js":
|
||||||
|
continue # the worker's reference is the redirect itself
|
||||||
|
if LEGACY in f.read_text(errors="replace"):
|
||||||
|
offenders.append(str(f.relative_to(ROOT)))
|
||||||
|
assert not offenders, f"legacy episode URLs still present: {offenders}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_rendered_episode_page_is_gone():
|
||||||
|
assert not (ROOT / "website" / "episode.html").exists()
|
||||||
|
assert not (ROOT / "website" / "js" / "episode.js").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_still_redirects_legacy_urls():
|
||||||
|
worker = (ROOT / "website" / "_worker.js").read_text()
|
||||||
|
assert "/episode.html" in worker and "301" in worker
|
||||||
|
|
||||||
|
|
||||||
|
def test_redirects_file_has_no_dead_episode_target():
|
||||||
|
redirects = (ROOT / "website" / "_redirects").read_text()
|
||||||
|
assert "/episode 302" not in redirects
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
/episodes.html /episode 302
|
/episodes.html / 302
|
||||||
|
|||||||
@@ -1,127 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<meta name="theme-color" content="#1a1209">
|
|
||||||
<title id="page-title">Episode — Luke at the Roost</title>
|
|
||||||
<meta name="description" id="page-description" content="Full transcript of this episode of Luke at the Roost, the late-night call-in radio show.">
|
|
||||||
<link rel="canonical" id="page-canonical" href="https://lukeattheroost.com/episode.html">
|
|
||||||
|
|
||||||
<!-- OG / Social -->
|
|
||||||
<meta property="og:site_name" content="Luke at the Roost">
|
|
||||||
<meta property="og:title" id="og-title" content="Episode — Luke at the Roost">
|
|
||||||
<meta property="og:description" id="og-description" content="Full transcript of this episode of Luke at the Roost.">
|
|
||||||
<meta property="og:image" content="https://cdn.lukeattheroost.com/media/podcasts/LukeAtTheRoost/cover_feed.png?v=3">
|
|
||||||
<meta property="og:url" id="og-url" content="https://lukeattheroost.com/episode.html">
|
|
||||||
<meta property="og:type" content="article">
|
|
||||||
<meta name="twitter:card" content="summary_large_image">
|
|
||||||
<meta name="twitter:title" id="tw-title" content="Episode — Luke at the Roost">
|
|
||||||
<meta name="twitter:description" id="tw-description" content="Full transcript of this episode of Luke at the Roost.">
|
|
||||||
<meta name="twitter:image" content="https://cdn.lukeattheroost.com/media/podcasts/LukeAtTheRoost/cover_feed.png?v=3">
|
|
||||||
|
|
||||||
<!-- Favicon -->
|
|
||||||
<link rel="icon" href="favicon.ico" sizes="48x48">
|
|
||||||
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
|
||||||
<link rel="icon" type="image/png" sizes="192x192" href="favicon-192.png">
|
|
||||||
<link rel="icon" type="image/png" sizes="48x48" href="favicon-48.png">
|
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png">
|
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="favicon-16.png">
|
|
||||||
<link rel="apple-touch-icon" href="apple-touch-icon.png">
|
|
||||||
|
|
||||||
<link rel="alternate" type="application/rss+xml" title="Luke at the Roost RSS Feed" href="https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml">
|
|
||||||
<link rel="stylesheet" href="css/style.css?v=7">
|
|
||||||
|
|
||||||
<!-- Structured Data (dynamically updated by JS) -->
|
|
||||||
<script type="application/ld+json" id="episode-jsonld">
|
|
||||||
{
|
|
||||||
"@context": "https://schema.org",
|
|
||||||
"@type": "PodcastEpisode",
|
|
||||||
"partOfSeries": {
|
|
||||||
"@type": "PodcastSeries",
|
|
||||||
"name": "Luke at the Roost",
|
|
||||||
"url": "https://lukeattheroost.com"
|
|
||||||
},
|
|
||||||
"name": "Episode — Luke at the Roost",
|
|
||||||
"url": "https://lukeattheroost.com/episode.html",
|
|
||||||
"description": "Full transcript of this episode of Luke at the Roost.",
|
|
||||||
"inLanguage": "en"
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<script defer data-domain="lukeattheroost.com" data-api="/p/event" src="/p/script"></script>
|
|
||||||
<script>window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }</script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<a href="#main-content" class="skip-link">Skip to content</a>
|
|
||||||
|
|
||||||
<nav class="site-nav">
|
|
||||||
<a href="/" class="site-nav-brand">Luke at the Roost</a>
|
|
||||||
<div class="site-nav-links">
|
|
||||||
<a href="/how-it-works">How It Works</a>
|
|
||||||
<a href="/clips">Clips</a>
|
|
||||||
<a href="/stats">Stats</a>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<main id="main-content">
|
|
||||||
|
|
||||||
<!-- Episode Header -->
|
|
||||||
<section class="ep-header" id="ep-header">
|
|
||||||
<div class="ep-header-inner">
|
|
||||||
<div class="ep-meta" id="ep-meta"></div>
|
|
||||||
<h1 class="ep-title" id="ep-title">Loading...</h1>
|
|
||||||
<p class="ep-desc" id="ep-desc"></p>
|
|
||||||
<div class="ep-actions">
|
|
||||||
<button class="ep-play-btn" id="ep-play-btn" style="display:none" aria-label="Play Episode">
|
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
|
|
||||||
<span>Play Episode</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Transcript -->
|
|
||||||
<section class="transcript-section" id="transcript-section">
|
|
||||||
<h2>Full Transcript</h2>
|
|
||||||
<div class="transcript-body" id="transcript-body">
|
|
||||||
<div class="episodes-loading">Loading transcript...</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<noscript>
|
|
||||||
<section class="transcript-section">
|
|
||||||
<p>This page requires JavaScript to load the episode transcript. Please enable JavaScript or listen on <a href="https://open.spotify.com/show/0ZrpMigG1fo0CCN7F4YmuF">Spotify</a>, <a href="https://podcasts.apple.com/us/podcast/luke-at-the-roost/id1875205848">Apple Podcasts</a>, or <a href="https://www.youtube.com/watch?v=xryGLifMBTY&list=PLGq4uZyNV1yYH_rcitTTPVysPbC6-7pe-">YouTube</a>.</p>
|
|
||||||
</section>
|
|
||||||
</noscript>
|
|
||||||
|
|
||||||
<footer class="footer"></footer>
|
|
||||||
|
|
||||||
<!-- Sticky Audio Player -->
|
|
||||||
<div class="sticky-player" id="sticky-player">
|
|
||||||
<div class="player-inner">
|
|
||||||
<button class="player-play-btn" id="player-play-btn" aria-label="Play/Pause">
|
|
||||||
<svg class="icon-play" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
|
||||||
<svg class="icon-pause" viewBox="0 0 24 24" style="display:none"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
|
|
||||||
</button>
|
|
||||||
<div class="player-info">
|
|
||||||
<div class="player-title" id="player-title">—</div>
|
|
||||||
<div class="player-progress-row">
|
|
||||||
<div class="player-progress" id="player-progress" role="slider" aria-label="Audio progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" tabindex="0">
|
|
||||||
<div class="player-progress-fill" id="player-progress-fill"></div>
|
|
||||||
</div>
|
|
||||||
<span class="player-time" id="player-time">0:00 / 0:00</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<audio id="audio-element" preload="none"></audio>
|
|
||||||
|
|
||||||
<script src="js/footer.js"></script>
|
|
||||||
<script src="js/player.js"></script>
|
|
||||||
<script src="js/episode.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
+4
-4
@@ -107,7 +107,7 @@ function createFeaturedCard(ep) {
|
|||||||
<button class="episode-play-btn featured-play-btn" aria-label="Play ${escapeAttr(ep.title)}">
|
<button class="episode-play-btn featured-play-btn" aria-label="Play ${escapeAttr(ep.title)}">
|
||||||
${playSVG}
|
${playSVG}
|
||||||
</button>
|
</button>
|
||||||
${epSlug ? `<a href="/episode.html?slug=${encodeURIComponent(epSlug)}" class="episode-transcript-link">Read Transcript</a>` : ''}
|
${epSlug ? `<a href="/episode/${encodeURIComponent(epSlug)}/" class="episode-transcript-link">Read Transcript</a>` : ''}
|
||||||
<button class="episode-share-btn" aria-label="Share episode">${shareSVG}</button>
|
<button class="episode-share-btn" aria-label="Share episode">${shareSVG}</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -117,7 +117,7 @@ function createFeaturedCard(ep) {
|
|||||||
|
|
||||||
const shareBtn = card.querySelector('.episode-share-btn');
|
const shareBtn = card.querySelector('.episode-share-btn');
|
||||||
const shareUrl = epSlug
|
const shareUrl = epSlug
|
||||||
? `${window.location.origin}/episode.html?slug=${encodeURIComponent(epSlug)}`
|
? `${window.location.origin}/episode/${encodeURIComponent(epSlug)}/`
|
||||||
: window.location.origin;
|
: window.location.origin;
|
||||||
shareBtn.addEventListener('click', () => shareContent(ep.title, shareUrl, shareBtn));
|
shareBtn.addEventListener('click', () => shareContent(ep.title, shareUrl, shareBtn));
|
||||||
|
|
||||||
@@ -207,7 +207,7 @@ function createEpisodeCard(ep) {
|
|||||||
<div class="episode-meta">${metaParts}</div>
|
<div class="episode-meta">${metaParts}</div>
|
||||||
<div class="episode-title">${escapeAttr(ep.title)}</div>
|
<div class="episode-title">${escapeAttr(ep.title)}</div>
|
||||||
<div class="episode-desc">${truncate(ep.description, 150)}</div>
|
<div class="episode-desc">${truncate(ep.description, 150)}</div>
|
||||||
${epSlug ? `<a href="/episode.html?slug=${encodeURIComponent(epSlug)}" class="episode-transcript-link">Read Transcript</a>` : ''}
|
${epSlug ? `<a href="/episode/${encodeURIComponent(epSlug)}/" class="episode-transcript-link">Read Transcript</a>` : ''}
|
||||||
<button class="episode-share-btn" aria-label="Share episode">${shareSVG}</button>
|
<button class="episode-share-btn" aria-label="Share episode">${shareSVG}</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -217,7 +217,7 @@ function createEpisodeCard(ep) {
|
|||||||
|
|
||||||
const shareBtn = card.querySelector('.episode-share-btn');
|
const shareBtn = card.querySelector('.episode-share-btn');
|
||||||
const shareUrl = epSlug
|
const shareUrl = epSlug
|
||||||
? `${window.location.origin}/episode.html?slug=${encodeURIComponent(epSlug)}`
|
? `${window.location.origin}/episode/${encodeURIComponent(epSlug)}/`
|
||||||
: window.location.origin;
|
: window.location.origin;
|
||||||
shareBtn.addEventListener('click', () => shareContent(ep.title, shareUrl, shareBtn));
|
shareBtn.addEventListener('click', () => shareContent(ep.title, shareUrl, shareBtn));
|
||||||
|
|
||||||
|
|||||||
@@ -1,142 +0,0 @@
|
|||||||
const FEED_URL = '/feed';
|
|
||||||
|
|
||||||
function formatDate(dateStr) {
|
|
||||||
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseDuration(raw) {
|
|
||||||
if (!raw) return '';
|
|
||||||
if (raw.includes(':')) {
|
|
||||||
const parts = raw.split(':').map(Number);
|
|
||||||
let t = 0;
|
|
||||||
if (parts.length === 3) t = parts[0]*3600 + parts[1]*60 + parts[2];
|
|
||||||
else if (parts.length === 2) t = parts[0]*60 + parts[1];
|
|
||||||
return `${Math.round(t/60)} min`;
|
|
||||||
}
|
|
||||||
const sec = parseInt(raw, 10);
|
|
||||||
return isNaN(sec) ? '' : `${Math.round(sec/60)} min`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function stripHtml(html) {
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.innerHTML = html || '';
|
|
||||||
return div.textContent || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(str) {
|
|
||||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get slug from URL
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
const slug = params.get('slug');
|
|
||||||
|
|
||||||
if (!slug) {
|
|
||||||
document.getElementById('ep-title').textContent = 'Episode not found';
|
|
||||||
document.getElementById('transcript-body').innerHTML = '<p>No episode specified. <a href="/">Go back to episodes.</a></p>';
|
|
||||||
} else {
|
|
||||||
loadEpisode(slug);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadEpisode(slug) {
|
|
||||||
try {
|
|
||||||
const res = await fetch(FEED_URL);
|
|
||||||
const xml = await res.text();
|
|
||||||
const parser = new DOMParser();
|
|
||||||
const doc = parser.parseFromString(xml, 'text/xml');
|
|
||||||
const items = doc.querySelectorAll('item');
|
|
||||||
|
|
||||||
let episode = null;
|
|
||||||
for (const item of items) {
|
|
||||||
const link = item.querySelector('link')?.textContent || '';
|
|
||||||
const itemSlug = link.split('/episodes/').pop()?.replace(/\/$/, '');
|
|
||||||
if (itemSlug === slug) {
|
|
||||||
episode = {
|
|
||||||
title: item.querySelector('title')?.textContent || 'Untitled',
|
|
||||||
description: item.querySelector('description')?.textContent || '',
|
|
||||||
audioUrl: item.querySelector('enclosure')?.getAttribute('url') || '',
|
|
||||||
pubDate: item.querySelector('pubDate')?.textContent || '',
|
|
||||||
duration: item.getElementsByTagNameNS('http://www.itunes.com/dtds/podcast-1.0.dtd', 'duration')[0]?.textContent || '',
|
|
||||||
episodeNum: item.getElementsByTagNameNS('http://www.itunes.com/dtds/podcast-1.0.dtd', 'episode')[0]?.textContent || '',
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!episode) {
|
|
||||||
document.getElementById('ep-title').textContent = 'Episode not found';
|
|
||||||
document.getElementById('transcript-body').innerHTML = '<p>Could not find this episode. <a href="/">Go back to episodes.</a></p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Populate header
|
|
||||||
const metaParts = [
|
|
||||||
episode.episodeNum ? `Episode ${episode.episodeNum}` : '',
|
|
||||||
episode.pubDate ? formatDate(episode.pubDate) : '',
|
|
||||||
parseDuration(episode.duration),
|
|
||||||
].filter(Boolean).join(' \u00b7 ');
|
|
||||||
|
|
||||||
document.getElementById('ep-meta').textContent = metaParts;
|
|
||||||
document.getElementById('ep-title').textContent = episode.title;
|
|
||||||
document.getElementById('ep-desc').textContent = stripHtml(episode.description || '');
|
|
||||||
|
|
||||||
// Update page meta
|
|
||||||
document.title = `${episode.title} — Luke at the Roost`;
|
|
||||||
document.getElementById('page-description')?.setAttribute('content', `Full transcript of ${episode.title} from Luke at the Roost.`);
|
|
||||||
document.getElementById('og-title')?.setAttribute('content', episode.title);
|
|
||||||
document.getElementById('og-description')?.setAttribute('content', stripHtml(episode.description).slice(0, 200));
|
|
||||||
const canonicalUrl = `https://lukeattheroost.com/episode.html?slug=${slug}`;
|
|
||||||
document.getElementById('page-canonical')?.setAttribute('href', canonicalUrl);
|
|
||||||
document.getElementById('og-url')?.setAttribute('content', canonicalUrl);
|
|
||||||
document.getElementById('tw-title')?.setAttribute('content', episode.title);
|
|
||||||
document.getElementById('tw-description')?.setAttribute('content', stripHtml(episode.description).slice(0, 200));
|
|
||||||
|
|
||||||
// Update JSON-LD structured data
|
|
||||||
const jsonLd = document.getElementById('episode-jsonld');
|
|
||||||
if (jsonLd) {
|
|
||||||
const ld = JSON.parse(jsonLd.textContent);
|
|
||||||
ld.name = episode.title;
|
|
||||||
ld.url = canonicalUrl;
|
|
||||||
ld.description = stripHtml(episode.description).slice(0, 300);
|
|
||||||
if (episode.pubDate) ld.datePublished = new Date(episode.pubDate).toISOString().split('T')[0];
|
|
||||||
if (episode.episodeNum) ld.episodeNumber = parseInt(episode.episodeNum, 10);
|
|
||||||
if (episode.audioUrl) {
|
|
||||||
ld.associatedMedia = {
|
|
||||||
"@type": "MediaObject",
|
|
||||||
"contentUrl": episode.audioUrl
|
|
||||||
};
|
|
||||||
}
|
|
||||||
jsonLd.textContent = JSON.stringify(ld);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Play button
|
|
||||||
if (episode.audioUrl) {
|
|
||||||
const playBtn = document.getElementById('ep-play-btn');
|
|
||||||
playBtn.style.display = 'inline-flex';
|
|
||||||
playBtn.addEventListener('click', () => {
|
|
||||||
audio.src = episode.audioUrl;
|
|
||||||
audio.play();
|
|
||||||
playerTitle.textContent = episode.title;
|
|
||||||
stickyPlayer.classList.add('active');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
document.getElementById('ep-title').textContent = 'Error loading episode';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch transcript
|
|
||||||
try {
|
|
||||||
const txRes = await fetch(`/transcripts/${slug}.txt`);
|
|
||||||
if (!txRes.ok) throw new Error('Not found');
|
|
||||||
const text = await txRes.text();
|
|
||||||
const paragraphs = text.split(/\n\n+/).filter(Boolean);
|
|
||||||
const html = paragraphs.map(p => {
|
|
||||||
const escaped = escapeHtml(p);
|
|
||||||
const labeled = escaped.replace(/^([A-Z][A-Z\s'\-]+?):\s*/, '<span class="speaker-label">$1:</span> ');
|
|
||||||
return `<p>${labeled.replace(/\n/g, '<br>')}</p>`;
|
|
||||||
}).join('');
|
|
||||||
document.getElementById('transcript-body').innerHTML = html;
|
|
||||||
} catch (e) {
|
|
||||||
document.getElementById('transcript-body').innerHTML = '<p class="transcript-unavailable">Transcript not yet available for this episode.</p>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+7
-7
@@ -48,7 +48,7 @@ The show is entirely custom-built: callers are generated in real-time using larg
|
|||||||
- **How It Works**: https://lukeattheroost.com/how-it-works — Technical deep dive into the AI caller generation, recording, and production pipeline
|
- **How It Works**: https://lukeattheroost.com/how-it-works — Technical deep dive into the AI caller generation, recording, and production pipeline
|
||||||
- **Clips**: https://lukeattheroost.com/clips — Best moments from the show as video clips
|
- **Clips**: https://lukeattheroost.com/clips — Best moments from the show as video clips
|
||||||
- **Stats**: https://lukeattheroost.com/stats — Download numbers, reviews, YouTube metrics
|
- **Stats**: https://lukeattheroost.com/stats — Download numbers, reviews, YouTube metrics
|
||||||
- **Episode transcripts**: https://lukeattheroost.com/episode.html?slug=EPISODE-SLUG — Full text transcripts of every episode
|
- **Episode transcripts**: https://lukeattheroost.com/episode/EPISODE-SLUG/ — Full text transcripts of every episode
|
||||||
|
|
||||||
## Community
|
## Community
|
||||||
|
|
||||||
@@ -121,16 +121,16 @@ Each episode (~30-60 minutes) follows a loose late-night radio format:
|
|||||||
## Recent Episodes
|
## Recent Episodes
|
||||||
|
|
||||||
Episodes are published daily. Each has a full transcript available at:
|
Episodes are published daily. Each has a full transcript available at:
|
||||||
https://lukeattheroost.com/episode.html?slug=EPISODE-SLUG
|
https://lukeattheroost.com/episode/EPISODE-SLUG/
|
||||||
|
|
||||||
Episode transcript URLs follow the pattern: episode-N-title-slug
|
Episode transcript URLs follow the pattern: episode-N-title-slug
|
||||||
|
|
||||||
Recent episodes include:
|
Recent episodes include:
|
||||||
- Episode 37: "Secrets, Lies, and Coffee Runs" — https://lukeattheroost.com/episode.html?slug=episode-37-secrets-lies-and-coffee-runs
|
- Episode 37: "Secrets, Lies, and Coffee Runs" — https://lukeattheroost.com/episode/episode-37-secrets-lies-and-coffee-runs/
|
||||||
- Episode 36: "Late Night Confessions and Unexpected Moments" — https://lukeattheroost.com/episode.html?slug=episode-36-late-night-confessions-and-unexpected-moments
|
- Episode 36: "Late Night Confessions and Unexpected Moments" — https://lukeattheroost.com/episode/episode-36-late-night-confessions-and-unexpected-moments/
|
||||||
- Episode 35: "Midnight Confessions and Unexpected Revelations" — https://lukeattheroost.com/episode.html?slug=episode-35-midnight-confessions-and-unexpected-revelations
|
- Episode 35: "Midnight Confessions and Unexpected Revelations" — https://lukeattheroost.com/episode/episode-35-midnight-confessions-and-unexpected-revelations/
|
||||||
- Episode 34: "Hidden Rooms, Potlucks, and Mysterious Notes" — https://lukeattheroost.com/episode.html?slug=episode-34-hidden-rooms-potlucks-and-mysterious-notes
|
- Episode 34: "Hidden Rooms, Potlucks, and Mysterious Notes" — https://lukeattheroost.com/episode/episode-34-hidden-rooms-potlucks-and-mysterious-notes/
|
||||||
- Episode 33: "Late Night Confessions and Cosmic Comedies" — https://lukeattheroost.com/episode.html?slug=episode-33-late-night-confessions-and-cosmic-comedies
|
- Episode 33: "Late Night Confessions and Cosmic Comedies" — https://lukeattheroost.com/episode/episode-33-late-night-confessions-and-cosmic-comedies/
|
||||||
|
|
||||||
## Clip Highlights
|
## Clip Highlights
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user