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
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>