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,
`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 |
| ---------- | ------------------------------------------------------ |

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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"<TempDir {self.path}>"
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("_", " ")