from __future__ import annotations import base64 import hashlib import io import json import mimetypes import re import zipfile from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path from typing import Iterable, Mapping, Sequence from .registry import PageDefinition, registry from .ui import HeaderButton ASSET_PATTERN = re.compile(r"data-docslib-asset\s*=\s*(['\"])(?P.*?)\1", re.IGNORECASE) @dataclass(frozen=True, slots=True) class BuildResult: manifest: dict[str, object] html: bytes zip_archive: bytes def _natural_part(value: str) -> tuple[tuple[int, object], ...]: return tuple( (0, int(part)) if part.isdigit() else (1, part.casefold()) for part in re.split(r"(\d+)", value) if part ) def page_sort_key(page: PageDefinition) -> tuple[tuple[tuple[int, object], ...], ...]: return tuple(_natural_part(part) for part in page.number.split(".")) def _flatten_content(value: object) -> Iterable[str]: if value is None: return if isinstance(value, str): yield value return if isinstance(value, (bytes, bytearray, Mapping)): raise TypeError("Page content must be HTML strings or an iterable of HTML strings") try: iterator = iter(value) # type: ignore[arg-type] except TypeError as error: raise TypeError( f"Page content must be HTML strings or an iterable, got {type(value).__name__}" ) from error for item in iterator: yield from _flatten_content(item) def _safe_json_for_script(value: object) -> str: return ( json.dumps(value, ensure_ascii=False, separators=(",", ":")) .replace("<", "\\u003c") .replace(">", "\\u003e") .replace("&", "\\u0026") .replace("\u2028", "\\u2028") .replace("\u2029", "\\u2029") ) def _read_optional_file(path: str | Path | None, *, label: str) -> str: if path is None: return "" file_path = Path(path).expanduser().resolve() if not file_path.is_file(): raise FileNotFoundError(f"{label} file does not exist: {file_path}") return file_path.read_text(encoding="utf-8") class Compiler: def __init__( self, *, title: str = "Documentation", assets_dir: str | Path = "./assets", custom_css: str | Path | None = None, custom_js: str | Path | None = None, template_dir: str | Path | None = None, manifest_url: str = "./manifest.json", live_preview: bool = False, header_buttons: Sequence[HeaderButton | Mapping[str, object]] | None = None, ) -> None: self.title = str(title) self.assets_dir = Path(assets_dir).expanduser().resolve() self.custom_css = custom_css self.custom_js = custom_js self.template_dir = ( Path(template_dir).expanduser().resolve() if template_dir else Path(__file__).with_name("templates") ) self.manifest_url = manifest_url self.live_preview = bool(live_preview) self.header_buttons = list(header_buttons or []) def build(self) -> BuildResult: pages = self._render_pages(registry.pages()) assets = self._load_assets(pages) stable_content: dict[str, object] = { "schema_version": 2, "title": self.title, "pages": pages, "assets": assets, "ui": {"header_buttons": self._compile_header_buttons()}, "client": self._load_client(), } canonical = json.dumps(stable_content, ensure_ascii=False, sort_keys=True, separators=(",", ":")) revision = hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:20] manifest = { **stable_content, "revision": revision, "updated_at": datetime.now(timezone.utc).isoformat(timespec="milliseconds"), } html = self._build_html(manifest).encode("utf-8") archive_buffer = io.BytesIO() with zipfile.ZipFile(archive_buffer, "w", compression=zipfile.ZIP_DEFLATED) as archive: archive.writestr("index.html", html) return BuildResult(manifest=manifest, html=html, zip_archive=archive_buffer.getvalue()) def watched_files(self) -> tuple[Path, ...]: """Return CSS and JavaScript sources that affect the manifest revision.""" paths = [self.template_dir / "client.css", self.template_dir / "client.js"] for optional in (self.custom_css, self.custom_js): if optional is not None: paths.append(Path(optional).expanduser().resolve()) return tuple(paths) def _load_client(self) -> dict[str, dict[str, str]]: css_path = self.template_dir / "client.css" script_path = self.template_dir / "client.js" for path in (css_path, script_path): if not path.is_file(): raise FileNotFoundError(f"Template file does not exist: {path}") return { "css": { "system": css_path.read_text(encoding="utf-8"), "custom": _read_optional_file(self.custom_css, label="CSS"), }, "js": { "system": script_path.read_text(encoding="utf-8"), "custom": _read_optional_file(self.custom_js, label="JavaScript"), }, } def _render_pages(self, definitions: list[PageDefinition]) -> list[dict[str, str]]: rendered: list[dict[str, str]] = [] for page in sorted(definitions, key=page_sort_key): try: content = "\n".join(_flatten_content(page.render())) except Exception as error: raise RuntimeError(f"Failed to render page {page.number!r} ({page.title})") from error rendered.append({"number": page.number, "title": page.title, "content": content}) return rendered def _load_assets(self, pages: list[dict[str, str]]) -> dict[str, dict[str, str]]: names = { match.group("name") for page in pages for match in ASSET_PATTERN.finditer(page["content"]) } assets: dict[str, dict[str, str]] = {} for name in sorted(names): candidate = (self.assets_dir / name).resolve() try: candidate.relative_to(self.assets_dir) except ValueError as error: raise ValueError(f"Asset escapes assets_dir: {name}") from error if not candidate.is_file(): raise FileNotFoundError(f"Referenced asset does not exist: {candidate}") mime = mimetypes.guess_type(candidate.name)[0] or "application/octet-stream" assets[name] = { "mime": mime, "data": base64.b64encode(candidate.read_bytes()).decode("ascii"), } return assets def _compile_header_buttons(self) -> list[dict[str, str]]: compiled: list[dict[str, str]] = [] for definition in self.header_buttons: if isinstance(definition, HeaderButton): label = definition.label page_target = definition.page href = definition.href section = definition.section new_tab = definition.new_tab elif isinstance(definition, Mapping): label = str(definition.get("label", "")).strip() page_target = definition.get("page") href_value = definition.get("href") href = str(href_value) if href_value is not None else None section = definition.get("section") new_tab = definition.get("target") == "_blank" or bool(definition.get("new_tab", False)) if not label: raise ValueError("Header button mapping requires a non-empty label") if (page_target is None) == (href is None): raise ValueError( f"Header button {label!r} requires exactly one of 'page' or 'href'" ) else: raise TypeError("header_buttons entries must be HeaderButton objects or mappings") if page_target is not None: page = registry.resolve_page(page_target, section=section) # type: ignore[arg-type] compiled.append({"label": label, "page": page.number}) else: button = {"label": label, "href": str(href)} if new_tab: button["target"] = "_blank" compiled.append(button) return compiled def _build_html(self, manifest: dict[str, object]) -> str: base_path = self.template_dir / "index.html" loader_path = self.template_dir / "loader.js" for path in (base_path, loader_path): if not path.is_file(): raise FileNotFoundError(f"Template file does not exist: {path}") template = base_path.read_text(encoding="utf-8") config = { "manifestUrl": self.manifest_url, "livePreview": self.live_preview, "liveUrl": "./live", "cacheKey": hashlib.sha256(self.manifest_url.encode("utf-8")).hexdigest()[:16], } replacements = { "{{DOCSLIB_TITLE}}": self.title.replace("&", "&").replace("<", "<").replace(">", ">"), "{{DOCSLIB_MANIFEST}}": _safe_json_for_script(manifest), "{{DOCSLIB_CONFIG}}": _safe_json_for_script(config), "{{DOCSLIB_LOADER}}": loader_path.read_text(encoding="utf-8"), } for marker, value in replacements.items(): if marker not in template: raise ValueError(f"Template is missing required marker {marker}") template = template.replace(marker, value) return template __all__ = ["BuildResult", "Compiler", "page_sort_key"]