Shipping Zola to GitHub Pages: A 2938-Broken-Links Postmortem - Prismatic Platform
Engineering

Shipping Zola to GitHub Pages: A 2938-Broken-Links Postmortem

A single-day devops postmortem: taking a private Elixir umbrella repo and shipping a 2808-page static site to GitHub Pages under a subpath, from 2938 broken links to zero.

Apr 24, 2026 Β· 9 min read Β· Tomas Korcak (korczis)

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.escape handler. 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 in base.html.
  • The glossary hover-card listener did e.target.closest('[data-glossary-hover]'). But e.target is sometimes a Text node (cursor passing over prose), and Text nodes lack .closest(). Every hover over body text threw TypeError: 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"
    done

Run 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.

Browse all β†’