fix(docs): preserve theme assets in deny-by-default publication
Publish documentation / publish (push) Successful in 36s
Publish documentation / publish (push) Successful in 36s
This commit is contained in:
@@ -32,7 +32,11 @@ bash scripts/test-verify-dwh-auth-docs.sh
|
|||||||
|
|
||||||
The strict build also verifies the public boundary: exactly 20 navigation pages,
|
The strict build also verifies the public boundary: exactly 20 navigation pages,
|
||||||
five approved source assets, no internal pages/assets, and matching search entries.
|
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.
|
Choose a unique release identifier consisting of UTC timestamp and source SHA.
|
||||||
Record the current symlink and container identity before proceeding:
|
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/`.
|
`install/standalone-manual-it/` and `install/standalone-manual-en/`.
|
||||||
- Compare live home and `search/search_index.json` checksums to the build. Search
|
- 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.
|
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/`,
|
- Require HTTP 404 for retired/internal paths, including `architecture/overview/`,
|
||||||
`plans/2026-09-08-memory-management/`, and `operations/compose-reference/`.
|
`plans/2026-09-08-memory-management/`, and `operations/compose-reference/`.
|
||||||
- Record source SHA, release ID, previous release and checks in the cleanup/release
|
- Record source SHA, release ID, previous release and checks in the cleanup/release
|
||||||
|
|||||||
+27
@@ -45,6 +45,8 @@ markdown_extensions:
|
|||||||
# Public manual: deny by default, including search and copied assets.
|
# Public manual: deny by default, including search and copied assets.
|
||||||
# Internal developer documents remain in Git, not in the generated site.
|
# Internal developer documents remain in Git, not in the generated site.
|
||||||
# Keep these exceptions aligned with nav; scripts/verify-public-docs.py checks it.
|
# 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: |
|
exclude_docs: |
|
||||||
*
|
*
|
||||||
!/index.md
|
!/index.md
|
||||||
@@ -72,6 +74,31 @@ exclude_docs: |
|
|||||||
!/install/examples/thothii-installation.local.yaml
|
!/install/examples/thothii-installation.local.yaml
|
||||||
!/install/examples/thothii-installation.server.yaml
|
!/install/examples/thothii-installation.server.yaml
|
||||||
!/install/examples/workspace-bindings.env.example
|
!/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:
|
nav:
|
||||||
- Home: index.md
|
- Home: index.md
|
||||||
- Understand ThothII:
|
- Understand ThothII:
|
||||||
|
|||||||
@@ -27,10 +27,18 @@ class PublicationBoundaryTest(unittest.TestCase):
|
|||||||
self.write(self.root / "docs" / source, "# Public")
|
self.write(self.root / "docs" / source, "# Public")
|
||||||
self.write(self.site / verifier.output_path(source), "public")
|
self.write(self.site / verifier.output_path(source), "public")
|
||||||
self.write(self.root / "mkdocs.yml", self.config)
|
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.search = {"docs": [{"location": "", "text": "public"}]}
|
||||||
self.write_search()
|
self.write_search()
|
||||||
self.write(self.root / "docs/plans/private.md", "# Internal")
|
self.write(self.root / "docs/plans/private.md", "# Internal")
|
||||||
self.write(self.root / "docs/reports/screenshot.png", "internal asset")
|
self.write(self.root / "docs/reports/screenshot.png", "internal asset")
|
||||||
|
self.write(self.site / "index.html", '<link rel="stylesheet" href="css/base.css"><script src="js/base.js"></script>')
|
||||||
|
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
|
@staticmethod
|
||||||
def write(path, content):
|
def write(path, content):
|
||||||
@@ -81,6 +89,39 @@ class PublicationBoundaryTest(unittest.TestCase):
|
|||||||
with self.assertRaisesRegex(ValueError, "missing generated public"):
|
with self.assertRaisesRegex(ValueError, "missing generated public"):
|
||||||
verifier.check(self.root, self.site)
|
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", '<link rel="stylesheet" href="/css/base.css">')
|
||||||
|
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", '<link rel="stylesheet" href="../../css/base.css">')
|
||||||
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -3,8 +3,10 @@
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
|
from html.parser import HTMLParser
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import unquote, urlsplit
|
from urllib.parse import unquote, urljoin, urlsplit
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
@@ -16,6 +18,35 @@ PUBLIC_ASSETS = {
|
|||||||
"install/examples/thothii-installation.server.yaml",
|
"install/examples/thothii-installation.server.yaml",
|
||||||
"install/examples/workspace-bindings.env.example",
|
"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 = {
|
INTERNAL_DIRS = {
|
||||||
"adr", "agents", "architecture", "contracts", "maintenance", "plans",
|
"adr", "agents", "architecture", "contracts", "maintenance", "plans",
|
||||||
"reports", "research", "testing",
|
"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")
|
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):
|
def check(root, site):
|
||||||
# BaseLoader reads configuration without executing custom Python YAML tags.
|
# BaseLoader reads configuration without executing custom Python YAML tags.
|
||||||
config = yaml.load((root / "mkdocs.yml").read_text(), Loader=yaml.BaseLoader)
|
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:]):
|
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")
|
raise ValueError("public docs must use deny-by-default exclusions and explicit exceptions")
|
||||||
published = [r[2:] for r in rules[1:]]
|
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")
|
raise ValueError("publication exceptions must match nav pages and approved assets exactly")
|
||||||
|
|
||||||
for source in published:
|
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():
|
if not (root / "docs" / source).is_file():
|
||||||
raise ValueError(f"missing public source: {source}")
|
raise ValueError(f"missing public source: {source}")
|
||||||
if not (site / output_path(source)).is_file():
|
if not (site / output_path(source)).is_file():
|
||||||
@@ -94,6 +180,10 @@ def check(root, site):
|
|||||||
indexed.add(location)
|
indexed.add(location)
|
||||||
if indexed != expected_urls:
|
if indexed != expected_urls:
|
||||||
raise ValueError("search index does not cover exactly the public pages")
|
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)
|
return len(pages)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user