From 1f2b7368156fb2ec6e2f2c2bb1523181e969013c Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Mon, 14 Jul 2025 21:20:50 -0400 Subject: [PATCH] feat: merge user templates with defaults --- README.md | 11 ++++++----- src/zona/builder.py | 24 ++++++++++++++++++------ src/zona/layout.py | 41 +++++++++++++++++++++++++++++++++++++++-- src/zona/templates.py | 5 ++++- src/zona/util.py | 36 +++++++++++++++++++++++++++++++++++- 5 files changed, 102 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index d46a69b..bd7eb9b 100644 --- a/README.md +++ b/README.md @@ -172,8 +172,8 @@ public/ The **root** of the zona **project** _must_ contain the configuration file, `config.yml`, and a directory called `content`. A directory called `templates` -is optional, and prioritized if it exists. `public` is the built site output — -it's recommended to add this path to your `.gitignore`. +is optional, and merged with the defaults if it exists. `public` is the built +site output — it's recommended to add this path to your `.gitignore`. The `content` directory is the **root of the website**. Think of it as the **content root**. For example, suppose your website is hosted at `example.com`. @@ -191,9 +191,10 @@ site using the `post_list` template. ### Templates The `templates` directory may contain any `jinja2` template files. You may -modify the existing templates or create your own. To apply a certain template to -a page, set the `template` option in its [frontmatter](#frontmatter). The -following public variables are made available to the template engine: +modify the existing templates or create your own. Your templates are merged with +the packaged defaults. To apply a certain template to a page, set the `template` +option in its [frontmatter](#frontmatter). The following public variables are +made available to the template engine: | Name | Description | | ---------- | ------------------------------------------------------ | diff --git a/src/zona/builder.py b/src/zona/builder.py index c53701e..5c8af6b 100644 --- a/src/zona/builder.py +++ b/src/zona/builder.py @@ -30,6 +30,7 @@ class ZonaBuilder: self.config.build.include_drafts = True self.items: list[Item] = [] self.item_map: dict[Path, Item] = {} + self.fresh: bool = True def _discover(self): layout = self.layout @@ -48,7 +49,9 @@ class ZonaBuilder: destination=destination, url=str(destination.relative_to(layout.output)), ) - if path.name.endswith(".md") and not path.is_relative_to( + if path.name.endswith( + ".md" + ) and not path.is_relative_to( layout.root / "content" / "static" ): logger.debug(f"Parsing {path.name}.") @@ -69,11 +72,13 @@ class ZonaBuilder: item.copy = False name = destination.stem if name == "index": - item.destination = item.destination.with_suffix( - ".html" + item.destination = ( + item.destination.with_suffix(".html") ) else: - relative = path.relative_to(base).with_suffix("") + relative = path.relative_to(base).with_suffix( + "" + ) name = relative.stem item.destination = ( layout.output @@ -85,7 +90,9 @@ class ZonaBuilder: layout.output ) item.url = ( - "" if rel_url == Path(".") else rel_url.as_posix() + "" + if rel_url == Path(".") + else rel_url.as_posix() ) items.append(item) self.items = items @@ -118,7 +125,9 @@ class ZonaBuilder: # write code highlighting stylesheet if self.config.markdown.syntax_highlighting.enabled: pygments_style = zmd.get_style_defs(self.config) - pygments_path = self.layout.output / "static" / "pygments.css" + pygments_path = ( + self.layout.output / "static" / "pygments.css" + ) util.ensure_parents(pygments_path) pygments_path.write_text(pygments_style) for item in self.item_map.values(): @@ -159,7 +168,10 @@ class ZonaBuilder: child.unlink() elif child.is_dir(): shutil.rmtree(child) + if not self.fresh: + self.layout = self.layout.refresh() logger.debug("Discovering...") self._discover() logger.debug("Building...") self._build() + self.fresh = False diff --git a/src/zona/layout.py b/src/zona/layout.py index 3526c4e..3d8de3d 100644 --- a/src/zona/layout.py +++ b/src/zona/layout.py @@ -16,6 +16,22 @@ class Layout: content: Path templates: Path output: Path + shared_templates: util.TempDir | None + _validate: bool + + def refresh(self): + logger.debug("Refreshing layout...") + if ( + self.shared_templates + and not self.shared_templates.removed + ): + logger.debug("Removing stale templates tempdir...") + self.shared_templates.remove() + return self.__class__.from_input( + root=self.root, + output=self.output, + validate=self._validate, + ) @classmethod def from_input( @@ -31,6 +47,8 @@ class Layout: output=(root / "public").resolve() if not output else output, + shared_templates=None, + _validate=validate, ) if validate: logger.debug("Validating site layout...") @@ -39,10 +57,29 @@ class Layout: raise FileNotFoundError( "Missing required content directory!" ) - if not layout.templates.is_dir(): + internal_templates = util.get_resource_dir("templates") + user_templates = layout.templates + if not user_templates.is_dir() or util.is_empty( + user_templates + ): logger.debug("Using default template directory.") # use the included defaults - layout.templates = util.get_resource_dir("templates") + layout.templates = internal_templates + else: + seen: set[str] = set() + temp = util.TempDir() + logger.debug( + f"Creating shared template directory at {temp}" + ) + for f in user_templates.iterdir(): + if f.is_file(): + util.copy_static_file(f, temp.path) + seen.add(f.name) + for f in internal_templates.iterdir(): + if f.is_file() and f.name not in seen: + util.copy_static_file(f, temp.path) + layout.shared_templates = temp + layout.templates = temp.path return layout diff --git a/src/zona/templates.py b/src/zona/templates.py index fe1e6dd..d5e0f22 100644 --- a/src/zona/templates.py +++ b/src/zona/templates.py @@ -36,6 +36,7 @@ class Templater: template_dir: Path, post_list: list[Item], ): + # build temporary template dir self.env: Environment = Environment( loader=FileSystemLoader(template_dir), autoescape=select_autoescape(["html", "xml"]), @@ -78,7 +79,9 @@ class Templater: header=header, footer=footer, is_post=item.post, - next=util.normalize_url(item.next.url) if item.next else None, + next=util.normalize_url(item.next.url) + if item.next + else None, previous=util.normalize_url(item.previous.url) if item.previous else None, diff --git a/src/zona/util.py b/src/zona/util.py index 9ccc2c6..5bf6f53 100644 --- a/src/zona/util.py +++ b/src/zona/util.py @@ -1,11 +1,36 @@ import fnmatch import re +import shutil import string +import tempfile +import weakref from importlib import resources from importlib.resources.abc import Traversable from pathlib import Path from shutil import copy2 -from typing import NamedTuple +from typing import Any, NamedTuple, override + + +class TempDir: + """Temporary directory that cleans up when it's garbage collected.""" + + def __init__(self): + self._tempdir: str = tempfile.mkdtemp() + self.path: Path = Path(self._tempdir) + self._finalizer: weakref.finalize[Any, Any] = ( + weakref.finalize(self, shutil.rmtree, self._tempdir) + ) + + def remove(self): + self._finalizer() + + @property + def removed(self): + return not self._finalizer.alive + + @override + def __repr__(self) -> str: + return f"" class ZonaResource(NamedTuple): @@ -65,6 +90,15 @@ def copy_static_file(src: Path, dst: Path): copy2(src, dst) +def is_empty(path: Path) -> bool: + """If given a file, check if it has any non-whitespace content. + If given a directory, check if it has any children.""" + if path.is_file(): + return path.read_text().strip() == "" + else: + return not any(path.iterdir()) + + def filename_to_title(path: Path) -> str: name = path.stem words = name.replace("-", " ").replace("_", " ")