Add episode page renderer with PodcastEpisode schema
This commit is contained in:
@@ -0,0 +1,121 @@
|
|||||||
|
import json
|
||||||
|
import re
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from website_gen.feed import Episode
|
||||||
|
from website_gen.render import render_episode_page
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_episode():
|
||||||
|
return Episode(
|
||||||
|
number=58,
|
||||||
|
slug="episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho",
|
||||||
|
title="Episode 58: Rayfield's Nephew, the Marfa Lights, and Why Nobody Believes Concho",
|
||||||
|
description=(
|
||||||
|
"Luke fields calls about stolen catalytic converters, unexplained phenomena over "
|
||||||
|
"Mitchell Flat, a sheriff's deputy's moral dilemma, and a fence builder convinced "
|
||||||
|
"someone is mapping West Texas water sources."
|
||||||
|
),
|
||||||
|
published_iso="2026-08-04T09:22:38+00:00",
|
||||||
|
duration_seconds=4770,
|
||||||
|
audio_url=(
|
||||||
|
"https://podcast.macneilmediagroup.com/audio/@LukeAtTheRoost/"
|
||||||
|
"episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho.mp3"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def other_episode():
|
||||||
|
return Episode(
|
||||||
|
number=57,
|
||||||
|
slug="episode-57-trace-s-box-of-family-secrets",
|
||||||
|
title="Episode 57: Trace's Box of Family Secrets",
|
||||||
|
description="Letters in a shoebox turn a family story inside out.",
|
||||||
|
published_iso="2026-06-02T11:32:58+00:00",
|
||||||
|
duration_seconds=4066,
|
||||||
|
audio_url=(
|
||||||
|
"https://podcast.macneilmediagroup.com/audio/@LukeAtTheRoost/"
|
||||||
|
"episode-57-trace-s-box-of-family-secrets.mp3"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_title_and_canonical_are_episode_specific(sample_episode):
|
||||||
|
html = render_episode_page(sample_episode, turns=[("LUKE", "Hello.")])
|
||||||
|
assert "<title>Episode 58: Rayfield" in html
|
||||||
|
assert '<link rel="canonical" href="https://lukeattheroost.com/episode/episode-58-' in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_transcript_is_in_the_html_not_fetched_by_js(sample_episode):
|
||||||
|
html = render_episode_page(sample_episode, turns=[("LUKE", "The Marfa Lights are real.")])
|
||||||
|
assert "The Marfa Lights are real." in html
|
||||||
|
assert "fetch(" not in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_emits_valid_podcastepisode_schema(sample_episode):
|
||||||
|
html = render_episode_page(sample_episode, turns=[("LUKE", "Hi.")])
|
||||||
|
block = re.search(r'<script type="application/ld\+json">(.*?)</script>', html, re.S).group(1)
|
||||||
|
data = json.loads(block)
|
||||||
|
types = {o["@type"] for o in (data if isinstance(data, list) else [data])}
|
||||||
|
assert "PodcastEpisode" in types
|
||||||
|
|
||||||
|
|
||||||
|
def test_schema_carries_big_bend_content_location(sample_episode):
|
||||||
|
html = render_episode_page(sample_episode, turns=[])
|
||||||
|
block = re.search(r'<script type="application/ld\+json">(.*?)</script>', html, re.S).group(1)
|
||||||
|
data = json.loads(block)
|
||||||
|
ep = next(o for o in data if o["@type"] == "PodcastEpisode")
|
||||||
|
assert ep["contentLocation"]["address"]["addressLocality"] == "Alpine"
|
||||||
|
|
||||||
|
|
||||||
|
def test_escapes_html_in_transcript_text(sample_episode):
|
||||||
|
html = render_episode_page(sample_episode, turns=[("LUKE", "5 < 6 & <script>alert(1)</script>")])
|
||||||
|
assert "<script>alert(1)</script>" not in html
|
||||||
|
assert "<script>" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_escapes_quotes_in_title_meta(sample_episode):
|
||||||
|
sample_episode.title = 'Episode 1: The "Best" Show'
|
||||||
|
html = render_episode_page(sample_episode, turns=[])
|
||||||
|
assert 'content="Episode 1: The "Best"' not in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_schema_json_is_valid_even_with_quotes_in_title(sample_episode):
|
||||||
|
"""JSON-LD must stay parseable when the title contains quotes and apostrophes."""
|
||||||
|
sample_episode.title = 'Ep "quoted" and Rayfield\'s'
|
||||||
|
html = render_episode_page(sample_episode, turns=[])
|
||||||
|
block = re.search(r'<script type="application/ld\+json">(.*?)</script>', html, re.S).group(1)
|
||||||
|
json.loads(block) # must not raise
|
||||||
|
|
||||||
|
|
||||||
|
def test_speaker_labels_get_semantic_markup(sample_episode):
|
||||||
|
html = render_episode_page(sample_episode, turns=[("LUKE", "Hi."), ("SLIM", "Hey.")])
|
||||||
|
assert html.count('class="transcript-turn"') == 2
|
||||||
|
assert "LUKE" in html and "SLIM" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_asset_paths_are_root_absolute(sample_episode):
|
||||||
|
"""Pages live at /episode/<slug>/ so relative asset paths would 404."""
|
||||||
|
html = render_episode_page(sample_episode, turns=[])
|
||||||
|
assert 'href="css/' not in html
|
||||||
|
assert 'src="js/' not in html
|
||||||
|
assert 'href="/css/style.css?v=6"' in html
|
||||||
|
assert 'src="/js/footer.js"' in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_prev_next_links_render_when_given(sample_episode, other_episode):
|
||||||
|
html = render_episode_page(sample_episode, turns=[], prev_ep=other_episode)
|
||||||
|
assert f'/episode/{other_episode.slug}/' in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_transcript_renders_placeholder(sample_episode):
|
||||||
|
html = render_episode_page(sample_episode, turns=[])
|
||||||
|
assert "Transcript not yet available" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_audio_element_present_for_no_js_playback(sample_episode):
|
||||||
|
html = render_episode_page(sample_episode, turns=[])
|
||||||
|
assert "<audio" in html and sample_episode.audio_url in html
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import html
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
SITE_URL = "https://lukeattheroost.com"
|
||||||
|
COVER_IMAGE = "https://cdn.lukeattheroost.com/media/podcasts/LukeAtTheRoost/cover_feed.png?v=3"
|
||||||
|
FEED_URL = "https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml"
|
||||||
|
|
||||||
|
PAGE = """<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{title} — Luke at the Roost</title>
|
||||||
|
<meta name="description" content="{meta_description}">
|
||||||
|
<meta name="theme-color" content="#1a1209">
|
||||||
|
<meta name="rating" content="adult">
|
||||||
|
<link rel="canonical" href="{page_url}">
|
||||||
|
|
||||||
|
<meta property="og:site_name" content="Luke at the Roost">
|
||||||
|
<meta property="og:title" content="{title}">
|
||||||
|
<meta property="og:description" content="{meta_description}">
|
||||||
|
<meta property="og:image" content="{cover}">
|
||||||
|
<meta property="og:url" content="{page_url}">
|
||||||
|
<meta property="og:type" content="article">
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta name="twitter:title" content="{title}">
|
||||||
|
<meta name="twitter:description" content="{meta_description}">
|
||||||
|
<meta name="twitter:image" content="{cover}">
|
||||||
|
|
||||||
|
<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="{feed}">
|
||||||
|
<link rel="stylesheet" href="/css/style.css?v=6">
|
||||||
|
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{schema}
|
||||||
|
</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">
|
||||||
|
|
||||||
|
<section class="page-header">
|
||||||
|
<h1>{title}</h1>
|
||||||
|
<p class="episode-meta">{meta_line}</p>
|
||||||
|
<p class="page-subtitle">{description}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="episode-player">
|
||||||
|
<audio controls preload="none" src="{audio_url}">
|
||||||
|
Your browser does not support audio playback.
|
||||||
|
<a href="{audio_url}">Download the episode</a>
|
||||||
|
</audio>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="episode-transcript">
|
||||||
|
<h2>Transcript</h2>
|
||||||
|
{transcript}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{episode_nav}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="footer"></footer>
|
||||||
|
<script src="/js/footer.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def render_episode_page(episode, turns, prev_ep=None, next_ep=None) -> str:
|
||||||
|
"""Render a complete static episode page, transcript and schema included."""
|
||||||
|
page_url = _page_url(episode.slug)
|
||||||
|
return PAGE.format(
|
||||||
|
title=_esc(episode.title),
|
||||||
|
meta_description=_esc(_truncate(episode.description, 160)),
|
||||||
|
description=_esc(episode.description),
|
||||||
|
page_url=_esc(page_url),
|
||||||
|
cover=_esc(COVER_IMAGE),
|
||||||
|
feed=_esc(FEED_URL),
|
||||||
|
audio_url=_esc(episode.audio_url),
|
||||||
|
meta_line=_meta_line(episode),
|
||||||
|
schema=_schema_block(episode, page_url),
|
||||||
|
transcript=_transcript_html(turns),
|
||||||
|
episode_nav=_episode_nav(prev_ep, next_ep),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _page_url(slug: str) -> str:
|
||||||
|
return f"{SITE_URL}/episode/{slug}/"
|
||||||
|
|
||||||
|
|
||||||
|
def _esc(value) -> str:
|
||||||
|
return html.escape(str(value or ""), quote=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate(text: str, limit: int) -> str:
|
||||||
|
text = (text or "").strip()
|
||||||
|
if len(text) <= limit:
|
||||||
|
return text
|
||||||
|
return text[:limit].rsplit(" ", 1)[0].rstrip(",.;:—- ") + "…"
|
||||||
|
|
||||||
|
|
||||||
|
def _schema_block(episode, page_url: str) -> str:
|
||||||
|
ep_schema = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "PodcastEpisode",
|
||||||
|
"url": page_url,
|
||||||
|
"name": episode.title,
|
||||||
|
"description": episode.description,
|
||||||
|
"associatedMedia": {"@type": "MediaObject", "contentUrl": episode.audio_url},
|
||||||
|
"partOfSeries": {
|
||||||
|
"@type": "PodcastSeries",
|
||||||
|
"name": "Luke at the Roost",
|
||||||
|
"url": SITE_URL,
|
||||||
|
},
|
||||||
|
"contentLocation": {
|
||||||
|
"@type": "Place",
|
||||||
|
"name": "Big Bend, West Texas",
|
||||||
|
"address": {
|
||||||
|
"@type": "PostalAddress",
|
||||||
|
"addressLocality": "Alpine",
|
||||||
|
"addressRegion": "TX",
|
||||||
|
"addressCountry": "US",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if episode.published_iso:
|
||||||
|
ep_schema["datePublished"] = episode.published_iso
|
||||||
|
if episode.duration_seconds:
|
||||||
|
ep_schema["timeRequired"] = f"PT{episode.duration_seconds}S"
|
||||||
|
if episode.number is not None:
|
||||||
|
ep_schema["episodeNumber"] = episode.number
|
||||||
|
|
||||||
|
breadcrumbs = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "BreadcrumbList",
|
||||||
|
"itemListElement": [
|
||||||
|
{"@type": "ListItem", "position": 1, "name": "Home", "item": SITE_URL},
|
||||||
|
{"@type": "ListItem", "position": 2, "name": episode.title, "item": page_url},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
dumped = json.dumps([ep_schema, breadcrumbs], indent=2, ensure_ascii=False)
|
||||||
|
return dumped.replace("<", "\\u003c")
|
||||||
|
|
||||||
|
|
||||||
|
def _meta_line(episode) -> str:
|
||||||
|
parts = []
|
||||||
|
published = _format_date(episode.published_iso)
|
||||||
|
if published:
|
||||||
|
parts.append(f'<time datetime="{_esc(episode.published_iso)}">{_esc(published)}</time>')
|
||||||
|
duration = _format_duration(episode.duration_seconds)
|
||||||
|
if duration:
|
||||||
|
parts.append(_esc(duration))
|
||||||
|
return " · ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_date(published_iso: str) -> str:
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(published_iso)
|
||||||
|
return f"{dt.strftime('%B')} {dt.day}, {dt.year}"
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _format_duration(seconds) -> str:
|
||||||
|
if not seconds:
|
||||||
|
return ""
|
||||||
|
hours, minutes = divmod(int(seconds) // 60, 60)
|
||||||
|
if hours:
|
||||||
|
return f"{hours} hr {minutes} min"
|
||||||
|
return f"{minutes} min"
|
||||||
|
|
||||||
|
|
||||||
|
def _transcript_html(turns) -> str:
|
||||||
|
if not turns:
|
||||||
|
return ' <p class="transcript-empty">Transcript not yet available for this episode.</p>'
|
||||||
|
rows = [
|
||||||
|
f' <div class="transcript-turn">'
|
||||||
|
f'<span class="transcript-speaker">{_esc(speaker)}</span>'
|
||||||
|
f"<p>{_esc(text)}</p></div>"
|
||||||
|
for speaker, text in turns
|
||||||
|
]
|
||||||
|
return "\n".join(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def _episode_nav(prev_ep, next_ep) -> str:
|
||||||
|
links = []
|
||||||
|
if prev_ep is not None:
|
||||||
|
links.append(
|
||||||
|
f' <a class="episode-nav-prev" rel="prev" href="/episode/{_esc(prev_ep.slug)}/">'
|
||||||
|
f"← {_esc(prev_ep.title)}</a>"
|
||||||
|
)
|
||||||
|
if next_ep is not None:
|
||||||
|
links.append(
|
||||||
|
f' <a class="episode-nav-next" rel="next" href="/episode/{_esc(next_ep.slug)}/">'
|
||||||
|
f"{_esc(next_ep.title)} →</a>"
|
||||||
|
)
|
||||||
|
if not links:
|
||||||
|
return ""
|
||||||
|
return ' <nav class="episode-nav">\n' + "\n".join(links) + "\n </nav>\n"
|
||||||
Reference in New Issue
Block a user