diff --git a/docs/operations/public-docs-publication.md b/docs/operations/public-docs-publication.md index 54012015..cb9b24d2 100644 --- a/docs/operations/public-docs-publication.md +++ b/docs/operations/public-docs-publication.md @@ -32,7 +32,11 @@ bash scripts/test-verify-dwh-auth-docs.sh The strict build also verifies the public boundary: exactly 20 navigation pages, five approved source assets, no internal pages/assets, and matching search entries. -Theme-generated assets are separate from those five source assets. +The locked Windmill theme requires a separate exact allowlist of 25 static assets; +MkDocs applies `exclude_docs` to those files too. Never replace the list with broad +directory exceptions. Sources under `docs/` may not shadow theme asset paths. +The verifier follows stylesheet/script/image references and CSS font references +from generated pages and fails when a local dependency is absent. Choose a unique release identifier consisting of UTC timestamp and source SHA. Record the current symlink and container identity before proceeding: @@ -78,6 +82,10 @@ briefly interrupt this manual only. `install/standalone-manual-it/` and `install/standalone-manual-en/`. - Compare live home and `search/search_index.json` checksums to the build. Search must contain only the 20 approved pages, never plans, reports or architecture. +- Check the actual stylesheet and JavaScript URLs in both installation pages: + HTTP 200, CSS served as `text/css`, JavaScript with a valid script content type, + and fonts available. In a browser confirm the stylesheets load and the layout is + styled. HTML 200 alone is not enough to accept a publication. - Require HTTP 404 for retired/internal paths, including `architecture/overview/`, `plans/2026-09-08-memory-management/`, and `operations/compose-reference/`. - Record source SHA, release ID, previous release and checks in the cleanup/release diff --git a/mkdocs.yml b/mkdocs.yml index a26cd32c..a7b1bf37 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -45,6 +45,8 @@ markdown_extensions: # Public manual: deny by default, including search and copied assets. # Internal developer documents remain in Git, not in the generated site. # Keep these exceptions aligned with nav; scripts/verify-public-docs.py checks it. +# MkDocs applies these rules to bundled theme assets too. Keep exact exceptions +# below in sync with THEME_ASSETS; never allow whole directories or docs overrides. exclude_docs: | * !/index.md @@ -72,6 +74,31 @@ exclude_docs: | !/install/examples/thothii-installation.local.yaml !/install/examples/thothii-installation.server.yaml !/install/examples/workspace-bindings.env.example + !/css/base.css + !/css/bootstrap-3.3.7.css + !/css/bootstrap-3.3.7.min.css + !/css/font-awesome-4.7.0.css + !/css/font-awesome-4.7.0.min.css + !/css/highlight.css + !/fonts/fontawesome-webfont.eot + !/fonts/fontawesome-webfont.svg + !/fonts/fontawesome-webfont.ttf + !/fonts/fontawesome-webfont.woff + !/fonts/fontawesome-webfont.woff2 + !/fonts/glyphicons-halflings-regular.eot + !/fonts/glyphicons-halflings-regular.svg + !/fonts/glyphicons-halflings-regular.ttf + !/fonts/glyphicons-halflings-regular.woff + !/fonts/glyphicons-halflings-regular.woff2 + !/img/favicon.ico + !/js/base.js + !/js/bootstrap-3.3.7.js + !/js/bootstrap-3.3.7.min.js + !/js/elasticlunr.js + !/js/elasticlunr.min.js + !/js/highlight.pack.js + !/js/jquery-3.2.1.js + !/js/jquery-3.2.1.min.js nav: - Home: index.md - Understand ThothII: diff --git a/scripts/test-verify-public-docs.py b/scripts/test-verify-public-docs.py index 4abfb179..8e757efa 100644 --- a/scripts/test-verify-public-docs.py +++ b/scripts/test-verify-public-docs.py @@ -27,10 +27,18 @@ class PublicationBoundaryTest(unittest.TestCase): self.write(self.root / "docs" / source, "# Public") self.write(self.site / verifier.output_path(source), "public") self.write(self.root / "mkdocs.yml", self.config) + for asset in sorted(verifier.THEME_ASSETS): + self.config += f" !/{asset}\n" + self.write(self.site / asset, "theme fixture") + self.write(self.root / "mkdocs.yml", self.config) self.search = {"docs": [{"location": "", "text": "public"}]} self.write_search() self.write(self.root / "docs/plans/private.md", "# Internal") self.write(self.root / "docs/reports/screenshot.png", "internal asset") + self.write(self.site / "index.html", '') + self.write(self.site / "css/base.css", '@font-face { src: url("../fonts/demo.woff2?v=1"); }') + self.write(self.site / "fonts/demo.woff2", "font fixture") + self.write(self.site / "js/base.js", "// script fixture") @staticmethod def write(path, content): @@ -81,6 +89,39 @@ class PublicationBoundaryTest(unittest.TestCase): with self.assertRaisesRegex(ValueError, "missing generated public"): verifier.check(self.root, self.site) + def test_missing_theme_stylesheet(self): + (self.site / "css/base.css").unlink() + with self.assertRaisesRegex(ValueError, "missing generated page asset: css/base.css"): + verifier.check(self.root, self.site) + + def test_docs_cannot_shadow_theme_assets(self): + self.write(self.root / "docs/css/base.css", "internal content") + with self.assertRaisesRegex(ValueError, "docs source shadows theme asset"): + verifier.check(self.root, self.site) + + def test_missing_theme_script(self): + (self.site / "js/base.js").unlink() + with self.assertRaisesRegex(ValueError, "missing generated page asset: js/base.js"): + verifier.check(self.root, self.site) + + def test_missing_css_font(self): + (self.site / "fonts/demo.woff2").unlink() + with self.assertRaisesRegex(ValueError, "missing generated page asset: fonts/demo.woff2"): + verifier.check(self.root, self.site) + + def test_asset_outside_site_prefix(self): + self.write(self.root / "mkdocs.yml", self.config + "site_url: https://docs.example.invalid/manual/\n") + self.write(self.site / "index.html", '') + with self.assertRaisesRegex(ValueError, "asset escapes public site prefix"): + verifier.check(self.root, self.site) + + def test_nested_installation_asset_resolution(self): + self.write(self.site / "install/manual/index.html", '') + verifier.check_assets(self.site, ["install/manual.md"], "https://docs.example.invalid/manual/") + (self.site / "css/base.css").unlink() + with self.assertRaisesRegex(ValueError, "missing generated page asset: css/base.css"): + verifier.check_assets(self.site, ["install/manual.md"], "https://docs.example.invalid/manual/") + if __name__ == "__main__": unittest.main() diff --git a/scripts/verify-public-docs.py b/scripts/verify-public-docs.py index b2fab69f..37b183cc 100644 --- a/scripts/verify-public-docs.py +++ b/scripts/verify-public-docs.py @@ -3,8 +3,10 @@ import argparse import json +import re +from html.parser import HTMLParser from pathlib import Path -from urllib.parse import unquote, urlsplit +from urllib.parse import unquote, urljoin, urlsplit import yaml @@ -16,6 +18,35 @@ PUBLIC_ASSETS = { "install/examples/thothii-installation.server.yaml", "install/examples/workspace-bindings.env.example", } +# Exact static assets shipped by the locked Windmill theme. Sources in docs/ must +# not shadow these paths, or an internal file could bypass the public allowlist. +THEME_ASSETS = { + "css/base.css", + "css/bootstrap-3.3.7.css", + "css/bootstrap-3.3.7.min.css", + "css/font-awesome-4.7.0.css", + "css/font-awesome-4.7.0.min.css", + "css/highlight.css", + "fonts/fontawesome-webfont.eot", + "fonts/fontawesome-webfont.svg", + "fonts/fontawesome-webfont.ttf", + "fonts/fontawesome-webfont.woff", + "fonts/fontawesome-webfont.woff2", + "fonts/glyphicons-halflings-regular.eot", + "fonts/glyphicons-halflings-regular.svg", + "fonts/glyphicons-halflings-regular.ttf", + "fonts/glyphicons-halflings-regular.woff", + "fonts/glyphicons-halflings-regular.woff2", + "img/favicon.ico", + "js/base.js", + "js/bootstrap-3.3.7.js", + "js/bootstrap-3.3.7.min.js", + "js/elasticlunr.js", + "js/elasticlunr.min.js", + "js/highlight.pack.js", + "js/jquery-3.2.1.js", + "js/jquery-3.2.1.min.js", +} INTERNAL_DIRS = { "adr", "agents", "architecture", "contracts", "maintenance", "plans", "reports", "research", "testing", @@ -46,6 +77,57 @@ def output_path(source): return path.with_suffix("") / "index.html" if path.name != "index.md" else path.with_suffix(".html") +class AssetLinks(HTMLParser): + def __init__(self): + super().__init__() + self.urls = [] + + def handle_starttag(self, tag, attrs): + attrs = dict(attrs) + if tag == "link" and "stylesheet" in attrs.get("rel", "").split(): + self.urls.append(attrs.get("href", "")) + elif tag in ("script", "img") and attrs.get("src"): + self.urls.append(attrs["src"]) + + +def check_assets(site, pages, site_url): + """Follow page assets and CSS font/image references using browser URL rules.""" + base = site_url.rstrip("/") + "/" + origin = urlsplit(base) + visited = set() + + def visit(reference, parent): + if not reference or reference.startswith("#"): + return + url = urlsplit(urljoin(parent, reference)) + if url.scheme not in ("http", "https") or url.netloc != origin.netloc: + return + path = unquote(url.path) + if not path.startswith(origin.path): + raise ValueError(f"asset escapes public site prefix: {reference}") + relative = path[len(origin.path):] + asset = (site / relative).resolve() + if site.resolve() not in asset.parents or not asset.is_file(): + raise ValueError(f"missing generated page asset: {relative}") + if relative in visited: + return + visited.add(relative) + if asset.suffix == ".css": + css = asset.read_text() + refs = re.findall(r"url\(\s*['\"]?([^)'\"\s]+)", css) + refs += re.findall(r"@import\s+['\"]([^'\"]+)", css) + for child in refs: + visit(child, url.geturl()) + + for page in pages: + output = output_path(page) + parser = AssetLinks() + parser.feed((site / output).read_text()) + page_url = urljoin(base, output.as_posix()) + for reference in parser.urls: + visit(reference, page_url) + + def check(root, site): # BaseLoader reads configuration without executing custom Python YAML tags. config = yaml.load((root / "mkdocs.yml").read_text(), Loader=yaml.BaseLoader) @@ -66,10 +148,14 @@ def check(root, site): if not rules or rules[0] != "*" or any(not r.startswith("!/") for r in rules[1:]): raise ValueError("public docs must use deny-by-default exclusions and explicit exceptions") published = [r[2:] for r in rules[1:]] - if len(published) != len(set(published)) or set(published) != set(pages) | PUBLIC_ASSETS: + if len(published) != len(set(published)) or set(published) != set(pages) | PUBLIC_ASSETS | THEME_ASSETS: raise ValueError("publication exceptions must match nav pages and approved assets exactly") for source in published: + if source in THEME_ASSETS: + if (root / "docs" / source).exists(): + raise ValueError(f"docs source shadows theme asset: {source}") + continue if not (root / "docs" / source).is_file(): raise ValueError(f"missing public source: {source}") if not (site / output_path(source)).is_file(): @@ -94,6 +180,10 @@ def check(root, site): indexed.add(location) if indexed != expected_urls: raise ValueError("search index does not cover exactly the public pages") + check_assets(site, pages, config.get("site_url", "https://docs.example.invalid/")) + for asset in sorted(THEME_ASSETS): + if not (site / asset).is_file(): + raise ValueError(f"missing generated theme asset: {asset}") return len(pages)