Files
singular-particular-space/Watchlists/build.py
JL Kruger dd51655792 Add Watchlists section — hub, 5 collection pages, build pipeline
- watchlists-hub.html: comic book panel grid, neon-teal/green/toucan palette,
  Rambla-only type hierarchy, CSS stagger entrance, wired to star map
- 5 collection pages built from *-watchlist.md sources via build.py:
  contentaddictionarchive, analogfrontier, culturaldecay,
  soundscapeanomalies, lastcinema
- build.py: parses MD files, generates self-contained HTML pages,
  updates hub subtitle with live counts
- index.html: Watchlists star node wired to Watchlists/watchlists-hub.html

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 14:22:02 +02:00

521 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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"""
// Lightbox: show on first load
const lightbox = document.getElementById('lightbox');
if (!localStorage.getItem('watchlist-{slug}-seen')) {{
lightbox.style.display = 'flex';
localStorage.setItem('watchlist-{slug}-seen', '1');
}}
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())
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>
</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); }}
</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()