From 56a6a2dfbedace8310f0a7495e80555200f79f97 Mon Sep 17 00:00:00 2001 From: tcpsyn Date: Fri, 14 Aug 2026 03:46:01 -0500 Subject: [PATCH] Add episode page renderer with PodcastEpisode schema --- tests/test_episode_render.py | 121 +++++++++++++++++++ website_gen/render.py | 219 +++++++++++++++++++++++++++++++++++ 2 files changed, 340 insertions(+) create mode 100644 tests/test_episode_render.py create mode 100644 website_gen/render.py diff --git a/tests/test_episode_render.py b/tests/test_episode_render.py new file mode 100644 index 0000000..5132bed --- /dev/null +++ b/tests/test_episode_render.py @@ -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 "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 diff --git a/website_gen/render.py b/website_gen/render.py new file mode 100644 index 0000000..7233bea --- /dev/null +++ b/website_gen/render.py @@ -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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+ +
+ +
+

Transcript

+{transcript} +
+ +{episode_nav} +
+ + + + + +""" + + +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'') + 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 '

Transcript not yet available for this episode.

' + rows = [ + f'
' + f'{_esc(speaker)}' + f"

{_esc(text)}

" + 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' " + ) + if next_ep is not None: + links.append( + f' " + ) + if not links: + return "" + return ' \n"