SharedDocsLib/docslib/compiler.py
2026-08-09 17:59:49 +00:00

221 lines
8.7 KiB
Python

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<name>.*?)\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) -> str:
if path is None:
return ""
file_path = Path(path).expanduser().resolve()
if not file_path.is_file():
raise FileNotFoundError(f"CSS 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,
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.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": 1,
"title": self.title,
"pages": pages,
"assets": assets,
"ui": {"header_buttons": self._compile_header_buttons()},
}
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 _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"
css_path = self.template_dir / "client.css"
script_path = self.template_dir / "client.js"
for path in (base_path, css_path, script_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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;"),
"{{DOCSLIB_CSS}}": css_path.read_text(encoding="utf-8"),
"{{DOCSLIB_CUSTOM_CSS}}": _read_optional_file(self.custom_css),
"{{DOCSLIB_MANIFEST}}": _safe_json_for_script(manifest),
"{{DOCSLIB_CONFIG}}": _safe_json_for_script(config),
"{{DOCSLIB_JS}}": script_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"]