The site carried no geographic signal at all: the homepage title, description and schema keywords never mentioned Alpine, Marfa or the Big Bend, and llms.txt still described the show as broadcasting from a fictional desert hermit's RV and being produced in New Mexico. - Homepage title, description and OG/Twitter tags now lead with Alpine, Texas and name the surrounding towns; keywords put the location terms first - PodcastSeries schema gains contentLocation with Alpine's real coordinates (from BIG_BEND_TOWNS), spatialCoverage for the region, and an about array covering Alpine, Marfa, Terlingua, Big Bend National Park and the Marfa Lights - llms.txt: corrects the setting throughout, adds the region to Key Facts, and answers "what places does the show talk about" - Fixes the stale "37+ episodes" in five places, using 50+ so it ages better - Corrects three claims that were also wrong on How It Works: 68 voice profiles (now 82), adaptive call shapes (deleted system), and 5 recording stems (now 7) Also adds the Tier 2 plan for crawlable per-episode pages. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
501 lines
19 KiB
Markdown
501 lines
19 KiB
Markdown
# Crawlable Episode Pages Implementation Plan
|
|
|
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
|
|
**Goal:** Turn 57 client-rendered `?slug=` episode pages into 57 static, crawlable HTML pages at `/episode/<slug>/` containing the full transcript, so the Big Bend content the show already produces becomes indexable.
|
|
|
|
**Architecture:** A build-time Python generator reads the Castopod RSS feed and the existing `website/transcripts/*.txt` files, and writes one fully static page per episode into `website/episode/<slug>/index.html`. No runtime feed dependency, no JS required to see content. The Cloudflare worker gains a 301 from the legacy `?slug=` URL, and loses its user-agent gate. `publish_episode.py` calls the generator so new episodes get a page automatically.
|
|
|
|
**Tech Stack:** Python 3.11 (stdlib `xml.etree`, `html`, `json`, `pathlib`), pytest, Cloudflare Pages + `_worker.js`, wrangler.
|
|
|
|
---
|
|
|
|
## Why This Matters (context for the implementer)
|
|
|
|
Current state, verified 2026-08-14:
|
|
|
|
- `website/episode.html` is a JS shell. It has **zero** server-rendered episode content — it fetches `/feed`, finds the matching `<item>` client-side, then fetches `/transcripts/<slug>.txt`.
|
|
- URLs are query params: `/episode.html?slug=episode-58-...`. 54 of the 64 sitemap entries are this shape.
|
|
- `website/transcripts/` holds **58 `.txt` files, 3.0 MB total**, none of which appear in `sitemap.xml` and which are linked only from JavaScript.
|
|
- `_worker.js:107-165` injects real `<title>`/OG tags — but **only when the User-Agent matches `facebookexternalhit|twitterbot|linkedinbot|slackbot|discordbot|telegrambot|whatsapp|pinterest|redditbot`**. Googlebot, Bingbot, GPTBot, ClaudeBot and PerplexityBot are all absent, so search and answer engines get the generic shell.
|
|
|
|
That UA gate is also a **cloaking risk**: serving different HTML to crawlers than to users is against Google's guidelines. Google deprecated "dynamic rendering" as a workaround. Task 6 removes the gate rather than extending it — once pages are static, nothing needs UA sniffing.
|
|
|
|
### Data facts confirmed before writing this plan
|
|
|
|
- RSS feed: `https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml`, HTTP 200, 124 KB, **57 `<item>` elements**.
|
|
- Each item has: `<title>`, `<link>`, `<pubDate>`, `<guid>`, `<description>` (CDATA-wrapped HTML), `<enclosure url=...>`, `<itunes:duration>` (seconds, e.g. `4770`), `<itunes:episode>` (e.g. `58`).
|
|
- Slug is derived from `<link>`: everything after `/episodes/`, trailing slash stripped.
|
|
- **All 57 feed episodes have a matching transcript file.** Zero missing.
|
|
- One orphan transcript, `episode-32-tacos-taxes-and-tall-tales.txt`, has no feed item — ep 32 never finished publishing (`data/publish_state.json` shows a castopod step and nothing else). **The generator must skip orphans, not crash on them.**
|
|
- Transcript format is plain text, blank-line separated, `SPEAKER: text` per paragraph — e.g. `LUKE: Alright, welcome back...` / `SLIM: Hey Luke, yeah thanks...`.
|
|
|
|
---
|
|
|
|
## Task 1: Transcript parser
|
|
|
|
**Files:**
|
|
- Create: `website_gen/transcript.py`
|
|
- Test: `tests/test_transcript_parser.py`
|
|
|
|
**Step 1: Write the failing test**
|
|
|
|
```python
|
|
import sys
|
|
from pathlib import Path
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
|
|
from website_gen.transcript import parse_transcript
|
|
|
|
|
|
def test_splits_speaker_turns():
|
|
raw = "LUKE: Welcome back.\n\nSLIM: Hey Luke, thanks for taking my call."
|
|
turns = parse_transcript(raw)
|
|
assert turns == [("LUKE", "Welcome back."),
|
|
("SLIM", "Hey Luke, thanks for taking my call.")]
|
|
|
|
|
|
def test_unlabeled_paragraph_carries_previous_speaker():
|
|
raw = "LUKE: First thing.\n\nStill Luke talking."
|
|
assert parse_transcript(raw) == [("LUKE", "First thing."),
|
|
("LUKE", "Still Luke talking.")]
|
|
|
|
|
|
def test_ignores_blank_and_whitespace_paragraphs():
|
|
raw = "LUKE: One.\n\n \n\nSLIM: Two."
|
|
assert len(parse_transcript(raw)) == 2
|
|
|
|
|
|
def test_speaker_name_with_spaces_is_not_treated_as_label():
|
|
"""A colon mid-sentence must not be mistaken for a speaker label."""
|
|
raw = "LUKE: Here's the thing: it was the alternator."
|
|
turns = parse_transcript(raw)
|
|
assert len(turns) == 1
|
|
assert turns[0][0] == "LUKE"
|
|
assert "the thing: it was" in turns[0][1]
|
|
|
|
|
|
def test_empty_input_returns_empty_list():
|
|
assert parse_transcript("") == []
|
|
assert parse_transcript(" \n\n ") == []
|
|
```
|
|
|
|
**Step 2: Run test to verify it fails**
|
|
|
|
Run: `./venv/bin/python -m pytest tests/test_transcript_parser.py -v`
|
|
Expected: FAIL — `ModuleNotFoundError: No module named 'website_gen'`
|
|
|
|
**Step 3: Write minimal implementation**
|
|
|
|
```python
|
|
import re
|
|
|
|
SPEAKER_RE = re.compile(r"^([A-Z][A-Z0-9 .'-]{0,30}):\s*(.*)$", re.DOTALL)
|
|
|
|
|
|
def parse_transcript(raw: str) -> list[tuple[str, str]]:
|
|
"""Split a transcript into (speaker, text) turns.
|
|
|
|
Paragraphs are blank-line separated. A paragraph that does not open with a
|
|
SPEAKER: label is attributed to whoever spoke last, which is how the
|
|
transcriber emits long turns that wrap.
|
|
"""
|
|
turns: list[tuple[str, str]] = []
|
|
current = None
|
|
for para in re.split(r"\n\s*\n", raw or ""):
|
|
para = para.strip()
|
|
if not para:
|
|
continue
|
|
m = SPEAKER_RE.match(para)
|
|
if m:
|
|
current = m.group(1).strip()
|
|
text = m.group(2).strip()
|
|
else:
|
|
text = para
|
|
if current is None:
|
|
current = "LUKE"
|
|
if text:
|
|
turns.append((current, text))
|
|
return turns
|
|
```
|
|
|
|
**Step 4: Run test to verify it passes**
|
|
|
|
Run: `./venv/bin/python -m pytest tests/test_transcript_parser.py -v`
|
|
Expected: PASS, 5 tests
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add website_gen/transcript.py tests/test_transcript_parser.py
|
|
git commit -m "Add transcript parser for episode page generation"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: RSS feed loader
|
|
|
|
**Files:**
|
|
- Create: `website_gen/feed.py`
|
|
- Test: `tests/test_feed_loader.py`
|
|
- Fixture: `tests/fixtures/feed_sample.xml` (hand-trim two `<item>` blocks out of the live feed)
|
|
|
|
**Step 1: Write the failing test**
|
|
|
|
```python
|
|
from website_gen.feed import parse_feed, Episode
|
|
|
|
|
|
def test_parses_core_fields(feed_xml):
|
|
eps = parse_feed(feed_xml)
|
|
ep = next(e for e in eps if e.number == 58)
|
|
assert ep.slug == "episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho"
|
|
assert ep.title.startswith("Episode 58: Rayfield's Nephew")
|
|
assert ep.duration_seconds == 4770
|
|
assert ep.audio_url.startswith("https://")
|
|
|
|
|
|
def test_description_is_stripped_of_cdata_and_html(feed_xml):
|
|
ep = parse_feed(feed_xml)[0]
|
|
assert "<![CDATA[" not in ep.description
|
|
assert "<p>" not in ep.description
|
|
|
|
|
|
def test_slug_comes_from_link_and_drops_trailing_slash(feed_xml):
|
|
for ep in parse_feed(feed_xml):
|
|
assert not ep.slug.endswith("/")
|
|
assert "/" not in ep.slug
|
|
|
|
|
|
def test_pubdate_parses_to_iso_date(feed_xml):
|
|
ep = next(e for e in parse_feed(feed_xml) if e.number == 58)
|
|
assert ep.published_iso.startswith("2026-08-04")
|
|
```
|
|
|
|
**Step 2: Run to verify it fails**
|
|
|
|
Run: `./venv/bin/python -m pytest tests/test_feed_loader.py -v`
|
|
Expected: FAIL — module missing
|
|
|
|
**Step 3: Implement**
|
|
|
|
Use `xml.etree.ElementTree` with the itunes namespace `http://www.itunes.com/dtds/podcast-1.0.dtd`. Dataclass:
|
|
|
|
```python
|
|
@dataclass
|
|
class Episode:
|
|
number: int | None
|
|
slug: str
|
|
title: str
|
|
description: str
|
|
published_iso: str
|
|
duration_seconds: int | None
|
|
audio_url: str
|
|
```
|
|
|
|
Parse `pubDate` with `email.utils.parsedate_to_datetime`. Strip CDATA and tags from `<description>` with a regex, then `html.unescape`.
|
|
|
|
**Step 4: Verify passes.** **Step 5: Commit.**
|
|
|
|
```bash
|
|
git commit -m "Add RSS feed loader for episode page generation"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: Page renderer
|
|
|
|
**Files:**
|
|
- Create: `website_gen/render.py`, `website_gen/templates/episode.html`
|
|
- Test: `tests/test_episode_render.py`
|
|
|
|
The template is a full standalone page matching the existing site chrome (copy the `<nav>`, footer markup and `css/style.css?v=6` link from `website/how-it-works.html` so it looks native).
|
|
|
|
**Step 1: Write the failing test**
|
|
|
|
```python
|
|
import json, re
|
|
from website_gen.render import render_episode_page
|
|
|
|
|
|
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_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 # must be escaped
|
|
|
|
|
|
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
|
|
```
|
|
|
|
**Step 3: Implementation notes**
|
|
|
|
- Escape every interpolated value with `html.escape(value, quote=True)`. The transcript is user-facing text from Whisper; treat it as untrusted.
|
|
- Schema block is a JSON array containing `PodcastEpisode` and `BreadcrumbList`:
|
|
|
|
```python
|
|
{
|
|
"@context": "https://schema.org",
|
|
"@type": "PodcastEpisode",
|
|
"url": f"https://lukeattheroost.com/episode/{ep.slug}/",
|
|
"name": ep.title,
|
|
"description": ep.description,
|
|
"datePublished": ep.published_iso,
|
|
"timeRequired": f"PT{ep.duration_seconds}S",
|
|
"associatedMedia": {"@type": "MediaObject", "contentUrl": ep.audio_url},
|
|
"partOfSeries": {
|
|
"@type": "PodcastSeries",
|
|
"name": "Luke at the Roost",
|
|
"url": "https://lukeattheroost.com",
|
|
},
|
|
"contentLocation": {
|
|
"@type": "Place",
|
|
"name": "Big Bend, West Texas",
|
|
"address": {"@type": "PostalAddress", "addressLocality": "Alpine",
|
|
"addressRegion": "TX", "addressCountry": "US"},
|
|
},
|
|
}
|
|
```
|
|
|
|
The `contentLocation` on every episode is the point of the whole exercise — it is what ties 57 pages of West Texas conversation to the region geographically.
|
|
|
|
- Include a native `<audio controls preload="none" src="{audio_url}">` so the page is useful without JS.
|
|
- Add prev/next episode links. Internal linking is what gets 57 pages crawled instead of 3.
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git commit -m "Add episode page renderer with PodcastEpisode schema"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Generator CLI
|
|
|
|
**Files:**
|
|
- Create: `generate_episode_pages.py` (repo root, matching the existing script convention)
|
|
- Test: `tests/test_episode_generator.py`
|
|
|
|
**Behaviour:**
|
|
|
|
```
|
|
python generate_episode_pages.py # fetch live feed, write all pages
|
|
python generate_episode_pages.py --feed FILE # use a local feed (tests, offline)
|
|
python generate_episode_pages.py --dry-run # report what would be written
|
|
```
|
|
|
|
**Step 1: Failing tests**
|
|
|
|
```python
|
|
def test_writes_one_index_html_per_feed_episode(tmp_path, feed_file, transcripts_dir):
|
|
n = generate(feed_file, transcripts_dir, tmp_path)
|
|
assert n == 2
|
|
assert (tmp_path / "episode" / "episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho" / "index.html").exists()
|
|
|
|
|
|
def test_orphan_transcript_without_feed_item_is_skipped(tmp_path, feed_file, transcripts_dir):
|
|
"""episode-32 has a transcript but was never published to the feed."""
|
|
generate(feed_file, transcripts_dir, tmp_path)
|
|
assert not (tmp_path / "episode" / "episode-32-tacos-taxes-and-tall-tales").exists()
|
|
|
|
|
|
def test_missing_transcript_still_produces_a_page(tmp_path, feed_file, transcripts_dir):
|
|
"""An episode published before its transcript lands must not break the build."""
|
|
(transcripts_dir / "episode-58-....txt").unlink()
|
|
generate(feed_file, transcripts_dir, tmp_path)
|
|
html = (tmp_path / "episode" / "episode-58-..." / "index.html").read_text()
|
|
assert "Transcript not yet available" in html
|
|
|
|
|
|
def test_dry_run_writes_nothing(tmp_path, feed_file, transcripts_dir):
|
|
generate(feed_file, transcripts_dir, tmp_path, dry_run=True)
|
|
assert not (tmp_path / "episode").exists()
|
|
```
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git commit -m "Add episode page generator CLI"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Sitemap regeneration
|
|
|
|
**Files:**
|
|
- Modify: `generate_episode_pages.py` (add `--sitemap`)
|
|
- Modify: `website/sitemap.xml` (regenerated output)
|
|
- Test: `tests/test_sitemap.py`
|
|
|
|
The current sitemap has 64 `<url>` entries, 54 of them `?slug=` URLs. Those must be **replaced**, not supplemented — leaving both forms invites duplicate-content dilution even with a canonical.
|
|
|
|
**Tests:**
|
|
|
|
```python
|
|
def test_sitemap_contains_no_query_param_urls(generated_sitemap):
|
|
assert "episode.html?slug=" not in generated_sitemap
|
|
|
|
|
|
def test_sitemap_has_one_entry_per_episode(generated_sitemap):
|
|
assert generated_sitemap.count("<loc>https://lukeattheroost.com/episode/") == 57
|
|
|
|
|
|
def test_static_pages_survive_regeneration(generated_sitemap):
|
|
for path in ["", "/how-it-works", "/clips", "/stats", "/privacy", "/terms", "/llms.txt"]:
|
|
assert f"<loc>https://lukeattheroost.com{path}</loc>" in generated_sitemap
|
|
|
|
|
|
def test_lastmod_matches_episode_publish_date(generated_sitemap):
|
|
assert "<lastmod>2026-08-04</lastmod>" in generated_sitemap
|
|
```
|
|
|
|
**Commit:** `git commit -m "Generate sitemap from feed with clean episode URLs"`
|
|
|
|
---
|
|
|
|
## Task 6: Worker — 301 the old URL, remove the UA gate
|
|
|
|
**Files:**
|
|
- Modify: `website/_worker.js:107-165`
|
|
|
|
Replace the entire social-crawler injection block with a redirect. Once pages are static, the injection is dead code and the UA gate is a liability.
|
|
|
|
```javascript
|
|
// Legacy query-param episode URLs -> clean paths.
|
|
// Published social posts and YouTube descriptions still point at the old
|
|
// form, so this 301 has to stay indefinitely.
|
|
if (url.pathname === "/episode.html" && url.searchParams.get("slug")) {
|
|
const slug = url.searchParams.get("slug").replace(/[^a-z0-9-]/gi, "");
|
|
if (slug) {
|
|
return Response.redirect(`https://lukeattheroost.com/episode/${slug}/`, 301);
|
|
}
|
|
}
|
|
```
|
|
|
|
**Why the slug is sanitised:** it lands in a `Location:` header. Stripping to `[a-z0-9-]` prevents CRLF injection and open-redirect via a crafted `?slug=`.
|
|
|
|
**Verify manually after deploy:**
|
|
|
|
```bash
|
|
curl -sI "https://lukeattheroost.com/episode.html?slug=episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho" | head -3
|
|
# Expect: HTTP/2 301 + location: https://lukeattheroost.com/episode/episode-58-.../
|
|
```
|
|
|
|
**Commit:** `git commit -m "Redirect legacy episode URLs and drop crawler UA gate"`
|
|
|
|
---
|
|
|
|
## Task 7: Retire the client-rendered page
|
|
|
|
**Files:**
|
|
- Delete: `website/episode.html`, `website/js/episode.js`
|
|
- Modify: `website/js/app.js` — episode links must point at `/episode/<slug>/`
|
|
- Modify: `website/_redirects` — keep `/episodes.html /episode 302`
|
|
|
|
Check every internal link first:
|
|
|
|
```bash
|
|
grep -rn "episode.html?slug=" website/ --include=*.html --include=*.js
|
|
```
|
|
|
|
All must become `/episode/<slug>/`. Also fix `publish_episode.py:1168`, which builds the `episode_url` used in social posts and YouTube descriptions:
|
|
|
|
```python
|
|
episode_url = f"https://lukeattheroost.com/episode/{episode_slug}/"
|
|
```
|
|
|
|
**Commit:** `git commit -m "Point internal links at clean episode URLs"`
|
|
|
|
---
|
|
|
|
## Task 8: Wire into the publish pipeline
|
|
|
|
**Files:**
|
|
- Modify: `publish_episode.py` — after the transcript is written to `website/transcripts/`
|
|
|
|
```python
|
|
subprocess.run([sys.executable, "generate_episode_pages.py", "--sitemap"], check=False)
|
|
```
|
|
|
|
`check=False` on purpose: a generator failure must not abort a publish that has already pushed audio to Castopod. Log loudly instead.
|
|
|
|
**Test:** `tests/test_publish_generates_page.py` — assert the generator is invoked with `--sitemap` after a successful publish (monkeypatch `subprocess.run`).
|
|
|
|
**Commit:** `git commit -m "Regenerate episode pages during publish"`
|
|
|
|
---
|
|
|
|
## Task 9: Deploy and verify
|
|
|
|
```bash
|
|
./venv/bin/python -m pytest tests/ -q # full suite green
|
|
python generate_episode_pages.py --sitemap # 57 pages + sitemap
|
|
npx wrangler pages deploy website/ --project-name=lukeattheroost --branch=main
|
|
```
|
|
|
|
**Post-deploy checks — all must pass:**
|
|
|
|
```bash
|
|
# 1. Page is static: transcript present with JS disabled
|
|
curl -s https://lukeattheroost.com/episode/episode-58-.../ | grep -c "Mitchell Flat" # >= 1
|
|
|
|
# 2. Real title in raw HTML, no UA spoofing
|
|
curl -s https://lukeattheroost.com/episode/episode-58-.../ | grep -o "<title>.*</title>"
|
|
|
|
# 3. Same bytes for Googlebot as for a browser (no cloaking)
|
|
diff <(curl -s -A "Mozilla/5.0" URL) <(curl -s -A "Googlebot/2.1" URL) && echo "no cloaking"
|
|
|
|
# 4. Legacy URL 301s
|
|
curl -sI "https://lukeattheroost.com/episode.html?slug=episode-58-..." | grep -i "^location"
|
|
|
|
# 5. Schema validates
|
|
# paste page source into https://validator.schema.org/
|
|
```
|
|
|
|
Then: submit the new `sitemap.xml` in Google Search Console and request indexing on three episode pages as a sample.
|
|
|
|
---
|
|
|
|
## Risks
|
|
|
|
| Risk | Mitigation |
|
|
|---|---|
|
|
| URL change loses existing ranking | 301 (permanent) from Task 6 passes equity; the old form stays supported forever |
|
|
| Published YouTube/social links break | Same 301. Ep 58's YouTube description contains a `?slug=` link — verify it after deploy |
|
|
| Transcripts contain explicit content | Show is already rated explicit; consider `<meta name="rating" content="adult">` on episode pages |
|
|
| Whisper errors become permanent indexed text | Transcripts are already public at `/transcripts/*.txt`; this changes exposure, not accuracy |
|
|
| 3 MB of new HTML on every deploy | Static assets, Cloudflare-cached; wrangler uploads only changed files |
|
|
| Feed unreachable at build time | `--feed` flag lets the build use a cached copy; generator exits non-zero rather than writing empty pages |
|
|
|
|
## Out of scope
|
|
|
|
Tier 3 — the "Big Bend episodes" topic hub, and off-site work (Alpine Avalanche, Marfa/Big Bend groups, Sul Ross). Worth doing after these pages exist to link to.
|