SharedDocsLib/docslib/generators.py

96 lines
3.6 KiB
Python

from __future__ import annotations
import json
import re
from dataclasses import dataclass
from pathlib import Path
from .compiler import BuildResult, Compiler
from .hooks import VariantSelection, normalise_variants
def _filename(value: str) -> str:
name = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", value).strip().rstrip(". ")
if not name:
name = "Documentation"
if name.split(".")[0].upper() in {
"CON", "PRN", "AUX", "NUL",
*(f"COM{i}" for i in range(1, 10)),
*(f"LPT{i}" for i in range(1, 10)),
}:
name = "_" + name
return name
@dataclass(frozen=True, slots=True)
class FilesBuildResult:
"""UTF-8 content keyed by generated filename, relative to build_directory."""
files: dict[str, str]
@dataclass(frozen=True, slots=True)
class DefaultGenerator:
"""Generate the standard four-artifact HTML bundle."""
component_variant: VariantSelection = 1
def __post_init__(self) -> None:
object.__setattr__(self, "component_variant", normalise_variants(self.component_variant))
def generate(self, compiler: Compiler, output: Path) -> BuildResult:
result = compiler.build()
output.mkdir(parents=True, exist_ok=True)
(output / "index.html").write_bytes(result.html)
(output / "manifest.json").write_text(
json.dumps(result.manifest, ensure_ascii=False, separators=(",", ":")),
encoding="utf-8",
)
(output / "version").write_text(str(result.manifest["revision"]), encoding="utf-8")
(output / "index.zip").write_bytes(result.zip_archive)
return result
@dataclass(frozen=True, slots=True)
class FilesGenerator:
"""Export a selected component variant as raw content, without an HTML shell."""
component_variant: VariantSelection
content_extension: str
as_single_file: bool = True
def __post_init__(self) -> None:
object.__setattr__(self, "component_variant", normalise_variants(self.component_variant))
if not isinstance(self.content_extension, str):
raise TypeError("content_extension must be a string")
extension = self.content_extension.removeprefix(".")
if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9._-]*", extension) or extension.endswith("."):
raise ValueError("content_extension must be a non-empty file extension without path separators")
object.__setattr__(self, "content_extension", extension)
if type(self.as_single_file) is not bool:
raise TypeError("as_single_file must be bool")
def generate(self, compiler: Compiler, output: Path) -> FilesBuildResult:
pages = compiler.render_pages()
if self.as_single_file:
files = {
f"{_filename(compiler.title)}.{self.content_extension}": "\n\n".join(
f"--- {page['number']}. {page['title']} ---\n{page['content']}"
for page in pages
)
}
else:
files = {}
for page in pages:
filename = f"{_filename(page['number'] + '. ' + page['title'])}.{self.content_extension}"
if filename.casefold() in {name.casefold() for name in files}:
raise ValueError(f"Generated filename collision: {filename}")
files[filename] = page["content"]
output.mkdir(parents=True, exist_ok=True)
for filename, content in files.items():
with (output / filename).open("w", encoding="utf-8", newline="") as stream:
stream.write(content)
return FilesBuildResult(files=files)
__all__ = ["DefaultGenerator", "FilesBuildResult", "FilesGenerator"]