mews.page/pelican-mews/pelican/plugins/mews/mews.py
2026-10-11 08:09:18 +03:00

127 lines
4.3 KiB
Python

"""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 <html>; 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)