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"]