The brief was twelve words: βcreate and deploy a visually consistent static GitHub Pages site for promo.β A day later the site serves 2,808 pages with zero broken internal links at https://prismatic-reality-com.github.io/prismatic-promo/. The path between those two sentences was paved with edge cases. This is the postmortem.
#Starting Conditions
The promo site already existed as a Zola source under sites/promo/ in a private Elixir umbrella repo β same Tailwind + Flowbite + Alpine + Chart.js + p5 + three.js stack that the main Phoenix LiveView app uses. The content symlinked to apps/prismatic_web/priv/content/, a 2,800-page corpus covering glossary, academy, blog, agents, commands, applications, and so on. So the theme, the content, and the build tool were all in place.
What was not in place: the private repo cannot host GitHub Pages on the free plan. The custom-domain variant (prismatic-reality.com) had broken DNS, pointing at Fly.io infra from a prior deployment rather than GH Pages IP addresses. So the first real decision was which public repo to deploy to. There was one: prismatic-reality-com/prismatic-promo, org-owned, Pages enabled, historical content serving from a gh-pages branch.
#The Seven Classes of Breakage
As the site came up, a full recursive crawl (every sitemap.xml entry, every <a href> harvested from every rendered page, HEAD-checked) returned 2,938 distinct broken internal links out of 5,928 unique hrefs. Nearly half. A second crawl after the first fix pass returned 274. The final crawl: zero.
What made up those 2,938? Seven categories, roughly in descending order of impact.
1. The x-data="commandRegistry()" Minifier Trap (~2,500)
Zolaβs HTML minifier (minify_html crate) aggressively strips attribute quotes when the value has no whitespace. Alpine directive :href="item.url" under <template x-for> survived. But attributes inside a <template> also got minified, and every Alpine :href rendered to href=item.url (no colon, no quotes, literal string item.url). The browser parsed it as a real href. Every /agents/<slug>/item.url, /commands/<slug>/item.url, /osint/<slug>/item.url 404βd.
The fix was setting minify_html = false in config.toml and rebuilding with --base-url https://prismatic-reality-com.github.io/prismatic-promo. This eliminated the template-artifact class entirely, and dropped the broken-link count by 98%.
#2. Broken Glossary Slugs (~175)
An earlier session had rewritten 2,714 markdown files to replace Zolaβs @/glossary/X.md internal-link syntax with plain URL paths /glossary/X/. That got past Zolaβs strict link checker at build time. But 163 of those slugs pointed at glossary terms that never existed β they were wishful references in term definitions, not actual pages.
The fix was a dual-path rewrite. First, check which slugs exist. Second, rewrite every reference to a non-existent slug to point at /glossary/ (the index). Post-build HTML patch, ~255 unique broken refs, ~78 files changed.
#3. Relative Path Resolution Against GH Pages Subpath (~140)
The content references used bare relative paths: [prismatic_agents](../../../apps/prismatic_agents/README.md). Under Phoenix these would serve from the monorepo. Under GH Pages they resolve against the rendered page URL, producing /apps/prismatic_agents/README.md, which 404s because Pages only serves /prismatic-promo/*.
The fix rewrote 106 such references (across 22 glossary files) to absolute GitHub blob URLs: https://github.com/korczis/prismatic-platform/blob/main/apps/prismatic_agents/README.md. The markdown still reads naturally, the link points at the actual source.
#4. Authenticated Phoenix Routes Linked From Public Pages (~30)
Glossary term definitions often linked into the main Phoenix app: /hub/*, /perimeter/, /quality/, /security/, /contact/, /observability/, /performance/, /admin/telemetry/. These are authenticated LiveView routes, not static pages β they donβt ship in the promo site by design.
The fix was a catalog of explicit redirects: /hub/* -> / (homepage), /contact/ -> mailto:, /perimeter/ -> /about/for-security/, /developers/{api,mcp}/ -> /api/, /osint/toolbox/ -> /osint/. Applied at the HTML output level and to source markdown so future rebuilds stay clean.
#5. Academy URL Structure Drift (~11)
/academy/storage-patterns, /academy/dd-investigation, etc. The /learn/ segment was removed in a prior refactor but content still referenced it. Pattern-match rewrite: /academy/learn/X -> /academy/X/.
6. Template Bugs in @keydown.escape and Alpine x-data="commandRegistry()"
Two interactive components were silently broken:
- Nav dropdowns had
@click.outside="open = false"but no@keydown.escapehandler. Keyboard users could open a dropdown and have no non-mouse way to close it. Fix: one-line addition of@keydown.escape.window="open = false"on four nav dropdowns inbase.html. - The glossary hover-card listener did
e.target.closest('[data-glossary-hover]'). Bute.targetis sometimes a Text node (cursor passing over prose), and Text nodes lack.closest(). Every hover over body text threwTypeError: e.target.closest is not a function. Fix:if (!(e.target instanceof Element)) return;guard.
#7. Asset URLs Hardcoded as Root-Absolute (~15)
base.html referenced /css/tailwind.css and /js/vendor/alpine.min.js. On GH Pages subpath serving, /css/tailwind.css resolves to origin, not to /prismatic-promo/css/tailwind.css. Every deep page (anything beyond the root) shipped unstyled with no JS.
Shallow pages worked by coincidence: the browser cache populated on first visit, then the visibility of the site at the top level concealed the bug. The fix was converting all 15 hardcoded asset references to Zolaβs get_url(path=...) helper (wrapped in the usual Tera {% raw %}{{ ... }}{% endraw %} delimiters), which emits a full absolute URL including the subpath.
#What It Took
Source-level fixes (permanent): 18 stale app references fixed, config.toml base_url set to the github.io subpath, 142 internal hrefs across 17 templates migrated to {{ config.base_url | safe }}/..., three <style> blocks extracted to CSS files, 750 decorative SVGs gained aria-hidden="true", 232 Alpine buttons gained type="button", one Tera macro (related_articles) switched from strict get_page/1 to a forgiving section.pages | filter to stop hard-erroring on missing slugs.
Output-level patches (the gh-pages branch): each rebuild produced fresh minified HTML, so we ran Python scripts across the 2,800+ index.html files to apply the pattern rewrites where source-level changes hadnβt propagated yet. Five post-build sweeps in total before the crawl returned zero.
#The Measurement Philosophy
The only metric that mattered was this command:
curl -s https://prismatic-reality-com.github.io/prismatic-promo/sitemap.xml \
| grep -oE '<loc>[^<]+</loc>' \
| while read loc; do
url=$(echo "$loc" | sed 's|<loc>||;s|</loc>||')
code=$(curl -sI -o /dev/null -w '%{http_code}' "$url")
echo "$code $url"
doneRun recursively over every page, extract every <a href>, HEAD-check each. Count 200s. Count 404s. Repeat after each fix batch. No qualitative βlooks goodβ allowed β the count goes down or the work was noise.
#What We Did Not Do
We did not deploy via GitHub Actions with actions/deploy-pages@v4 as the workflow. We tried. One build ran 37 minutes on the GH runner with minify_html=true and generate_feeds=true (producing 50MB atom.xml + 50MB rss.xml) before we cancelled. The runner was not the bottleneck; the config was. With both flags disabled and a local zola build taking 7.5 minutes, a push-built artifact approach became the deploy loop.
We did not chase βfix every glossary termβ as a content task. The 163 missing slugs are legitimate gaps in the knowledge base β a separate curation effort. The devops fix was to make broken-slug links degrade gracefully (redirect to /glossary/), not to write 163 stub pages on deploy day.
We did not add a custom domain. prismatic-reality.comβs DNS still points at Fly.io. The github.io URL is stable, indexable, and unambiguous. A custom domain is an addition, not a blocker.
#The Result
The site serves 2,808 pages; 3,216 unique internal hrefs all return 200; 112 are trailing-slash redirects that resolve normally; zero genuine 404s.
Every link works. That is the deliverable. Everything else is narrative.