127 lines
4.3 KiB
Python
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)
|