feat: merge user templates with defaults

This commit is contained in:
Daniel Fichtinger 2025-07-14 21:20:50 -04:00
parent c489a6f076
commit 1f2b736815
5 changed files with 102 additions and 15 deletions

View file

@ -172,8 +172,8 @@ public/
The **root** of the zona **project** _must_ contain the configuration file, The **root** of the zona **project** _must_ contain the configuration file,
`config.yml`, and a directory called `content`. A directory called `templates` `config.yml`, and a directory called `content`. A directory called `templates`
is optional, and prioritized if it exists. `public` is the built site output — is optional, and merged with the defaults if it exists. `public` is the built
it's recommended to add this path to your `.gitignore`. 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 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`. **content root**. For example, suppose your website is hosted at `example.com`.
@ -191,9 +191,10 @@ site using the `post_list` template.
### Templates ### Templates
The `templates` directory may contain any `jinja2` template files. You may 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 modify the existing templates or create your own. Your templates are merged with
a page, set the `template` option in its [frontmatter](#frontmatter). The the packaged defaults. To apply a certain template to a page, set the `template`
following public variables are made available to the template engine: option in its [frontmatter](#frontmatter). The following public variables are
made available to the template engine:
| Name | Description | | Name | Description |
| ---------- | ------------------------------------------------------ | | ---------- | ------------------------------------------------------ |

View file

@ -30,6 +30,7 @@ class ZonaBuilder:
self.config.build.include_drafts = True self.config.build.include_drafts = True
self.items: list[Item] = [] self.items: list[Item] = []
self.item_map: dict[Path, Item] = {} self.item_map: dict[Path, Item] = {}
self.fresh: bool = True
def _discover(self): def _discover(self):
layout = self.layout layout = self.layout
@ -48,7 +49,9 @@ class ZonaBuilder:
destination=destination, destination=destination,
url=str(destination.relative_to(layout.output)), 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" layout.root / "content" / "static"
): ):
logger.debug(f"Parsing {path.name}.") logger.debug(f"Parsing {path.name}.")
@ -69,11 +72,13 @@ class ZonaBuilder:
item.copy = False item.copy = False
name = destination.stem name = destination.stem
if name == "index": if name == "index":
item.destination = item.destination.with_suffix( item.destination = (
".html" item.destination.with_suffix(".html")
) )
else: else:
relative = path.relative_to(base).with_suffix("") relative = path.relative_to(base).with_suffix(
""
)
name = relative.stem name = relative.stem
item.destination = ( item.destination = (
layout.output layout.output
@ -85,7 +90,9 @@ class ZonaBuilder:
layout.output layout.output
) )
item.url = ( item.url = (
"" if rel_url == Path(".") else rel_url.as_posix() ""
if rel_url == Path(".")
else rel_url.as_posix()
) )
items.append(item) items.append(item)
self.items = items self.items = items
@ -118,7 +125,9 @@ class ZonaBuilder:
# write code highlighting stylesheet # write code highlighting stylesheet
if self.config.markdown.syntax_highlighting.enabled: if self.config.markdown.syntax_highlighting.enabled:
pygments_style = zmd.get_style_defs(self.config) 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) util.ensure_parents(pygments_path)
pygments_path.write_text(pygments_style) pygments_path.write_text(pygments_style)
for item in self.item_map.values(): for item in self.item_map.values():
@ -159,7 +168,10 @@ class ZonaBuilder:
child.unlink() child.unlink()
elif child.is_dir(): elif child.is_dir():
shutil.rmtree(child) shutil.rmtree(child)
if not self.fresh:
self.layout = self.layout.refresh()
logger.debug("Discovering...") logger.debug("Discovering...")
self._discover() self._discover()
logger.debug("Building...") logger.debug("Building...")
self._build() self._build()
self.fresh = False

View file

@ -16,6 +16,22 @@ class Layout:
content: Path content: Path
templates: Path templates: Path
output: 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 @classmethod
def from_input( def from_input(
@ -31,6 +47,8 @@ class Layout:
output=(root / "public").resolve() output=(root / "public").resolve()
if not output if not output
else output, else output,
shared_templates=None,
_validate=validate,
) )
if validate: if validate:
logger.debug("Validating site layout...") logger.debug("Validating site layout...")
@ -39,10 +57,29 @@ class Layout:
raise FileNotFoundError( raise FileNotFoundError(
"Missing required content directory!" "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.") logger.debug("Using default template directory.")
# use the included defaults # 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 return layout

View file

@ -36,6 +36,7 @@ class Templater:
template_dir: Path, template_dir: Path,
post_list: list[Item], post_list: list[Item],
): ):
# build temporary template dir
self.env: Environment = Environment( self.env: Environment = Environment(
loader=FileSystemLoader(template_dir), loader=FileSystemLoader(template_dir),
autoescape=select_autoescape(["html", "xml"]), autoescape=select_autoescape(["html", "xml"]),
@ -78,7 +79,9 @@ class Templater:
header=header, header=header,
footer=footer, footer=footer,
is_post=item.post, 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) previous=util.normalize_url(item.previous.url)
if item.previous if item.previous
else None, else None,

View file

@ -1,11 +1,36 @@
import fnmatch import fnmatch
import re import re
import shutil
import string import string
import tempfile
import weakref
from importlib import resources from importlib import resources
from importlib.resources.abc import Traversable from importlib.resources.abc import Traversable
from pathlib import Path from pathlib import Path
from shutil import copy2 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"<TempDir {self.path}>"
class ZonaResource(NamedTuple): class ZonaResource(NamedTuple):
@ -65,6 +90,15 @@ def copy_static_file(src: Path, dst: Path):
copy2(src, dst) 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: def filename_to_title(path: Path) -> str:
name = path.stem name = path.stem
words = name.replace("-", " ").replace("_", " ") words = name.replace("-", " ").replace("_", " ")