221 lines
8.7 KiB
Python
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("&", "&").replace("<", "<").replace(">", ">"),
|
|
"{{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"]
|