- Add target="_top" to all ← SPACE / back-to-root links so navigating back to index.html breaks out of the content iframe instead of loading it recursively inside itself (Images, Music, Watchlists hubs) - Remove all localStorage usage: visited-star tracking in index.html (resets each visit, no persistence without storage), and lightbox "show once" guards in all section pages (lightboxes now show on every load — Videos, Watchlists, Music). build.py templates updated so future generated pages stay clean. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
540 lines
17 KiB
Python
540 lines
17 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Watchlists build script.
|
||
|
||
For each *-watchlist.md in this folder:
|
||
- Parse title, optional first-load lightbox, and playlist entries
|
||
- Generate a self-contained HTML page
|
||
|
||
Then update watchlists-hub.html subtitle with current totals.
|
||
|
||
Output filenames: strip '-watchlist' suffix, lowercase the stem, no hyphens/spaces.
|
||
ContentAddictionArchive-watchlist.md → contentaddictionarchive.html
|
||
AnalogFrontier-watchlist.md → analogfrontier.html
|
||
etc.
|
||
|
||
Run after adding or editing any *-watchlist.md file.
|
||
"""
|
||
|
||
import re
|
||
from pathlib import Path
|
||
from html import escape
|
||
|
||
HERE = Path(__file__).parent
|
||
HUB = HERE / "watchlists-hub.html"
|
||
|
||
COLORS = ["teal", "green", "toucan"] # rotation order
|
||
|
||
ALLOW = (
|
||
"accelerometer; autoplay; clipboard-write; "
|
||
"encrypted-media; gyroscope; picture-in-picture; web-share"
|
||
)
|
||
|
||
# ── MD parsing ────────────────────────────────────────────────────────────────
|
||
|
||
def slug_from_path(path: Path) -> str:
|
||
stem = path.stem # e.g. "AnalogFrontier-watchlist"
|
||
stem = re.sub(r'-watchlist$', '', stem, flags=re.IGNORECASE)
|
||
return stem.lower().replace('-', '').replace('_', '').replace(' ', '')
|
||
|
||
|
||
def parse_md(path: Path) -> dict:
|
||
text = path.read_text(encoding='utf-8')
|
||
|
||
# Title: first # heading
|
||
m = re.search(r'^#\s+(.+)$', text, re.MULTILINE)
|
||
title = m.group(1).strip() if m else path.stem
|
||
|
||
# Optional lightbox: >>on-first-load - lightbox - "text"
|
||
m = re.search(r'>>on-first-load\s+-\s+lightbox\s+-\s+"([^"]+)"', text)
|
||
lightbox = m.group(1).strip() if m else None
|
||
|
||
# Playlist entries
|
||
# - **Name \- YouTube**
|
||
# - URL: https://www.youtube.com/playlist?list=PLxxxxxx
|
||
playlists = []
|
||
for m in re.finditer(
|
||
r'-\s+\*\*(.+?)\s*\\-\s*YouTube\*\*\s*\n\s+-\s+URL:\s+(https://[^\s]+)',
|
||
text
|
||
):
|
||
name = m.group(1).strip()
|
||
url = m.group(2).strip()
|
||
lid = re.search(r'[?&]list=([A-Za-z0-9_-]+)', url)
|
||
if lid:
|
||
playlists.append({'name': name, 'list_id': lid.group(1)})
|
||
|
||
return {'title': title, 'lightbox': lightbox, 'playlists': playlists}
|
||
|
||
|
||
# ── HTML generation ───────────────────────────────────────────────────────────
|
||
|
||
def build_lightbox_html(text: str, slug: str) -> tuple[str, str]:
|
||
"""Return (lightbox_div_html, lightbox_js_html). Empty strings if no lightbox."""
|
||
if not text:
|
||
return '', ''
|
||
|
||
safe = escape(text)
|
||
div = f"""
|
||
<div id="lightbox" role="dialog" aria-modal="true" aria-label="Introduction">
|
||
<div class="caption-box">
|
||
<button class="caption-close" id="caption-close" aria-label="Close">×</button>
|
||
<p class="caption-text">{safe}</p>
|
||
<p class="dismiss-hint">CLICK ANYWHERE TO DISMISS</p>
|
||
</div>
|
||
</div>"""
|
||
|
||
js = f"""
|
||
const lightbox = document.getElementById('lightbox');
|
||
lightbox.style.display = 'flex';
|
||
|
||
function closeLightbox() {{ lightbox.style.display = 'none'; }}
|
||
|
||
lightbox.addEventListener('click', (e) => {{
|
||
if (e.target === lightbox) closeLightbox();
|
||
}});
|
||
document.getElementById('caption-close').addEventListener('click', closeLightbox);
|
||
document.addEventListener('keydown', (e) => {{
|
||
if (e.key === 'Escape') closeLightbox();
|
||
}});
|
||
"""
|
||
return div, js
|
||
|
||
|
||
def build_panels_html(playlists: list) -> str:
|
||
panels = []
|
||
for i, pl in enumerate(playlists):
|
||
color = COLORS[i % len(COLORS)]
|
||
num = f"PANEL {i+1:02d}"
|
||
embed_url = f"https://www.youtube.com/embed/videoseries?list={pl['list_id']}"
|
||
title_esc = escape(pl['name'].upper())
|
||
|
||
playlist_url = f"https://www.youtube.com/playlist?list={pl['list_id']}"
|
||
|
||
panels.append(f"""
|
||
<section class="playlist-panel panel-{color}">
|
||
<div class="panel-header">
|
||
<h2>{title_esc}</h2>
|
||
<span class="panel-number">{num}</span>
|
||
</div>
|
||
<div class="video-container">
|
||
<iframe
|
||
data-src="{embed_url}"
|
||
allow="{ALLOW}"
|
||
allowfullscreen>
|
||
</iframe>
|
||
</div>
|
||
<a href="{playlist_url}" target="_blank" rel="noopener" class="playlist-link">
|
||
→ OPEN FULL PLAYLIST ON YOUTUBE
|
||
</a>
|
||
</section>""")
|
||
|
||
return '\n'.join(panels)
|
||
|
||
|
||
def build_stagger_css(count: int) -> str:
|
||
lines = []
|
||
for i in range(1, count + 1):
|
||
delay = (i - 1) * 60
|
||
lines.append(
|
||
f" .playlist-panel:nth-child({i}) {{ animation-delay: {delay}ms; }}"
|
||
)
|
||
return '\n'.join(lines)
|
||
|
||
|
||
def generate_html(data: dict, slug: str) -> str:
|
||
title = data['title']
|
||
playlists = data['playlists']
|
||
count = len(playlists)
|
||
title_upper = title.upper()
|
||
count_label = f"{count} PLAYLIST{'S' if count != 1 else ''}"
|
||
|
||
lightbox_div, lightbox_js = build_lightbox_html(data['lightbox'], slug)
|
||
panels_html = build_panels_html(playlists)
|
||
stagger_css = build_stagger_css(count)
|
||
|
||
# Lightbox CSS block (only if there's a lightbox)
|
||
lightbox_css = """
|
||
/* ── Lightbox ── */
|
||
#lightbox {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(4, 6, 11, 0.92);
|
||
display: none;
|
||
justify-content: center;
|
||
align-items: center;
|
||
z-index: 2000;
|
||
padding: 2rem;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.caption-box {
|
||
background: #080d10;
|
||
border: 2px solid var(--wl-teal);
|
||
border-top: 10px solid var(--wl-toucan);
|
||
border-left: 6px solid var(--wl-green);
|
||
max-width: 640px;
|
||
width: 100%;
|
||
max-height: 90vh;
|
||
overflow-y: auto;
|
||
padding: 2rem 2rem 1.5rem;
|
||
position: relative;
|
||
cursor: default;
|
||
}
|
||
|
||
.caption-close {
|
||
position: absolute;
|
||
top: 0.6rem;
|
||
right: 0.75rem;
|
||
background: none;
|
||
border: none;
|
||
color: var(--wl-teal);
|
||
font-family: 'Rambla', sans-serif;
|
||
font-size: 1.6rem;
|
||
font-weight: 700;
|
||
line-height: 1;
|
||
cursor: pointer;
|
||
padding: 0.1rem 0.3rem;
|
||
transition: color 100ms ease;
|
||
}
|
||
|
||
.caption-close:hover { color: var(--wl-toucan); }
|
||
|
||
.caption-text {
|
||
font-style: italic;
|
||
font-size: 1.05rem;
|
||
color: var(--text-warm);
|
||
margin-bottom: 1.25rem;
|
||
line-height: 1.65;
|
||
padding-right: 1.5rem;
|
||
}
|
||
|
||
.dismiss-hint {
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
font-size: 0.62rem;
|
||
letter-spacing: 0.18em;
|
||
color: var(--muted);
|
||
text-align: center;
|
||
margin-top: 1.25rem;
|
||
}
|
||
""" if data['lightbox'] else ''
|
||
|
||
return f"""<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>{escape(title_upper)} | WATCHLISTS</title>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
<link href="https://fonts.googleapis.com/css2?family=Rambla:ital,wght@0,400;0,700;1,400;1,700&display=swap" rel="stylesheet">
|
||
<style>
|
||
:root {{
|
||
--bg-void: #04060b;
|
||
--text-warm: #e8d5b8;
|
||
--wl-teal: #2ac4b3;
|
||
--wl-green: #32dc8c;
|
||
--wl-toucan: #ffcf40;
|
||
--wl-teal-glow: rgba(42, 196, 179, 0.22);
|
||
--wl-green-glow: rgba(50, 220, 140, 0.22);
|
||
--wl-toucan-glow: rgba(255, 207, 64, 0.22);
|
||
--muted: rgba(232, 213, 184, 0.45);
|
||
}}
|
||
|
||
* {{ box-sizing: border-box; margin: 0; padding: 0; border-radius: 0; }}
|
||
|
||
body {{
|
||
background-color: var(--bg-void);
|
||
color: var(--text-warm);
|
||
font-family: 'Rambla', sans-serif;
|
||
line-height: 1.5;
|
||
padding-top: 88px;
|
||
}}
|
||
|
||
/* ── Sticky header ── */
|
||
.sticky-header {{
|
||
position: fixed;
|
||
top: 0; left: 0; right: 0;
|
||
height: 72px;
|
||
background: var(--bg-void);
|
||
border-bottom: 2px solid var(--wl-teal);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 0 2rem;
|
||
z-index: 1000;
|
||
}}
|
||
|
||
.header-title {{
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
color: var(--wl-toucan);
|
||
font-size: 1.1rem;
|
||
letter-spacing: -0.01em;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
max-width: 55%;
|
||
}}
|
||
|
||
.header-nav {{
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 2rem;
|
||
}}
|
||
|
||
.nav-link {{
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
text-decoration: none;
|
||
color: var(--wl-teal);
|
||
font-size: 0.85rem;
|
||
letter-spacing: 0.04em;
|
||
transition: color 100ms ease;
|
||
}}
|
||
|
||
.nav-link:hover {{ color: var(--wl-green); }}
|
||
|
||
.header-count {{
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
color: var(--wl-green);
|
||
font-size: 0.78rem;
|
||
letter-spacing: 0.1em;
|
||
}}
|
||
|
||
.menu-toggle {{
|
||
display: none;
|
||
background: none;
|
||
border: 2px solid var(--wl-teal);
|
||
color: var(--wl-teal);
|
||
padding: 0.4rem 0.6rem;
|
||
font-size: 1.1rem;
|
||
cursor: pointer;
|
||
font-family: inherit;
|
||
transition: color 100ms ease, border-color 100ms ease;
|
||
}}
|
||
|
||
.menu-toggle:hover {{ color: var(--wl-green); border-color: var(--wl-green); }}
|
||
|
||
@media (max-width: 600px) {{
|
||
.header-nav {{
|
||
display: none;
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
position: absolute;
|
||
top: 72px; left: 0; right: 0;
|
||
background: var(--bg-void);
|
||
padding: 1.25rem 2rem;
|
||
border-bottom: 2px solid var(--wl-teal);
|
||
gap: 0.75rem;
|
||
}}
|
||
.header-nav.open {{ display: flex; }}
|
||
.menu-toggle {{ display: block; }}
|
||
.header-title {{ max-width: 65%; }}
|
||
}}
|
||
{lightbox_css}
|
||
/* ── Panels ── */
|
||
.container {{
|
||
max-width: 900px;
|
||
margin: 0 auto;
|
||
padding: 2rem 2rem 4rem;
|
||
}}
|
||
|
||
@media (max-width: 600px) {{
|
||
.container {{ padding: 1.5rem 1.25rem 3rem; }}
|
||
}}
|
||
|
||
@keyframes panelIn {{
|
||
from {{ opacity: 0; transform: translateY(20px); }}
|
||
to {{ opacity: 1; transform: translateY(0); }}
|
||
}}
|
||
|
||
.playlist-panel {{
|
||
margin-bottom: 3.5rem;
|
||
border: 2px solid var(--border-color);
|
||
background: var(--bg-void);
|
||
position: relative;
|
||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.6);
|
||
animation: panelIn 350ms ease both;
|
||
}}
|
||
|
||
{stagger_css}
|
||
|
||
.panel-header {{
|
||
background: var(--border-color);
|
||
padding: 0.75rem 1.25rem;
|
||
color: var(--bg-void);
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}}
|
||
|
||
.panel-header h2 {{
|
||
font-size: 1.15rem;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
margin: 0;
|
||
line-height: 1.2;
|
||
}}
|
||
|
||
.panel-number {{
|
||
font-size: 0.75rem;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
color: rgba(4, 6, 11, 0.75);
|
||
letter-spacing: 0.06em;
|
||
white-space: nowrap;
|
||
margin-left: 1rem;
|
||
flex-shrink: 0;
|
||
}}
|
||
|
||
.video-container {{
|
||
position: relative;
|
||
padding-bottom: 56.25%;
|
||
height: 0;
|
||
overflow: hidden;
|
||
}}
|
||
|
||
.video-container iframe {{
|
||
position: absolute;
|
||
top: 0; left: 0;
|
||
width: 100%; height: 100%;
|
||
border: 0;
|
||
}}
|
||
|
||
.panel-teal {{ --border-color: var(--wl-teal); }}
|
||
.panel-green {{ --border-color: var(--wl-green); }}
|
||
.panel-toucan {{ --border-color: var(--wl-toucan); }}
|
||
|
||
.playlist-link {{
|
||
display: block;
|
||
padding: 0.85rem 1.25rem;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
font-size: 0.9rem;
|
||
letter-spacing: 0.08em;
|
||
color: var(--border-color);
|
||
text-decoration: none;
|
||
border-top: 1px solid var(--border-color);
|
||
transition: background 100ms ease, color 100ms ease;
|
||
}}
|
||
|
||
.playlist-link:hover {{
|
||
background: var(--border-color);
|
||
color: var(--bg-void);
|
||
}}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<header class="sticky-header">
|
||
<div class="header-title">{escape(title_upper)}</div>
|
||
<button class="menu-toggle" id="menu-toggle">☰</button>
|
||
<nav class="header-nav" id="header-nav">
|
||
<a href="watchlists-hub.html" class="nav-link">← WATCHLISTS</a>
|
||
<span class="header-count">{count_label}</span>
|
||
</nav>
|
||
</header>
|
||
{lightbox_div}
|
||
<main class="container">
|
||
{panels_html}
|
||
</main>
|
||
|
||
<script>
|
||
document.addEventListener('DOMContentLoaded', () => {{
|
||
{lightbox_js}
|
||
// Mobile nav toggle
|
||
const menuToggle = document.getElementById('menu-toggle');
|
||
const headerNav = document.getElementById('header-nav');
|
||
|
||
menuToggle.addEventListener('click', (e) => {{
|
||
e.stopPropagation();
|
||
headerNav.classList.toggle('open');
|
||
}});
|
||
|
||
document.addEventListener('click', (e) => {{
|
||
if (!headerNav.contains(e.target) && e.target !== menuToggle) {{
|
||
headerNav.classList.remove('open');
|
||
}}
|
||
}});
|
||
|
||
// Lazy-load iframes via IntersectionObserver
|
||
const observer = new IntersectionObserver((entries, obs) => {{
|
||
entries.forEach(entry => {{
|
||
if (entry.isIntersecting) {{
|
||
const iframe = entry.target;
|
||
const src = iframe.getAttribute('data-src');
|
||
if (src) {{
|
||
iframe.setAttribute('src', src);
|
||
iframe.removeAttribute('data-src');
|
||
}}
|
||
obs.unobserve(iframe);
|
||
}}
|
||
}});
|
||
}}, {{ rootMargin: '200px 0px' }});
|
||
|
||
document.querySelectorAll('iframe[data-src]').forEach(
|
||
iframe => observer.observe(iframe)
|
||
);
|
||
}});
|
||
</script>
|
||
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
|
||
# ── Hub subtitle update ───────────────────────────────────────────────────────
|
||
|
||
def update_hub_subtitle(collection_count: int, playlist_total: int):
|
||
hub_text = HUB.read_text(encoding='utf-8')
|
||
subtitle = (
|
||
f'<div class="subtitle">'
|
||
f'{collection_count} COLLECTION{"S" if collection_count != 1 else ""}'
|
||
f' · CURATED VIDEO ARCHIVES'
|
||
f' · {playlist_total} PLAYLIST{"S" if playlist_total != 1 else ""}'
|
||
f'</div>'
|
||
)
|
||
hub_text = re.sub(
|
||
r'<!-- SUBTITLE-START -->.*?<!-- SUBTITLE-END -->',
|
||
f'<!-- SUBTITLE-START -->{subtitle}<!-- SUBTITLE-END -->',
|
||
hub_text,
|
||
flags=re.DOTALL
|
||
)
|
||
HUB.write_text(hub_text, encoding='utf-8')
|
||
|
||
|
||
# ── Main ──────────────────────────────────────────────────────────────────────
|
||
|
||
def main():
|
||
md_files = sorted(HERE.glob('*-watchlist.md'))
|
||
if not md_files:
|
||
print("No *-watchlist.md files found.")
|
||
return
|
||
|
||
total_playlists = 0
|
||
|
||
for md_path in md_files:
|
||
slug = slug_from_path(md_path)
|
||
out = HERE / f"{slug}.html"
|
||
|
||
data = parse_md(md_path)
|
||
count = len(data['playlists'])
|
||
|
||
if count == 0:
|
||
print(f" SKIP {md_path.name} — no playlists found")
|
||
continue
|
||
|
||
html = generate_html(data, slug)
|
||
out.write_text(html, encoding='utf-8')
|
||
total_playlists += count
|
||
print(f" BUILD {out.name} — {data['title']} ({count} playlists)")
|
||
|
||
update_hub_subtitle(len(md_files), total_playlists)
|
||
print(f"\nUpdated hub subtitle — {len(md_files)} collections, {total_playlists} playlists total")
|
||
|
||
|
||
if __name__ == '__main__':
|
||
main()
|