Talks Site Architecture — Hugo, Two-Build Visibility, Embedded Viz¶
Trevor Bedford — 2026-06-01
Decision¶
Build a new talks static site on Hugo, deployed via two Cloudflare Pages projects from one repo — a public site (talks.pegasus-research.com, open to the internet) and a private site (talks-private.pegasus-research.com, behind Cloudflare Access) — distinguished by a visibility front-matter field on each talk and switched by Hugo's --environment flag. Slides stay in Reveal.js; interactive visualizations are embedded as iframes pointing at standalone, date-versioned viz pages drawn from a shared static/ asset pool (the same model the existing /images/talks/ PNGs already use). The existing blab/blotter (Bedford Lab talks) stays on Jekyll, untouched.
This document lives in pegasus-docs for now and moves to the talks repo when that repo exists.
Context¶
The transition to Pegasus creates a need for a talks-hosting setup that the existing Bedford Lab arrangement doesn't cover. Talks now come in distinct visibility classes — public Pegasus talks (where surfacing slides openly is an asset, as it has been for Bedford Lab) and private Pegasus talks (drafts, internal, or commercially sensitive material that should sit behind authentication or be delivered as PDF). At the same time, the arrival of Claude Code makes in-depth inline D3 (and other JS) visualization in slides practical for the first time, where previously talks were almost entirely static PNGs with the occasional iframe embed.
Several principles emerged from working through the design and should be treated as the load-bearing commitments the architecture serves:
- A talk is an artifact, not a living system. A talk given on a date is a snapshot of thinking at that date. The 2024 version of a figure should keep looking exactly as it did in 2024. This is why visualizations are frozen and date-versioned rather than live-linked to evolving dashboards.
- Public deployment, not public source, is the actual value. For Bedford Lab, the fact that talk source is on GitHub is incidental; the principle is that if a talk is given to a room of 100 people at a public conference, the slides may as well be online for the world. For Pegasus, only the deployed public talks need to be world-readable; source stays private.
- Self-containment and reproducibility win over cleverness. No build step for viz, no toolchain dependencies inside decks, frozen assets, static output. A deck should render identically years later on whatever browser exists then.
- Don't migrate working systems for theoretical consistency.
blotteris greyfield and winding down; it stays Jekyll.talksis greenfield with requirements (two-build visibility, directory-as-unit content, modern ESM) that land on Jekyll's weak spots and Hugo's strengths, so it starts on Hugo. The asymmetry is justified by greyfield-vs-greenfield, not by any difference in values between the two institutions.
The relationship to the other parts of the content ecosystem is unchanged: pegasus-docs remains the home for durable reference material; this is a separate concern (presentation artifacts), which is why it gets its own eventual repo.
Why Hugo (and not Jekyll, MkDocs/Zensical, or a React stack)¶
Jekyll is right for blotter and wrong for talks. The new repo's requirements map onto Jekyll's weak spots: (1) two filtered builds from one source by a visibility field needs custom Liquid; (2) treating a talk-with-its-assets as a governed unit is not native — Jekyll's default is to scatter assets into a global folder, and co-locating them needs a contested third-party plugin (jekyll-postfiles); (3) its asset story predates ES modules. Note that "Jekyll page bundles" do not exist — that is a Hugo concept; the term was a misnomer.
Hugo fits natively: environment-based build profiles (config/_default/, config/public/) are designed for exactly the "same site, different build" case; it filters and lists content by front-matter field via templates; it emits pretty dated URLs directly from YYYY-MM-DD-slug filenames (extracting both the date metadata and the slug automatically); and it is a single Go binary with no Ruby/Bundler toolchain to reproduce on Cloudflare's builder.
MkDocs/Zensical is the wrong shape — it is a hierarchical-reference-docs tool (explicit nav tree, themed chrome on every page), not a chronological-archive-of-fullscreen-decks tool, and it is itself mid-migration (mkdocs-material in maintenance mode), so adopting it for a second use case just buys another future migration. A React meta-framework (Next, Astro) reintroduces the npm/hydration/build-step world being deliberately avoided for a fundamentally content-static site; Astro is the only one worth a glance for its island model, but it beats Hugo on no axis that matters here.
Reveal.js and D3 are framework-agnostic static JS/HTML and port unchanged regardless of the site generator — the generator choice is purely about the scaffolding layer (index, filtering, bundling, deploy), not about anything inside a deck.
Why iframe-standalone viz (and not inline mount-modules) for v1¶
Earlier design iterations considered factoring dashboard visualizations into ES modules with a mount(container, data, opts) API, lazy-mounted on Reveal slide entry. For v1 this is explicitly not the chosen pattern. A standalone versioned viz page strictly dominates a bare module as an artifact: it is independently openable at its own URL, linkable from a blog post or paper, and embeddable via iframe — whereas a module's only use is the slide. The iframe approach also bundles each viz's dependencies (D3, etc.) inside its own frozen page, closing a reproducibility seam that the shared-import-map module approach left open (where bumping a shared D3 would silently re-version every old viz). And it requires no refactor of the source dashboards (e.g. blab/trellis) — Claude Code extracts a self-contained page per talk, leaving the dashboard as a living system untouched.
The one genuine cost of iframes — the separate browsing context complicates choreographing a viz with slide state (fragment-driven reveals, transitions on slide-advance, theme inheritance) — is reserved for a later milestone. The inline mount-module pattern is deferred to if/when a specific slide needs that choreography; v1 does not build scaffolding for it.
Consequences¶
- A working private talks site exists in the interim (the near-term need), with the public site and its filtering apparatus designed-in but not built until Pegasus has a public web presence (months out).
- Visibility is a property of the talk, expressed once as a
visibilityfront-matter field. Assets carry no visibility tag of their own; an asset is published iff a published talk references it. This avoids a second visibility field to keep consistent and avoids any in-place "flip an asset from private to public" operation. - Heavy PNG reuse across talks is preserved (measured at ~64% shared assets between two consecutive recent talks), because assets live in a shared flat pool rather than being copied per-talk.
- Viz becomes a strictly better artifact than a PNG (interactive, openable at its own URL) while keeping the PNG's self-contained-and-frozen property.
- For PDF-delivered talks (private decktape exports, printed decks), an embedded viz renders only as a dead initial frame — so for those talks a PNG is the deliverable, not a fallback. Embedded viz earns its keep on live-served talks; the talk declares which mode it is in (see open items).
- Hugo's Go templates are more austere than Liquid and carry a one-time learning curve for the index/list/single scaffolding — substantially absorbed by Claude Code.
- A current-Hugo regression (cascade-to-self removed in v0.153.0) steers the public-build filtering toward template-level filtering rather than per-page build options (see Milestone 3).
Repo layout¶
talks/ (in pegasus-docs/notes/ for now; own repo later)
hugo.toml base config
config/
_default/ base config layer (private build = include all)
public/ public env layer: baseURL, visibility filtering
content/
talks/
2026-06-01-some-talk.md date-in-filename → date + slug for free
...
_index.md talks listing page
layouts/
talks/
single.html wraps deck content in Reveal.js scaffolding
list.html date-sorted index, filtered by visibility in public env
_default/
baseof.html
static/
images/talks/ shared PNG pool (mirrors blotter /images/talks/)
fitness_dynamics_..._2026_03_02.png
viz/ shared standalone-viz pool
trellis-fitness-plot-2026-06-01/
index.html self-contained: D3 vendored in, data inlined or sibling
...
reveal/ vendored Reveal.js (shared across all talks)
assets/ (if any SCSS/JS needs Hugo pipeline processing)
Key points:
- Shared asset pools are plain
static/, not page bundles. Page bundles are for talk-owned resources; a shared pool that many talks draw from is a top-level static directory. Most assets (PNGs, reused viz) are shared. A genuinely one-off, talk-specific asset could live in that talk's bundle, but this is the exception. - Viz pages are standalone and self-contained. Each
static/viz/{project}-{panel}-YYYY-MM-DD/is a frozen, independently-servable page with its own dependencies. A talk embeds it with an absolute same-origin path:<iframe src="/viz/trellis-fitness-plot-2026-06-01/">. Absolute paths resolve correctly on whichever host serves them (public or private domain) with no per-build rewriting. - Date-versioned viz mirrors the existing dated-PNG convention.
trellis-fitness-plot-2026-06-01is frozen; a later talk needing a newer version creates a new dated directory; multiple talks can share one dated viz. Same rhythm as..._2026_03_02.png.
The locked decisions¶
| Decision | Choice | Rationale |
|---|---|---|
| Site generator | Hugo | Native env build profiles, front-matter filtering, dated URLs from filenames, single-binary deploy |
| Two builds | Two Cloudflare Pages projects, one repo | Standard pattern; difference is which talks are included, not different code |
| Build switch | Env var → hugo --environment {public,private} |
Hugo's first-class environment support; config difference is data, not command archaeology |
| Visibility model | visibility: public|private front-matter field on each talk |
Single field; mechanism-independent; assets inherit via reachability |
| Asset visibility | None — reachability-based | Asset published iff a published talk references it; no second field, no in-place flips |
| Asset storage | Shared flat static/ pools (PNG + viz) |
Preserves heavy cross-talk reuse; mirrors existing /images/talks/ model |
| Viz embedding (v1) | iframe → standalone date-versioned viz page | Better artifact (openable/linkable/embeddable); self-freezing deps; no dashboard refactor |
| URL scheme | /talks/YYYY-MM-DD-slug/, date in filename |
Mirrors blotter; Hugo derives date + slug from filename natively |
| Slides | Reveal.js, vendored | Framework-agnostic; ports unchanged; the right tool for programmatic decks |
| blotter | Untouched, stays Jekyll | Greyfield, working, winding down |
MVP scope (the buildable slice)¶
The goal of the MVP is to feel the Hugo authoring experience and prove the embedding pattern end-to-end, without committing to the full two-site Cloudflare/Access apparatus. Since the public site won't exist until Pegasus has a public web presence, the MVP targets the private path only, where "include everything" is correct and trivial.
MVP delivers:
- A Hugo repo with config structured to anticipate the public/private split — the
visibilityfront-matter field defined and used, andconfig/_default/+config/public/environment layers stubbed — but only the default (all-inclusive) build wired. - The shared
static/asset pools (images/talks/,viz/) and vendored Reveal.js. - One real private talk rendering locally (
hugo server), in Reveal.js, in the/talks/YYYY-MM-DD-slug/URL scheme. - That talk embedding one iframe-standalone viz extracted from a source dashboard (the
trellisfitness-plot panel is the natural first candidate — simplest single timeseries) intostatic/viz/trellis-fitness-plot-YYYY-MM-DD/, with a provenance comment noting its source (// Adapted from blab/trellis@<sha> on YYYY-MM-DD). - A date-sorted talks index (
list.html) — unfiltered for now.
Explicitly deferred out of MVP: the second Cloudflare Pages project, the public domain, Cloudflare Access wiring, public-build visibility filtering, and reachability-based asset closure.
Downstream milestones¶
Milestone 2 — Private site deployed¶
Wire the private Cloudflare Pages project: connect the repo, build with the default (all-inclusive) environment, map to talks-private.pegasus-research.com, protect with Cloudflare Access (GitHub-org-gated, same pattern as docs.pegasus-research.com). Because everything behind Access is equally protected, this build includes all talks and all assets — no filtering needed yet. This is the milestone that makes the setup actually usable for giving private Pegasus talks.
Verify before relying on it: Cloudflare Access response headers do not block same-origin iframing on the private domain (deck and viz are same-origin under one Access session, so this should hold, but confirm Access doesn't inject a framing-hostile header by default).
Milestone 3 — Public launch + visibility filtering¶
Triggered when Pegasus has a public web presence (pegasus-research.com, branding). Wire the second (public) Cloudflare Pages project at talks.pegasus-research.com, building with --environment public.
Filtering approach (decided, steering around a known regression): use template-level filtering on the visibility param — list templates and any range over talks apply where .Site.RegularPages "Params.visibility" "public" when building the public environment. Do not use the cascade + _build self-targeting approach: as of Hugo v0.153.0, "cascade to self" no longer applies to regular pages, which is a fail-open regression on exactly the hide-this-page operation a privacy boundary depends on.
Template-level filtering handles listings but does not by itself prevent a private talk's rendered page from existing in the public output. It needs a companion render-exclusion mechanism. Open verification item: evaluate Hugo's segments / --renderSegments feature as the current best mechanism for rendering only the public subset — it postdates the cascade approach and may be the clean replacement. Resolve this at this milestone, not before.
Reachability-based asset closure: the public build must not copy the entire static/ pool wholesale (that would leak an asset referenced only by an unreleased private talk). It must emit the transitive closure of assets referenced by public talks. This is the same scan needed for the build-time assertion below — one scan, two purposes.
Build-time safety assertion: parse public-built decks for /viz/ and /images/ references and assert each resolves to something in the public output; fail the build loudly otherwise. The failure mode is already fail-safe in the right direction (forgetting to publish an asset breaks a public deck's image — embarrassing, not a breach; leaking private data requires explicitly mis-tagging a talk), but the assertion is cheap insurance.
Visibility default and promotion: talks default to visibility: private. Promoting a talk to public is a one-line front-matter flip — but if it reuses a viz originally created for a private talk, create a new dated public viz snapshot rather than flipping the existing one in place (a still-active private deck may rely on that viz staying behind Access). Per-snapshot data duplication is already accepted as cheap, so this costs nothing new and keeps the reachability invariant clean.
Milestone 4 — Move to dedicated talks repo¶
When the talks repo is created, move this plan and the Hugo project out of pegasus-docs. Mark the pegasus-docs copy as superseded with a link, per the content-taxonomy convention.
Milestone 5 (conditional) — Inline mount-module viz¶
Only if a specific slide needs a viz choreographed with slide state (fragment-driven reveals, transitions on advance, theme inheritance) that the iframe boundary can't cleanly provide. At that point, introduce the mount(container, data, opts) ES-module pattern lazy-mounted on Reveal slidechanged for that slide only, alongside (not replacing) the iframe-standalone default.
Open verification items¶
These are confirm-the-incantation lookups, not open design questions. None block the MVP.
- Cloudflare Access + same-origin framing (needed at Milestone 2): confirm Access does not inject framing-hostile response headers on the private domain.
- Two Cloudflare Pages projects, one repo (Milestone 2/3): confirm per-project build-command / environment-variable override and custom-domain mapping work as expected for the public/private split.
- Hugo render-exclusion for the public subset (Milestone 3): evaluate
segments/--renderSegmentsvs. per-page_build.render(latter has the v0.153.0 cascade-to-self caveat) as the mechanism that complements template-levelwherefiltering to keep private pages out of public output entirely.
Notes on conventions¶
- Provenance comments on extracted viz. When Claude Code extracts a viz from a source dashboard, leave a top-of-file comment:
// Adapted from blab/trellis@<sha>:viz/... on YYYY-MM-DD. Pure provenance metadata — creates no dependency, but lets future work trace lineage. - Live-vs-frozen data discipline. Develop a viz against live build artifacts if useful, but commit a frozen data snapshot for delivery so the deck is hermetic on stage and in the historical record.
- No JS build step. Plain ES modules / vendored libraries / inlined data. Add bundling only if first-paint speed or offline constraints ever demand it.