"""Make Pelican output Mews Profile pages. Every .xhtml file Pelican writes is brought inside the subset the bundled Mews DTD defines: disallowed markup is stripped (or, with MEWS_STRICT, fails the build), the XHTML namespace is ensured, and the page is re-serialized as well-formed UTF-8 XML. The plugin also copies the Mews stylesheet into the output tree and hands themes a MEWS_CSS global and the mews/head.html partial. Settings: MEWS_STRICT Fail the build on disallowed content instead of stripping it. Defaults to False. Removals are always logged. MEWS_CSS_DIR Directory below the output root for the stylesheet. Defaults to "css"; themes get the path as the MEWS_CSS global. MEWS_OUTPUT_PATH Put the finished pages in this folder instead of OUTPUT_PATH, so a normal blog can keep its own output tree. Unset by default. The stylesheet is copied there too, and the .xhtml file Pelican left in OUTPUT_PATH is removed. """ from __future__ import annotations from importlib.resources import files import logging import os from pathlib import Path import shutil from lxml import etree from pelican import signals from .subset import ( MewsSubsetError, element_name, enforce_subset, ensure_xhtml_root, parse_allowlist, parse_page, serialize, ) __all__ = ("register",) LOGGER = logging.getLogger(__name__) CSS_NAME = "mews-0.1.css" DEFAULT_CSS_DIR = "css" XHTML_SUFFIX = ".xhtml" ALLOWLIST = parse_allowlist( files(__package__).joinpath("dtd", "mews.dtd").read_text(encoding="utf-8") ) # content_written carries no settings, so the handler reads them from here; # refreshed by the initialized signal, which fires before every build. _settings: dict = {} def _on_initialized(pelican) -> None: _settings.clear() _settings.update(pelican.settings) overrides = pelican.settings.setdefault("THEME_TEMPLATES_OVERRIDES", []) templates = str(files(__package__).joinpath("templates")) if templates not in overrides: overrides.append(templates) def _on_generator_init(generator) -> None: css_dir = generator.settings.get("MEWS_CSS_DIR", DEFAULT_CSS_DIR) generator.env.globals["MEWS_CSS"] = f"{css_dir}/{CSS_NAME}" def _on_content_written(path, **_) -> None: file = Path(path) if file.suffix != XHTML_SUFFIX: return try: tree, doctype = parse_page(file.read_bytes()) except etree.XMLSyntaxError as error: message = f"{file} is not well-formed XML: {error}" raise MewsSubsetError(message) from error if element_name(tree.getroot().tag) != "html": LOGGER.warning("%s: root element is not ; leaving it unchanged", file) return tree = ensure_xhtml_root(tree) strict = bool(_settings.get("MEWS_STRICT", False)) enforce_subset(tree, ALLOWLIST, strict=strict, source=str(file), log=LOGGER.warning) data = serialize(tree, doctype) parallel = _settings.get("MEWS_OUTPUT_PATH") if not parallel: file.write_bytes(data) return target = Path(parallel) / os.path.relpath(file, _settings["OUTPUT_PATH"]) target.parent.mkdir(parents=True, exist_ok=True) target.write_bytes(data) file.unlink() _prune_empty_dirs(file, Path(_settings["OUTPUT_PATH"]).resolve()) LOGGER.info("moved %s to %s", file, target) def _prune_empty_dirs(path: Path, root: Path) -> None: """Remove directories the move left empty, up to the output root.""" empty = path.parent.resolve() while empty not in (root, empty.parent) and not any(empty.iterdir()): empty.rmdir() empty = empty.parent def _on_finalized(pelican) -> None: css_dir = pelican.settings.get("MEWS_CSS_DIR", DEFAULT_CSS_DIR) root = pelican.settings.get("MEWS_OUTPUT_PATH") or pelican.settings["OUTPUT_PATH"] target = Path(root) / css_dir / CSS_NAME target.parent.mkdir(parents=True, exist_ok=True) source = files(__package__).joinpath("static", CSS_NAME) with source.open("rb") as src, target.open("wb") as dst: shutil.copyfileobj(src, dst) LOGGER.info("wrote %s", target) def register() -> None: """Connect the plugin to Pelican's signals.""" signals.initialized.connect(_on_initialized) signals.generator_init.connect(_on_generator_init) signals.content_written.connect(_on_content_written) signals.finalized.connect(_on_finalized)