refactor builder

This commit is contained in:
Daniel Fichtinger 2025-06-23 22:06:36 -04:00
parent 8fe55bf3f4
commit 42a8b51914
7 changed files with 168 additions and 117 deletions

View file

@ -1,102 +1,92 @@
from rich import print
from zona.models import Item, Metadata, ItemType, BuildCtx
from zona.models import Item, ItemType
from zona import markdown as zmd
from zona.templates import Templater
from zona.layout import Layout
from zona.layout import Layout, discover_layout
from zona.config import ZonaConfig
from zona import util
from pathlib import Path
import frontmatter
from dacite import Config, from_dict, DaciteError
from datetime import date
from yaml import YAMLError
def split_metadata(path: Path) -> tuple[Metadata, str]:
"""
Parses a file and returns parsed Metadata and its content. Defaults
are applied for missing fields. If there is no metadata, a Metadata
with default values is returned.
Raises:
ValueError: If the metadata block is malformed in any way.
"""
try:
post = frontmatter.load(str(path))
except YAMLError as e:
raise ValueError(f"YAML frontmatter error in {path}: {e}")
raw_meta = post.metadata or {}
defaults = {
"title": util.filename_to_title(path),
"date": date.fromtimestamp(path.stat().st_mtime),
}
meta = {**defaults, **raw_meta}
try:
metadata = from_dict(
data_class=Metadata,
data=meta,
config=Config(check_types=True, strict=True),
class ZonaBuilder:
def __init__(
self, cli_root: Path | None = None, cli_output: Path | None = None
):
self.layout: Layout = discover_layout(cli_root, cli_output)
self.config: ZonaConfig = ZonaConfig.from_file(
self.layout.root / "config.yml"
)
except DaciteError as e:
raise ValueError(f"Malformed metadata in {path}: {e}")
return metadata, post.content
self.items: list[Item] = []
self.item_map: dict[Path, Item] = {}
def _discover(self):
layout = self.layout
items: list[Item] = []
def discover(layout: Layout) -> list[Item]:
items: list[Item] = []
base = layout.root / layout.content
for path in base.rglob("*"):
if path.is_file():
# we only parse markdown files not in static/
destination = layout.output / path.relative_to(base)
item = Item(
source=path,
destination=destination,
url=str(destination.relative_to(layout.output)),
)
if path.name.endswith(".md") and not path.is_relative_to(
layout.root / "content" / "static"
base = layout.root / layout.content
for path in base.rglob("*"):
if path.is_file() and not util.should_ignore(
path, patterns=self.config.ignore, base=base
):
item.metadata, item.content = split_metadata(path)
item.type = ItemType.MARKDOWN
item.copy = False
name = destination.stem
if name == "index":
item.destination = item.destination.with_suffix(".html")
else:
relative = path.relative_to(base).with_suffix("")
name = relative.stem
item.destination = (
layout.output / relative.parent / name / "index.html"
# we only parse markdown files not in static/
destination = layout.output / path.relative_to(base)
item = Item(
source=path,
destination=destination,
url=str(destination.relative_to(layout.output)),
)
if path.name.endswith(".md") and not path.is_relative_to(
layout.root / "content" / "static"
):
item.metadata, item.content = util.parse_metadata(path)
item.type = ItemType.MARKDOWN
item.copy = False
name = destination.stem
if name == "index":
item.destination = item.destination.with_suffix(".html")
else:
relative = path.relative_to(base).with_suffix("")
name = relative.stem
item.destination = (
layout.output
/ relative.parent
/ name
/ "index.html"
)
rel_url = item.destination.parent.relative_to(layout.output)
item.url = (
"" if rel_url == Path(".") else rel_url.as_posix()
)
rel_url = item.destination.parent.relative_to(layout.output)
item.url = "" if rel_url == Path(".") else rel_url.as_posix()
items.append(item)
return items
items.append(item)
self.items = items
def _build(self):
assert self.items
templater = Templater(self.layout.templates)
self.item_map = {item.source.resolve(): item for item in self.items}
# print(item_map)
for item in self.item_map.values():
dst = item.destination
# print(item)
# create parent dirs if needed
if item.type == ItemType.MARKDOWN:
assert item.content is not None
# parse markdown and render as html
raw_html = zmd.md_to_html(
content=item.content,
resolve_links=True,
source=item.source,
layout=self.layout,
item_map=self.item_map,
)
# TODO: test this
rendered = templater.render_item(item, raw_html)
util.ensure_parents(dst)
dst.write_text(rendered, encoding="utf-8")
else:
if item.copy:
util.copy_static_file(item.source, dst)
def build(layout: Layout, items: list[Item]):
ctx = BuildCtx(layout)
templater = Templater(layout.templates)
ctx.item_map = {item.source.resolve(): item for item in items}
# print(item_map)
for item in ctx.item_map.values():
dst = item.destination
# print(item)
# create parent dirs if needed
if item.type == ItemType.MARKDOWN:
assert item.content is not None
# parse markdown and render as html
raw_html = zmd.md_to_html(
content=item.content,
resolve_links=True,
source=item.source,
ctx=ctx,
)
# TODO: test this
rendered = templater.render_item(item, raw_html)
util.ensure_parents(dst)
dst.write_text(rendered, encoding="utf-8")
else:
if item.copy:
util.copy_static_file(item.source, dst)
def build(self):
self._discover()
self._build()

View file

@ -1,16 +1,16 @@
import typer
from pathlib import Path
from zona import builder, server
from zona.layout import Layout, discover_layout, initialize_site
from zona import server
from zona.builder import ZonaBuilder
from zona.layout import initialize_site
app = typer.Typer()
@app.command()
def build(root: Path | None = None, output: Path | None = None):
layout: Layout = discover_layout(root, output)
items = builder.discover(layout)
builder.build(layout, items)
builder = ZonaBuilder(root, output)
builder.build()
@app.command()

View file

@ -1,4 +1,17 @@
from dataclasses import dataclass, field
from dacite import from_dict
import yaml
from pathlib import Path
def find_config(start: Path | None = None) -> Path | None:
current = (start or Path.cwd()).resolve()
for parent in [current, *current.parents]:
candidate = parent / "zona.yml"
if candidate.is_file():
return candidate
return None
@dataclass
@ -23,6 +36,14 @@ class ZonaConfig:
title: str = "Zona Blog"
base_url: str = "https://example.com"
language: str = "en"
# list of globs relative to content that should be ignored
ignore: list[str] = field(default_factory=lambda: [".env", ".git"])
markdown: MarkdownConfig = field(default_factory=MarkdownConfig)
theme: ThemeConfig = field(default_factory=ThemeConfig)
build: BuildConfig = field(default_factory=BuildConfig)
@classmethod
def from_file(cls, path: Path) -> "ZonaConfig":
with open(path, "r") as f:
raw = yaml.safe_load(f)
return from_dict(data_class=cls, data=raw)

View file

@ -1,6 +1,6 @@
from pathlib import Path
from dataclasses import dataclass, asdict
from zona.config import ZonaConfig
from zona.config import ZonaConfig, find_config
import yaml
@ -30,16 +30,6 @@ class Layout:
return layout
def find_config(start: Path | None = None) -> Path | None:
current = (start or Path.cwd()).resolve()
for parent in [current, *current.parents]:
candidate = parent / "zona.yml"
if candidate.is_file():
return candidate
return None
def discover_layout(
cli_root: Path | None = None, cli_output: Path | None = None
) -> Layout:

View file

@ -4,9 +4,10 @@ from pathlib import Path
from marko.inline import Link, Image
from marko.html_renderer import HTMLRenderer
from marko.parser import Parser
from zona.layout import Layout
from zona.models import BuildCtx
from zona import util
from zona.models import Item
class ZonaRenderer(HTMLRenderer):
@ -14,16 +15,19 @@ class ZonaRenderer(HTMLRenderer):
self,
resolve: bool = False,
source: Path | None = None,
ctx: BuildCtx | None = None,
layout: Layout | None = None,
item_map: dict[Path, Item] | None = None,
):
super().__init__()
self.resolve: bool = resolve
if self.resolve:
# print("Resolve is set")
assert source is not None
assert ctx is not None
assert layout is not None
assert item_map is not None
self.source: Path = source.resolve()
self.ctx: BuildCtx = ctx
self.layout: Layout = layout
self.item_map: dict[Path, Item] = item_map
@override
def render_link(self, element: Link):
@ -36,14 +40,14 @@ class ZonaRenderer(HTMLRenderer):
if href.startswith("/"):
# resolve relative to content root
resolved = (
self.ctx.layout.content / cur.relative_to("/")
self.layout.content / cur.relative_to("/")
).resolve()
else:
# treat as relative link and try to resolve
resolved = (self.source.parent / cur).resolve()
# only substitute if link points to an actual file
if resolved.exists():
item = self.ctx.item_map.get(resolved)
item = self.item_map.get(resolved)
if item:
href = util.normalize_url(item.url)
print(
@ -75,13 +79,16 @@ def md_to_html(
content: str,
resolve_links: bool = False,
source: Path | None = None,
ctx: BuildCtx | None = None,
layout: Layout | None = None,
item_map: dict[Path, Item] | None = None,
) -> str:
if resolve_links and (source is None or ctx is None):
if resolve_links and (source is None or layout is None or item_map is None):
raise TypeError(
"md_to_html() missing source and ctx when resolve_links is true"
)
parser = Parser()
ast = parser.parse(content)
renderer = ZonaRenderer(resolve_links, source, ctx)
renderer = ZonaRenderer(
resolve_links, source, layout=layout, item_map=item_map
)
return renderer.render(ast)

View file

@ -34,7 +34,7 @@ class Item:
copy: bool = True
@dataclass
class BuildCtx:
layout: Layout
item_map: dict[Path, Item] = field(default_factory=dict)
# @dataclass
# class BuildCtx:
# layout: Layout
# item_map: dict[Path, Item] = field(default_factory=dict)

View file

@ -1,7 +1,15 @@
from datetime import date
import fnmatch
from pathlib import Path
from shutil import copy2
import string
from dacite import Config, DaciteError, from_dict
import frontmatter
from yaml import YAMLError
from zona.models import Metadata
def ensure_parents(target: Path):
"""Ensure the target's parent directories exist."""
@ -24,3 +32,38 @@ def normalize_url(url: str) -> str:
if not url.startswith("/"):
url = "/" + url
return url
def should_ignore(path: Path, patterns: list[str], base: Path) -> bool:
rel_path = path.relative_to(base)
return any(fnmatch.fnmatch(str(rel_path), pattern) for pattern in patterns)
def parse_metadata(path: Path) -> tuple[Metadata, str]:
"""
Parses a file and returns parsed Metadata and its content. Defaults
are applied for missing fields. If there is no metadata, a Metadata
with default values is returned.
Raises:
ValueError: If the metadata block is malformed in any way.
"""
try:
post = frontmatter.load(str(path))
except YAMLError as e:
raise ValueError(f"YAML frontmatter error in {path}: {e}")
raw_meta = post.metadata or {}
defaults = {
"title": filename_to_title(path),
"date": date.fromtimestamp(path.stat().st_mtime),
}
meta = {**defaults, **raw_meta}
try:
metadata = from_dict(
data_class=Metadata,
data=meta,
config=Config(check_types=True, strict=True),
)
except DaciteError as e:
raise ValueError(f"Malformed metadata in {path}: {e}")
return metadata, post.content