Fail the build on root-absolute URLs, so the Pages mirror matches the custom domain
Summary
docs/index.md linked its screenshot as /images/python_markdown.webp. That image
rendered on https://learn.celbridge.org/ and 404'd on
https://celbridge-org.github.io/celbridge-docs/, even though both hosts serve the
same build from the deploy branch.
The content bug is already fixed (b826460). This issue is about the missing guard:
nothing in the build or the publish workflow catches a root-absolute URL, so the same
mistake ships again the next time someone writes one.
Why one build broke on one host and not the other
A leading / resolves against the host root, not the site root:
| Written in Markdown |
learn.celbridge.org |
celbridge-org.github.io |
/images/x.webp |
learn.celbridge.org/images/x.webp — correct |
celbridge-org.github.io/images/x.webp — 404, the /celbridge-docs/ prefix is lost |
images/x.webp |
correct |
correct |
The custom domain serves the site at the root, so an absolute path happens to work
there. GitHub Pages serves the same build under /celbridge-docs/, so the same path
points outside the site entirely. Page-relative URLs resolve correctly under both.
This is already documented in the README under Editing → Images; it is a rule with
no enforcement behind it.
Why --strict did not catch it
zensical build --strict validates internal links between pages. It does not
check asset URLs, and an absolute URL is perfectly valid markup — it just points
somewhere else. The build passed, the publish workflow passed, and the mirror shipped
with a broken image.
Proposed fix: a post-build check
Add scripts/check_absolute_paths.py, and call it from build.py after the build, in
the same place the redirect stubs are written. CI picks it up for free, because the
workflow already runs python build.py.
This mirrors how the celbridge-website repo runs its check_front_matter_links.py and
check_no_third_party.py after its build.
scripts/check_absolute_paths.py
"""Fail the build on a root-absolute URL in the built site.
The site is published at two base paths: learn.celbridge.org serves it at the
root, and GitHub Pages serves the same build under /celbridge-docs/. A URL
written as /images/x.webp resolves against the host, so it points outside the
site on Pages and 404s there while working on the custom domain. Page-relative
URLs resolve correctly under both.
404.html is exempt: a 404 is served in place of any URL at any depth, so its
links cannot be relative. It is built from site_url and only resolves on the
custom domain, which is a known limit of publishing one build to two base paths.
"""
import re
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
SITE_FOLDER = REPO_ROOT / "site"
EXEMPT_FILES = {"404.html"}
# A src or href whose value starts with a single / - not // , which is a
# protocol-relative URL naming another host.
ABSOLUTE_URL = re.compile(r'(?:src|href)="(/(?!/)[^"]*)"')
def main() -> int:
if not SITE_FOLDER.is_dir():
print(f"error: {SITE_FOLDER} does not exist - run the build first")
return 1
findings = []
for page in sorted(SITE_FOLDER.rglob("*.html")):
if page.name in EXEMPT_FILES and page.parent == SITE_FOLDER:
continue
for url in ABSOLUTE_URL.findall(page.read_text(encoding="utf-8")):
findings.append((page.relative_to(SITE_FOLDER), url))
if findings:
print(f"{len(findings)} root-absolute URL(s) in the built site:")
for page, url in findings:
print(f" {page}: {url}")
print()
print("These break on GitHub Pages, which serves the site under /celbridge-docs/.")
print("Write them page-relative instead, e.g. images/x.webp or ../images/x.webp.")
return 1
print(f"No root-absolute URLs in {SITE_FOLDER.name}/")
return 0
if __name__ == "__main__":
sys.exit(main())
build.py
def build() -> int:
# --strict fails the build on a broken internal link, rather than
# publishing a site with holes in it.
print("==> building (strict)")
run_step(*ZENSICAL, "build", "--clean", "--strict")
redirects()
+ # A root-absolute URL works on the custom domain and 404s on the Pages
+ # mirror, which serves the same build under a subpath.
+ print("==> checking for root-absolute URLs")
+ run_step(PY, "scripts/check_absolute_paths.py")
+
print()
print("Done. Output in site/")
return 0
The check runs after the redirect stubs so that it sees the stubs too — they are
written into site/ and carry URLs of their own.
Verified
Run against the current build, both ways:
- with the bug reintroduced in
docs/index.md, the check fails with exit 1 and prints
index.html: /images/python_markdown.webp
- with the fix in place, it passes:
No root-absolute URLs in site/
Known limitation this does not solve: 404.html
site/404.html is built with root-absolute URLs by design — a 404 is served in place
of any URL at any depth, so its links cannot be relative — and they are built from
site_url, i.e. learn.celbridge.org. On the Pages mirror, the 404 page's nav links
point at celbridge-org.github.io/01_about/ and its stylesheet 404s, so a missing page
there renders unstyled with dead links.
No build-time check fixes this; one build genuinely cannot serve two base paths for
that one page. The options are to accept it (it affects only the mirror's 404 page), to
drop the Pages mirror and treat learn.celbridge.org as canonical, or to build twice
with a different site_url per host — which gives up "one build feeds both". The check
above exempts 404.html so this known case does not fail every build.
Acceptance criteria
Fail the build on root-absolute URLs, so the Pages mirror matches the custom domain
Summary
docs/index.mdlinked its screenshot as/images/python_markdown.webp. That imagerendered on https://learn.celbridge.org/ and 404'd on
https://celbridge-org.github.io/celbridge-docs/, even though both hosts serve the
same build from the
deploybranch.The content bug is already fixed (b826460). This issue is about the missing guard:
nothing in the build or the publish workflow catches a root-absolute URL, so the same
mistake ships again the next time someone writes one.
Why one build broke on one host and not the other
A leading
/resolves against the host root, not the site root:/images/x.webplearn.celbridge.org/images/x.webp— correctcelbridge-org.github.io/images/x.webp— 404, the/celbridge-docs/prefix is lostimages/x.webpThe custom domain serves the site at the root, so an absolute path happens to work
there. GitHub Pages serves the same build under
/celbridge-docs/, so the same pathpoints outside the site entirely. Page-relative URLs resolve correctly under both.
This is already documented in the README under Editing → Images; it is a rule with
no enforcement behind it.
Why
--strictdid not catch itzensical build --strictvalidates internal links between pages. It does notcheck asset URLs, and an absolute URL is perfectly valid markup — it just points
somewhere else. The build passed, the publish workflow passed, and the mirror shipped
with a broken image.
Proposed fix: a post-build check
Add
scripts/check_absolute_paths.py, and call it frombuild.pyafter the build, inthe same place the redirect stubs are written. CI picks it up for free, because the
workflow already runs
python build.py.This mirrors how the celbridge-website repo runs its
check_front_matter_links.pyandcheck_no_third_party.pyafter its build.scripts/check_absolute_paths.pybuild.pydef build() -> int: # --strict fails the build on a broken internal link, rather than # publishing a site with holes in it. print("==> building (strict)") run_step(*ZENSICAL, "build", "--clean", "--strict") redirects() + # A root-absolute URL works on the custom domain and 404s on the Pages + # mirror, which serves the same build under a subpath. + print("==> checking for root-absolute URLs") + run_step(PY, "scripts/check_absolute_paths.py") + print() print("Done. Output in site/") return 0The check runs after the redirect stubs so that it sees the stubs too — they are
written into
site/and carry URLs of their own.Verified
Run against the current build, both ways:
docs/index.md, the check fails with exit 1 and printsindex.html: /images/python_markdown.webpNo root-absolute URLs in site/Known limitation this does not solve:
404.htmlsite/404.htmlis built with root-absolute URLs by design — a 404 is served in placeof any URL at any depth, so its links cannot be relative — and they are built from
site_url, i.e. learn.celbridge.org. On the Pages mirror, the 404 page's nav linkspoint at
celbridge-org.github.io/01_about/and its stylesheet 404s, so a missing pagethere renders unstyled with dead links.
No build-time check fixes this; one build genuinely cannot serve two base paths for
that one page. The options are to accept it (it affects only the mirror's 404 page), to
drop the Pages mirror and treat learn.celbridge.org as canonical, or to build twice
with a different
site_urlper host — which gives up "one build feeds both". The checkabove exempts
404.htmlso this known case does not fail every build.Acceptance criteria
scripts/check_absolute_paths.pyexists and is called frombuild.pypython build.pyfails when any page other than404.htmlcarries aroot-absolute
src/href, naming the file and the URL