Extract shared layout template for HTML pages
Deduplicates the HTML shell, base CSS, footer, and analytics
script that were copy-pasted between the index and episode
templates. A render_page helper does the two-pass render.

Assisted-by: Claude Opus 4.6 via pi
change vuxoupttlkymvumypqutwqzvsqrwspnl
commit c6392d2a4cd3f4ffa63dae3ab682b4f9d8acb594
author Alpha Chen <alpha@kejadlen.dev>
date
parent kksykkxn
diff --git a/Rakefile b/Rakefile
index e419b79..6ee52e1 100644
--- a/Rakefile
+++ b/Rakefile
@@ -1,3 +1,5 @@
+require "cgi"
+require "erb"
 require "json"
 require "net/http"
 require "pathname"
@@ -65,9 +67,22 @@ EPISODE_TASKS = episodes.map.with_index do |ep, i|
   CookingIssues::EpisodeTask.new(index: i, episode: ep, config: CONFIG)
 end
 
+LAYOUT_TEMPLATE_PATH = File.expand_path("lib/pages/layout.html.erb", __dir__)
 EPISODE_TEMPLATE_PATH = File.expand_path("lib/pages/episode.html.erb", __dir__)
 INDEX_TEMPLATE_PATH = File.expand_path("lib/pages/index.html.erb", __dir__)
 
+# Renders an inner template inside the shared layout.
+# page_vars are passed to the inner template; layout_vars supply
+# title, styles, head, scripts, and post_footer to the layout.
+def render_page(inner_path, page_vars:, layout_vars:)
+  inner = ERB.new(File.read(inner_path))
+  content = inner.result_with_hash(**page_vars)
+
+  layout = ERB.new(File.read(LAYOUT_TEMPLATE_PATH))
+  defaults = { head: "", styles: "", scripts: "", post_footer: "" }
+  layout.result_with_hash(**defaults, **layout_vars, content:)
+end
+
 # --- Per-episode file tasks ---
 
 EPISODE_TASKS.each do |et|
@@ -89,11 +104,7 @@ EPISODE_TASKS.each do |et|
     end
   end
 
