Compare commits

..

3 commits

11 changed files with 152 additions and 29 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
@ -99,6 +106,13 @@ class ZonaBuilder:
else date.min,
reverse=True,
)
posts = len(post_list)
for i, item in enumerate(post_list):
prev = post_list[i - 1] if i > 0 else None
next = post_list[i + 1] if i < posts - 2 else None
item.previous = prev
item.next = next
templater = Templater(
config=self.config,
template_dir=self.layout.templates,
@ -111,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():
@ -152,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

@ -31,6 +31,18 @@ header {
text-transform: lowercase;
}
.post-nav {
font-family: monospace;
}
.post-nav .symbol {
color: var(--main-bullet-color);
}
.post-nav a {
margin: 0 2px;
}
.site-logo {
color: inherit;
font-weight: bold;

View file

@ -1,18 +1,12 @@
{% extends "base.html" %} {% block content %}
{% if metadata.show_title %}
{% include "title.html" %}
{% if metadata.date %}
{% extends "base.html" %} {% block content %} {% if metadata.show_title %} {%
include "title.html" %} {% if metadata.date %}
<center>
<time class="post-date" datetime="{{ metadata.date | safe }}"
>{{ metadata.date | safe}}</time>
</center>
{% endif %}
{% endif %} {% endif %} {% if is_post %} {% include "post_nav.html" %} {% endif
%}
<hr>
{% endif %}
<article>{{ content | safe }}</article>
{% endblock %}

View file

@ -0,0 +1,9 @@
<div class="post-nav">
<center>
<span class="symbol">&lt;</span>{% if previous %}<a
href="{{ previous.url }}"
>prev</a>{% endif %}{% if previous and next %}<span class="symbol"
>|</span>{% endif %}{% if next %}<a href="{{ next.url }}">next</a>{% endif
%}<span class="symbol">&gt;</span>
</center>
</div>

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

@ -18,6 +18,8 @@ class Metadata:
date: date
description: str | None
show_title: bool = True
show_date: bool = True
show_nav: bool = True
style: str | None = "/static/style.css"
header: bool = True
footer: bool = True

View file

@ -1,3 +1,5 @@
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
@ -21,6 +23,8 @@ class Item:
type: ItemType | None = None
copy: bool = True
post: bool = False
next: Item | None = None
previous: Item | None = None
# @dataclass

View file

@ -237,11 +237,13 @@ def serve(
observer.schedule(
event_handler, path=str(root / "content"), recursive=True
)
observer.schedule(
event_handler,
path=str(root / "templates"),
recursive=True,
)
templates = root / "templates"
if templates.is_dir():
observer.schedule(
event_handler,
path=str(templates),
recursive=True,
)
observer.start()
# function to shut down gracefully

View file

@ -3,6 +3,7 @@ from typing import Literal
from jinja2 import Environment, FileSystemLoader, select_autoescape
from zona import util
from zona.config import ZonaConfig
from zona.markdown import md_to_html
from zona.models import Item
@ -35,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"]),
@ -76,5 +78,12 @@ class Templater:
metadata=meta,
header=header,
footer=footer,
is_post=item.post,
next=util.normalize_url(item.next.url)
if item.next
else None,
previous=util.normalize_url(item.previous.url)
if item.previous
else None,
post_list=self.post_list,
)

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("_", " ")