docs: separate public manual from internal project documentation
Publish documentation / publish (push) Successful in 27s
Publish documentation / publish (push) Successful in 27s
This commit is contained in:
@@ -3,9 +3,16 @@ set -euo pipefail
|
||||
|
||||
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)
|
||||
|
||||
uv run \
|
||||
--quiet \
|
||||
--isolated \
|
||||
--no-env-file \
|
||||
--with-requirements "$root/docs/requirements.lock" \
|
||||
mkdocs build --strict --config-file "$root/mkdocs.yml"
|
||||
|
||||
exec uv run \
|
||||
--quiet \
|
||||
--isolated \
|
||||
--no-env-file \
|
||||
--with-requirements "$root/docs/requirements.lock" \
|
||||
mkdocs build --strict --config-file "$root/mkdocs.yml"
|
||||
python "$root/scripts/verify-public-docs.py" --root "$root"
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Negative fixtures for public documentation publication checks."""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"public_docs", Path(__file__).with_name("verify-public-docs.py")
|
||||
)
|
||||
verifier = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(verifier)
|
||||
|
||||
|
||||
class PublicationBoundaryTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory(prefix="thoth-public-docs-")
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
self.site = self.root / "site"
|
||||
self.config = "nav:\n- Home: index.md\nexclude_docs: |\n *\n"
|
||||
for source in ["index.md", *sorted(verifier.PUBLIC_ASSETS)]:
|
||||
self.config += f" !/{source}\n"
|
||||
self.write(self.root / "docs" / source, "# Public")
|
||||
self.write(self.site / verifier.output_path(source), "public")
|
||||
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")
|
||||
|
||||
@staticmethod
|
||||
def write(path, content):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content)
|
||||
|
||||
def write_search(self):
|
||||
self.write(self.site / "search/search_index.json", json.dumps(self.search))
|
||||
|
||||
def test_public_only(self):
|
||||
self.assertEqual(verifier.check(self.root, self.site), 1)
|
||||
|
||||
def test_internal_nav(self):
|
||||
self.write(self.root / "mkdocs.yml", self.config.replace(
|
||||
"- Home: index.md", "- Home: index.md\n- Internal: plans/private.md"
|
||||
))
|
||||
with self.assertRaisesRegex(ValueError, "internal page"):
|
||||
verifier.check(self.root, self.site)
|
||||
|
||||
def test_unlisted_exception(self):
|
||||
self.write(self.root / "mkdocs.yml", self.config + " !/plans/private.md\n")
|
||||
with self.assertRaisesRegex(ValueError, "exceptions must match"):
|
||||
verifier.check(self.root, self.site)
|
||||
|
||||
def test_missing_deny_default(self):
|
||||
self.write(self.root / "mkdocs.yml", self.config.replace(" *\n", ""))
|
||||
with self.assertRaisesRegex(ValueError, "deny-by-default"):
|
||||
verifier.check(self.root, self.site)
|
||||
|
||||
def test_leaked_internal_page(self):
|
||||
self.write(self.site / "plans/private/index.html", "internal")
|
||||
with self.assertRaisesRegex(ValueError, "internal file leaked"):
|
||||
verifier.check(self.root, self.site)
|
||||
|
||||
def test_leaked_internal_asset(self):
|
||||
self.write(self.site / "reports/screenshot.png", "internal")
|
||||
with self.assertRaisesRegex(ValueError, "internal file leaked"):
|
||||
verifier.check(self.root, self.site)
|
||||
|
||||
def test_internal_search_entry(self):
|
||||
self.search["docs"].append({"location": "plans/private/#internal", "text": "internal"})
|
||||
self.write_search()
|
||||
with self.assertRaisesRegex(ValueError, "non-public page in search"):
|
||||
verifier.check(self.root, self.site)
|
||||
|
||||
def test_missing_public_page(self):
|
||||
(self.site / "index.html").unlink()
|
||||
with self.assertRaisesRegex(ValueError, "missing generated public"):
|
||||
verifier.check(self.root, self.site)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -34,9 +34,7 @@ mkdir -p "$fixture/docs" "$fixture/scripts"
|
||||
cp -R "$root/docs/install" "$fixture/docs/install"
|
||||
cp -R "$root/docs/operations" "$fixture/docs/operations"
|
||||
cp "$root/docs/guida-utente.md" "$fixture/docs/guida-utente.md"
|
||||
mkdir -p "$fixture/docs/architecture" "$fixture/docs/contracts"
|
||||
cp "$root/docs/architecture/overview.md" "$fixture/docs/architecture/overview.md"
|
||||
cp "$root/docs/contracts/catalog-schema-snapshot.md" "$fixture/docs/contracts/catalog-schema-snapshot.md"
|
||||
cp "$root/docs/product-overview.md" "$fixture/docs/product-overview.md"
|
||||
cp "$root/mkdocs.yml" "$fixture/mkdocs.yml"
|
||||
cp "$root/compose.yaml" "$fixture/compose.yaml"
|
||||
cp "$root/scripts/run-stack.sh" "$fixture/scripts/run-stack.sh"
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify the public manual boundary, including generated pages and search."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote, urlsplit
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
PUBLIC_ASSETS = {
|
||||
"stylesheets/extra.css",
|
||||
"javascripts/layout-init.js",
|
||||
"install/examples/thothii-installation.local.yaml",
|
||||
"install/examples/thothii-installation.server.yaml",
|
||||
"install/examples/workspace-bindings.env.example",
|
||||
}
|
||||
INTERNAL_DIRS = {
|
||||
"adr", "agents", "architecture", "contracts", "maintenance", "plans",
|
||||
"reports", "research", "testing",
|
||||
}
|
||||
INTERNAL_PAGES = {
|
||||
"disambiguazione-iniziale.md", "gestione-memory.md", "installazione-docker-4-contesti.md",
|
||||
"install/authentication-upstream.md", "operations/docker-refresh.md",
|
||||
"operations/shell-and-localization.md",
|
||||
}
|
||||
|
||||
|
||||
def nav_pages(value):
|
||||
if isinstance(value, str):
|
||||
yield value
|
||||
elif isinstance(value, list):
|
||||
for item in value:
|
||||
yield from nav_pages(item)
|
||||
elif isinstance(value, dict):
|
||||
for item in value.values():
|
||||
yield from nav_pages(item)
|
||||
|
||||
|
||||
def output_path(source):
|
||||
path = Path(source)
|
||||
if path.suffix != ".md":
|
||||
return path
|
||||
return path.with_suffix("") / "index.html" if path.name != "index.md" else path.with_suffix(".html")
|
||||
|
||||
|
||||
def check(root, site):
|
||||
# BaseLoader reads configuration without executing custom Python YAML tags.
|
||||
config = yaml.load((root / "mkdocs.yml").read_text(), Loader=yaml.BaseLoader)
|
||||
pages = list(nav_pages(config["nav"]))
|
||||
if len(pages) != len(set(pages)):
|
||||
raise ValueError("duplicate public navigation page")
|
||||
for page in pages:
|
||||
path = Path(page)
|
||||
if path.is_absolute() or ".." in path.parts or path.suffix != ".md":
|
||||
raise ValueError(f"invalid public page: {page}")
|
||||
if (path.parts[0] in INTERNAL_DIRS or page in INTERNAL_PAGES
|
||||
or page.startswith("operations/server-")):
|
||||
raise ValueError(f"internal page in public navigation: {page}")
|
||||
if not (root / "docs" / page).is_file():
|
||||
raise ValueError(f"missing public source: {page}")
|
||||
|
||||
rules = config.get("exclude_docs", "").splitlines()
|
||||
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:
|
||||
raise ValueError("publication exceptions must match nav pages and approved assets exactly")
|
||||
|
||||
for source in published:
|
||||
if not (root / "docs" / source).is_file():
|
||||
raise ValueError(f"missing public source: {source}")
|
||||
if not (site / output_path(source)).is_file():
|
||||
raise ValueError(f"missing generated public file: {source}")
|
||||
for source in (root / "docs").rglob("*"):
|
||||
if not source.is_file():
|
||||
continue
|
||||
relative = source.relative_to(root / "docs").as_posix()
|
||||
if relative not in published and (site / output_path(relative)).exists():
|
||||
raise ValueError(f"internal file leaked into site: {relative}")
|
||||
|
||||
expected_urls = {
|
||||
"" if page == "index.md" else str(output_path(page).parent).replace("\\", "/") + "/"
|
||||
for page in pages
|
||||
}
|
||||
search = json.loads((site / "search/search_index.json").read_text())
|
||||
indexed = set()
|
||||
for entry in search["docs"]:
|
||||
location = unquote(urlsplit(entry["location"]).path)
|
||||
if location not in expected_urls:
|
||||
raise ValueError(f"non-public page in search index: {location}")
|
||||
indexed.add(location)
|
||||
if indexed != expected_urls:
|
||||
raise ValueError("search index does not cover exactly the public pages")
|
||||
return len(pages)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1])
|
||||
parser.add_argument("--site-dir", type=Path)
|
||||
args = parser.parse_args()
|
||||
root = args.root.resolve()
|
||||
try:
|
||||
count = check(root, args.site_dir or root / "site")
|
||||
except (ValueError, KeyError, OSError, yaml.YAMLError) as error:
|
||||
raise SystemExit(f"public docs verification failed: {error}") from error
|
||||
print(f"Public documentation boundary passed: {count} pages; internal files and search excluded")
|
||||
@@ -64,8 +64,8 @@ verify_navigation() {
|
||||
operations/workspaces.md \
|
||||
operations/database-management.md \
|
||||
guida-utente.md \
|
||||
architecture/overview.md \
|
||||
contracts/catalog-schema-snapshot.md; do
|
||||
product-overview.md \
|
||||
install/shell-and-language.md; do
|
||||
require_file "docs/$path"
|
||||
require_text mkdocs.yml "$path"
|
||||
done
|
||||
@@ -100,7 +100,7 @@ verify_install_and_workspace_guides() {
|
||||
|
||||
for text in \
|
||||
'tht setup --profile local' \
|
||||
'./scripts/run-stack.sh' \
|
||||
'--configure-only' \
|
||||
'catalog-migrate' \
|
||||
'tht --installation /absolute/path/thothii-installation.yaml doctor --json'; do
|
||||
require_text "$install" "$text"
|
||||
|
||||
Reference in New Issue
Block a user