96 lines
3.6 KiB
Python
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"]
|