diff --git a/omicron/ssg/output/__init__.py b/omicron/ssg/output/__init__.py index f8c332c..ccfd9ee 100644 --- a/omicron/ssg/output/__init__.py +++ b/omicron/ssg/output/__init__.py @@ -1,13 +1,15 @@ -from omicron.ssg.output.output import Output +from omicron.ssg.output.output import Output, OutputError from omicron.ssg.output.content import Content, ContentError from omicron.ssg.output.factory import create_content, is_content from omicron.ssg.output.file import File from omicron.ssg.output.index import Index, discover_index from omicron.ssg.output.memory import Memory +from omicron.ssg.output.symlink import Symlink from omicron.ssg.output.tags import Tags, discover_tags __all__ = [ "Output", + "OutputError", "Content", "ContentError", "create_content", @@ -16,6 +18,7 @@ __all__ = [ "Index", "discover_index", "Memory", + "Symlink", "Tags", "discover_tags", ] diff --git a/omicron/ssg/output/content.py b/omicron/ssg/output/content.py index 9071d40..abca242 100644 --- a/omicron/ssg/output/content.py +++ b/omicron/ssg/output/content.py @@ -1,6 +1,6 @@ import re from typing import TYPE_CHECKING -from omicron.ssg.output.output import Output +from omicron.ssg.output.output import Output, OutputError from pathlib import Path from datetime import date @@ -10,7 +10,7 @@ else: Site = "omicron.ssg.site.Site" -class ContentError(RuntimeError): +class ContentError(OutputError): pass diff --git a/omicron/ssg/output/output.py b/omicron/ssg/output/output.py index 0826698..7346406 100644 --- a/omicron/ssg/output/output.py +++ b/omicron/ssg/output/output.py @@ -9,6 +9,10 @@ else: Site = "omicron.ssg.site.Site" # for runtime checking with beartype +class OutputError(RuntimeError): + pass + + class Output(ABC): def __init__(self, site: Site, destination: Path): self._site_ref = weakref.ref(site) diff --git a/omicron/ssg/output/symlink.py b/omicron/ssg/output/symlink.py new file mode 100644 index 0000000..0b8bde6 --- /dev/null +++ b/omicron/ssg/output/symlink.py @@ -0,0 +1,38 @@ +import logging +import os +from typing import TYPE_CHECKING +from omicron.ssg.output.output import Output, OutputError +from pathlib import Path + +log = logging.getLogger(__name__) + +if TYPE_CHECKING: + from omicron.ssg.site import Site # for static checking with mypy +else: + Site = "omicron.ssg.site.Site" # for runtime checking with beartype + + +class Symlink(Output): + def __init__(self, site: Site, link: Path, target: Path): + if target.is_absolute(): + raise ValueError("Symlinks must be relative") + if ( + not (site.output_path / target) + .resolve() + .is_relative_to(site.output_path.resolve()) + ): + raise ValueError("Symlink target escapes the output directory") + super().__init__(site, link) + self.target = target + + def write(self) -> None: + site = self.site + if self.target not in site.by_path: + raise OutputError(f"Symlink target '{self.target}' is not a known output") + link = site.output_path / self.destination + link.parent.mkdir(parents=True, exist_ok=True) + if link.exists() or link.is_symlink(): + link.unlink() + rel_target = os.path.relpath(site.output_path / self.target, link.parent) + link.symlink_to(rel_target) + log.debug("Symlinked %s -> %s", link, rel_target)