-  file et.html_path => [et.text_path, CONFIG.pages_dir.to_s, EPISODE_TEMPLATE_PATH] do
-    require "cgi"
-    require "erb"
-
-    template = ERB.new(File.read(EPISODE_TEMPLATE_PATH))
+  file et.html_path => [et.text_path, CONFIG.pages_dir.to_s, LAYOUT_TEMPLATE_PATH, EPISODE_TEMPLATE_PATH] do
     ep = {
       number: et.number,
       title: et.episode.title.sub(/^Episode #{et.number}:\s+/, ""),
@@ -101,7 +112,61 @@ EPISODE_TASKS.each do |et|
       audio_url: et.episode.audio_url,
       text: File.read(et.text_path)
     }
-    File.write(et.html_path, template.result_with_hash(ep:))
+
+    html = render_page(EPISODE_TEMPLATE_PATH,
+      page_vars: { ep: },
+      layout_vars: {
+        title: "#{CGI.escapeHTML("#{ep.fetch(:number)}. #{ep.fetch(:title)}")} — Cooking Issues",
+        styles: <<~CSS,
+          body { padding-bottom: 5rem; }
+          h1 { font-size: 1.3rem; margin-bottom: 0.5rem; }
+          .back { display: inline-block; margin-bottom: 1rem; color: #0066cc; text-decoration: none; }
+          .back:hover { text-decoration: underline; }
+          p { margin-bottom: 1em; }
+          h6.timestamp { color: #888; font-size: 0.85rem; font-family: monospace; font-weight: normal; margin-bottom: 0.25em; }
+          h6.timestamp a { color: inherit; text-decoration: none; }
+          h6.timestamp a:hover { text-decoration: underline; color: #0066cc; }
+          h6.timestamp + p { margin-top: 0; }
+          :target { background: #fff3a8; }
+          [data-pagefind-highlight] { background: #fff3a8; padding: 0 2px; border-radius: 2px; }
+          .player { position: sticky; bottom: 0; background: #f8f8f8; border-top: 1px solid #ddd; padding: 0.5rem 0; margin: 0 -1rem; padding-left: 1rem; padding-right: 1rem; }
+          .player-note { font-size: 0.8rem; color: #888; margin-bottom: 0.25rem; }
+          .player audio { width: 100%; }
+        CSS
+        post_footer: <<~HTML,
+          <div class="player">
+            <p class="player-note">Timestamps may be off due to dynamic ad insertion.</p>
+            <audio id="audio" controls preload="none" src="#{CGI.escapeHTML(ep.fetch(:audio_url))}"></audio>
+          </div>
+        HTML
+        scripts: <<~HTML,
+          <script type="module">
+            const audio = document.getElementById("audio");
+
+            function parseTimestamp(ts) {
+              const parts = ts.split(":").map(Number);
+              if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
+              if (parts.length === 2) return parts[0] * 60 + parts[1];
+              return parts[0];
+            }
+
+            document.addEventListener("click", (e) => {
+              const link = e.target.closest("[data-seek]");
+              if (!link) return;
+              e.preventDefault();
+              const seconds = parseTimestamp(link.dataset.seek);
+              audio.currentTime = seconds;
+              audio.play();
+              history.replaceState(null, "", link.getAttribute("href"));
+            });
+
+            await import("./pagefind/pagefind-highlight.js");
+            new PagefindHighlight({ highlightParam: "highlight" });
+          </script>
+        HTML
+      })
+
+    File.write(et.html_path, html)
   end
 end
 
@@ -151,20 +216,36 @@ EPISODE_HTML_TASKS = EPISODE_TASKS.select { |et| Pathname(et.text_path).exist? }
 EPISODE_HTML_PATHS = EPISODE_HTML_TASKS.map(&:html_path)
 INDEX_HTML_PATH = (CONFIG.pages_dir / "index.html").to_s
 
-file INDEX_HTML_PATH => [*EPISODE_HTML_PATHS, INDEX_TEMPLATE_PATH] do
-  require "cgi"
-  require "erb"
-
-  template = ERB.new(File.read(INDEX_TEMPLATE_PATH))
-  transcripts = EPISODE_HTML_TASKS.map do |et|
+file INDEX_HTML_PATH => [*EPISODE_HTML_PATHS, LAYOUT_TEMPLATE_PATH, INDEX_TEMPLATE_PATH] do
+  transcripts = EPISODE_HTML_TASKS.map { |et|
     {
       number: et.number,
       title: et.episode.title.sub(/^Episode #{et.number}:\s+/, ""),
       slug: et.slug,
     }
-  end
+  }
+
+  html = render_page(INDEX_TEMPLATE_PATH,
+    page_vars: { transcripts: },
+    layout_vars: {
+      title: "Cooking Issues Transcripts",
+      head: '<link href="./pagefind/pagefind-ui.css" rel="stylesheet">',
+      styles: <<~CSS,
+        h1 { margin-bottom: 1rem; }
+        #search { margin-bottom: 1.5rem; }
+        ul { list-style: none; }
+        li { padding: 0.4rem 0; border-bottom: 1px solid #eee; }
+      CSS
+      scripts: <<~HTML,
+        <script src="./pagefind/pagefind-ui.js"></script>
+        <script>
+          window.addEventListener('DOMContentLoaded', (event) => {
+            new PagefindUI({ element: "#search", showSubResults: true, highlightParam: "highlight" });
+          });
+        </script>
+      HTML
+    })
 
-  html = template.result_with_hash(transcripts:)
   File.write(INDEX_HTML_PATH, html)
 
   puts "Generated #{transcripts.length} episode pages + index."
diff --git a/lib/pages/episode.html.erb b/lib/pages/episode.html.erb
index b9de18e..701ebe1 100644
--- a/lib/pages/episode.html.erb
+++ b/lib/pages/episode.html.erb
@@ -1,34 +1,7 @@
-<!DOCTYPE html>
-<html lang="en">
-<head>
-<meta charset="utf-8">
-<meta name="viewport" content="width=device-width, initial-scale=1">
-<title><%= CGI.escapeHTML("#{ep[:number]}. #{ep[:title]}") %> — Cooking Issues</title>
-<style>
-  * { box-sizing: border-box; margin: 0; padding: 0; }
-  body { font-family: system-ui, sans-serif; line-height: 1.6; color: #222; max-width: 50rem; margin: 0 auto; padding: 1rem; padding-bottom: 5rem; }
-  h1 { font-size: 1.3rem; margin-bottom: 0.5rem; }
-  .back { display: inline-block; margin-bottom: 1rem; color: #0066cc; text-decoration: none; }
-  .back:hover { text-decoration: underline; }
-  p { margin-bottom: 1em; }
-  h6.timestamp { color: #888; font-size: 0.85rem; font-family: monospace; font-weight: normal; margin-bottom: 0.25em; }
-  h6.timestamp a { color: inherit; text-decoration: none; }
-  h6.timestamp a:hover { text-decoration: underline; color: #0066cc; }
-  h6.timestamp + p { margin-top: 0; }
-  :target { background: #fff3a8; }
-  [data-pagefind-highlight] { background: #fff3a8; padding: 0 2px; border-radius: 2px; }
-  footer { margin-top: 2rem; color: #888; font-size: 0.85rem; }
-  .player { position: sticky; bottom: 0; background: #f8f8f8; border-top: 1px solid #ddd; padding: 0.5rem 0; margin: 0 -1rem; padding-left: 1rem; padding-right: 1rem; }
-  .player-note { font-size: 0.8rem; color: #888; margin-bottom: 0.25rem; }
-  .player audio { width: 100%; }
-</style>
-</head>
-<body>
-
 <a class="back" href="index.html">← All episodes</a>
-<h1><%= CGI.escapeHTML("#{ep[:number]}. #{ep[:title]}") %></h1>
+<h1><%= CGI.escapeHTML("#{ep.fetch(:number)}. #{ep.fetch(:title)}") %></h1>
 
-<% ep[:text].split("\n\n").each_with_index do |para, i| %>
+<% ep.fetch(:text).split("\n\n").each do |para| %>
   <% if (m = para.match(/\A\[([^\]]+)\]\s*(.*)/)) %>
     <% anchor = m[1].gsub(/[^0-9:]/, "").gsub(":", "-") %>
     <% time = m[1].gsub(/[^0-9:]/, "") %>
@@ -38,40 +11,3 @@
     <p><%= CGI.escapeHTML(para) %></p>
   <% end %>
 <% end %>
-
-<footer>
-  <p>Source on <a href="https://git.kejadlen.dev/alpha/low-quality-transcripts">git.kejadlen.dev</a>. PRs welcome on <a href="https://github.com/kejadlen/low-quality-transcripts">GitHub</a>.</p>
-</footer>
-
-<div class="player">
-  <p class="player-note">Timestamps may be off due to dynamic ad insertion.</p>
-  <audio id="audio" controls preload="none" src="<%= CGI.escapeHTML(ep[:audio_url]) %>"></audio>
-</div>
-
-<script type="module">
-  const audio = document.getElementById("audio");
-
-  function parseTimestamp(ts) {
-    const parts = ts.split(":").map(Number);
-    if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
-    if (parts.length === 2) return parts[0] * 60 + parts[1];
-    return parts[0];
-  }
-
-  document.addEventListener("click", (e) => {
-    const link = e.target.closest("[data-seek]");
-    if (!link) return;
-    e.preventDefault();
-    const seconds = parseTimestamp(link.dataset.seek);
-    audio.currentTime = seconds;
-    audio.play();
-    history.replaceState(null, "", link.getAttribute("href"));
-  });
-
-  await import("./pagefind/pagefind-highlight.js");
-  new PagefindHighlight({ highlightParam: "highlight" });
-</script>
-<script data-goatcounter="https://low-quality-transcripts.goatcounter.com/count" async src="//gc.zgo.at/count.js"></script>
-
-</body>
-</html>
diff --git a/lib/pages/index.html.erb b/lib/pages/index.html.erb
index c8d0da0..68ab041 100644
--- a/lib/pages/index.html.erb
+++ b/lib/pages/index.html.erb
@@ -1,24 +1,3 @@
-<!DOCTYPE html>
-<html lang="en">
-<head>
-<meta charset="utf-8">
-<meta name="viewport" content="width=device-width, initial-scale=1">
-<title>Cooking Issues Transcripts</title>
-<link href="./pagefind/pagefind-ui.css" rel="stylesheet">
-<style>
-  * { box-sizing: border-box; margin: 0; padding: 0; }
-  body { font-family: system-ui, sans-serif; line-height: 1.6; color: #222; max-width: 50rem; margin: 0 auto; padding: 1rem; }
-  h1 { margin-bottom: 1rem; }
-  #search { margin-bottom: 1.5rem; }
-  ul { list-style: none; }
-  li { padding: 0.4rem 0; border-bottom: 1px solid #eee; }
-  a { color: #0066cc; text-decoration: none; }
-  a:hover { text-decoration: underline; }
-  footer { margin-top: 2rem; color: #888; font-size: 0.85rem; }
-</style>
-</head>
-<body>
-
 <h1>Cooking Issues Transcripts</h1>
 
 <div id="search"></div>
@@ -27,21 +6,6 @@
 
 <ul>
 <% transcripts.each do |ep| %>
-  <li><a href="<%= ep[:slug] %>.html"><%= CGI.escapeHTML("#{ep[:number]}. #{ep[:title]}") %></a></li>
+  <li><a href="<%= ep.fetch(:slug) %>.html"><%= CGI.escapeHTML("#{ep.fetch(:number)}. #{ep.fetch(:title)}") %></a></li>
 <% end %>
 </ul>
-
-<footer>
-  <p>Source on <a href="https://git.kejadlen.dev/alpha/low-quality-transcripts">git.kejadlen.dev</a>. PRs welcome on <a href="https://github.com/kejadlen/low-quality-transcripts">GitHub</a>.</p>
-</footer>
-
-<script src="./pagefind/pagefind-ui.js"></script>
-<script>
-  window.addEventListener('DOMContentLoaded', (event) => {
-    new PagefindUI({ element: "#search", showSubResults: true, highlightParam: "highlight" });
-  });
-</script>
-<script data-goatcounter="https://low-quality-transcripts.goatcounter.com/count" async src="//gc.zgo.at/count.js"></script>
-
-</body>
-</html>
diff --git a/lib/pages/layout.html.erb b/lib/pages/layout.html.erb
new file mode 100644
index 0000000..fa366a2
--- /dev/null
+++ b/lib/pages/layout.html.erb
@@ -0,0 +1,31 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title><%= title %></title>
+<%= head %>
+<style>
+  * { box-sizing: border-box; margin: 0; padding: 0; }
+  body { font-family: system-ui, sans-serif; line-height: 1.6; color: #222; max-width: 50rem; margin: 0 auto; padding: 1rem; }
+  a { color: #0066cc; text-decoration: none; }
+  a:hover { text-decoration: underline; }
+  footer { margin-top: 2rem; color: #888; font-size: 0.85rem; }
+  <%= styles %>
+</style>
+</head>
+<body>
+
+<%= content %>
+
+<footer>
+  <p>Source on <a href="https://git.kejadlen.dev/alpha/low-quality-transcripts">git.kejadlen.dev</a>. PRs welcome on <a href="https://github.com/kejadlen/low-quality-transcripts">GitHub</a>.</p>
+</footer>
+
+<%= post_footer %>
+
+<%= scripts %>
+<script data-goatcounter="https://low-quality-transcripts.goatcounter.com/count" async src="//gc.zgo.at/count.js"></script>
+
+</body>
+</html>