# Website JS Infrastructure, SEO, Shared Components & Content Fixes
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Fix JS duplication, add worker-level social meta injection, standardize analytics proxying, improve security and UX, and clean up content/SEO issues across lukeattheroost.com.
**Architecture:** Extract shared footer into `js/footer.js`, extract shared audio player into `js/player.js`, enhance `_worker.js` to intercept social crawler requests and inject episode-specific meta tags, switch all subpages to proxied Plausible analytics, add episode pagination, fix XSS surfaces, and clean up sitemap/clips data.
**Tech Stack:** Vanilla JS, Cloudflare Pages Worker (ES module), static HTML, XML sitemap
---
### Task 1: Create shared footer component (`js/footer.js`)
**Files:**
- Create: `website/js/footer.js`
**Step 1: Write footer.js**
The footer HTML is duplicated across 7 pages (index.html:265-306, episode.html:95-136, clips.html:68-109, stats.html, privacy.html, terms.html, how-it-works.html). Extract the footer from `index.html` as the canonical version.
```js
function initFooter() {
const footer = document.querySelector('.footer');
if (!footer) return;
footer.innerHTML = `
`;
}
initFooter();
```
**Step 2: Commit**
```bash
git add website/js/footer.js
git commit -m "Add shared footer component (js/footer.js)"
```
---
### Task 2: Replace inline footers with shared component
**Files:**
- Modify: `website/index.html` — replace lines 265-306 (inline footer content) with empty ``, add `` before closing ``, before any page-specific scripts
Note: index.html's footer has slightly different nav links (no "Home" link since it IS home). The shared footer includes "Home" which is fine — clicking Home on the homepage just reloads it.
**Step 2: Verify no footer content remains inline**
Search for `footer-icons-label` in all HTML files — should only appear in `js/footer.js`.
**Step 3: Commit**
```bash
git add website/index.html website/episode.html website/clips.html website/stats.html website/privacy.html website/terms.html website/how-it-works.html
git commit -m "Replace inline footers with shared footer.js component"
```
---
### Task 3: Extract shared audio player module (`js/player.js`)
**Files:**
- Create: `website/js/player.js`
The audio player code is duplicated: `app.js:1-11,14-23,143-226` and `episode.html:159-346` (inline `` then ``
- Create: `website/js/episode.js` — episode-specific logic extracted from episode.html inline script (load episode from RSS, populate header, transcript loading, play button)
- Modify: `website/index.html` — add `` before `app.js`
**Step 1: Refactor app.js**
Remove from app.js:
- Lines 1-11 (element lookups — now in player.js)
- Lines 14-23 (formatTime — now in player.js)
- Lines 173-226 (audio event listeners, updatePlayIcons, playerPlayBtn click, playerProgress click — now in player.js)
Keep the `currentEpisodeCard` variable and the card-specific icon toggling in `updatePlayIcons`. Since player.js handles the sticky player icons, app.js only needs to handle the episode card icons. Add a listener:
```js
audio.addEventListener('play', () => {
if (currentEpisodeCard) {
const btn = currentEpisodeCard.querySelector('.episode-play-btn');
if (btn) { btn.innerHTML = pauseSVG; btn.classList.add('playing'); }
}
});
audio.addEventListener('pause', () => {
if (currentEpisodeCard) {
const btn = currentEpisodeCard.querySelector('.episode-play-btn');
if (btn) { btn.innerHTML = playSVG; btn.classList.remove('playing'); }
}
});
audio.addEventListener('ended', () => {
if (currentEpisodeCard) {
const btn = currentEpisodeCard.querySelector('.episode-play-btn');
if (btn) { btn.innerHTML = playSVG; btn.classList.remove('playing'); }
}
});
```
**Step 2: Create episode.js**
Extract episode-specific logic from episode.html inline script. Use the global `audio`, `playerTitle`, `stickyPlayer` from player.js. Include: `formatDate`, `parseDuration`, `stripHtml`, slug parsing, `loadEpisode()`.
**Step 3: Update HTML script tags**
In `index.html`, change script loading order:
```html
```
In `episode.html`, replace inline `
```
**Step 4: Commit**
```bash
git add website/js/app.js website/js/player.js website/js/episode.js website/index.html website/episode.html
git commit -m "Deduplicate audio player code into shared player.js module"
```
---
### Task 5: Fix Plausible analytics — switch all subpages to proxied version
**Files:**
- Modify: `website/episode.html` line 51-52
- Modify: `website/clips.html` line 43-44
- Modify: `website/stats.html` line 43-44
- Modify: `website/privacy.html` line 37-38
- Modify: `website/terms.html` line 37-38
- Modify: `website/how-it-works.html` line 66-67
**Step 1: In each file, replace the direct Plausible script tag**
Replace:
```html
```
With:
```html
```
**Step 2: Verify**
Grep for `plausible.macneilmediagroup.com` in HTML files — should return 0 matches (only `_worker.js` should have it).
**Step 3: Commit**
```bash
git add website/episode.html website/clips.html website/stats.html website/privacy.html website/terms.html website/how-it-works.html
git commit -m "Switch all subpages to proxied Plausible analytics"
```
---
### Task 6: Worker — social crawler meta tag injection for episode pages
**Files:**
- Modify: `website/_worker.js`
**Step 1: Add social crawler detection and meta injection**
Before the `return env.ASSETS.fetch(request)` line (line 90), add a handler for `/episode.html` requests from social crawlers:
```js
// Social crawler meta injection for episode pages
if (url.pathname === "/episode.html" && url.searchParams.get("slug")) {
const ua = (request.headers.get("User-Agent") || "").toLowerCase();
const isCrawler = /facebookexternalhit|twitterbot|linkedinbot|slackbot|discordbot|telegrambot|whatsapp|pinterest|redditbot/i.test(ua);
if (isCrawler) {
const slug = url.searchParams.get("slug");
// Fetch RSS to find episode info
try {
const feedResp = await fetch("https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml", {
signal: AbortSignal.timeout(5000),
});
if (feedResp.ok) {
const feedXml = await feedResp.text();
// Simple string-based extraction (no DOM parser in Workers)
const items = feedXml.split("");
let title = "";
let description = "";
for (let i = 1; i < items.length; i++) {
const item = items[i];
const linkMatch = item.match(/(.*?)<\/link>/);
if (linkMatch) {
const itemSlug = linkMatch[1].split("/episodes/").pop()?.replace(/\/$/, "");
if (itemSlug === slug) {
const titleMatch = item.match(/(.*?)<\/title>/);
title = titleMatch ? titleMatch[1].replace(//g, "").trim() : "";
const descMatch = item.match(/(.*?)<\/description>/s);
description = descMatch
? descMatch[1].replace(//g, "").replace(/<[^>]+>/g, "").trim().slice(0, 200)
: "";
break;
}
}
}
if (title) {
// Fetch the actual HTML page
const pageResp = await env.ASSETS.fetch(request);
let html = await pageResp.text();
const escTitle = title.replace(/&/g, "&").replace(/"/g, """).replace(/]*>/,
``
);
html = html.replace(
/]*>/,
``
);
html = html.replace(
/]*>/,
``
);
html = html.replace(
/]*>/,
``
);
html = html.replace(
/]*>/,
``
);
html = html.replace(
/]*>.*?<\/title>/,
`${escTitle} — Luke at the Roost`
);
return new Response(html, {
status: 200,
headers: { "Content-Type": "text/html;charset=UTF-8" },
});
}
}
} catch (e) {
// Fall through to static page
}
}
}
```
**Step 2: Commit**
```bash
git add website/_worker.js
git commit -m "Add social crawler meta tag injection for episode pages"
```
---
### Task 7: Security — sanitize innerHTML XSS surfaces
**Files:**
- Modify: `website/js/episode.js` (created in Task 4)
- Modify: `website/js/app.js`
**Step 1: Fix episode description XSS in episode.js**
In the `loadEpisode` function, change line that sets description:
```js
// BEFORE (XSS):
document.getElementById('ep-desc').innerHTML = episode.description || '';
// AFTER (safe):
document.getElementById('ep-desc').textContent = stripHtml(episode.description || '');
```
**Step 2: Fix title escaping in app.js episode card rendering**
In `renderEpisodes()`, the title goes into a `data-title` attribute with basic `.replace(/"/g, '"')`. Use the `escapeHTML` pattern from clips.js. Add a helper at top of app.js:
```js
function escapeAttr(str) {
return str.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>');
}
```
Then change line 125:
```js
// BEFORE:
data-title="${ep.title.replace(/"/g, '"')}"
// AFTER:
data-title="${escapeAttr(ep.title)}"
```
Also escape the title in the aria-label and visible title:
```js