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 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 import markdown as zmd
from zona.templates import Templater 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 zona import util
from pathlib import Path 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]: class ZonaBuilder:
""" def __init__(
Parses a file and returns parsed Metadata and its content. Defaults self, cli_root: Path | None = None, cli_output: Path | None = None
are applied for missing fields. If there is no metadata, a Metadata ):
with default values is returned. self.layout: Layout = discover_layout(cli_root, cli_output)
self.config: ZonaConfig = ZonaConfig.from_file(
Raises: self.layout.root / "config.yml"
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),
) )
except DaciteError as e: self.items: list[Item] = []
raise ValueError(f"Malformed metadata in {path}: {e}") self.item_map: dict[Path, Item] = {}
return metadata, post.content
def _discover(self):
layout = self.layout
items: list[Item] = []
def discover(layout: Layout) -> list[Item]: base = layout.root / layout.content
items: list[Item] = [] for path in base.rglob("*"):
if path.is_file() and not util.should_ignore(
base = layout.root / layout.content path, patterns=self.config.ignore, base=base
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"
): ):
item.metadata, item.content = split_metadata(path) # we only parse markdown files not in static/
item.type = ItemType.MARKDOWN destination = layout.output / path.relative_to(base)
item.copy = False item = Item(
name = destination.stem source=path,
if name == "index": destination=destination,
item.destination = item.destination.with_suffix(".html") url=str(destination.relative_to(layout.output)),
else: )
relative = path.relative_to(base).with_suffix("") if path.name.endswith(".md") and not path.is_relative_to(
name = relative.stem layout.root / "content" / "static"
item.destination = ( ):
layout.output / relative.parent / name / "index.html" 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) items.append(item)
item.url = "" if rel_url == Path(".") else rel_url.as_posix() self.items = items
items.append(item)
return 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]): def build(self):
ctx = BuildCtx(layout) self._discover()
templater = Templater(layout.templates) self._build()
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)

View file

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

View file

@ -1,4 +1,17 @@
from dataclasses import dataclass, field 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 @dataclass
@ -23,6 +36,14 @@ class ZonaConfig:
title: str = "Zona Blog" title: str = "Zona Blog"
base_url: str = "https://example.com" base_url: str = "https://example.com"
language: str = "en" 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) markdown: MarkdownConfig = field(default_factory=MarkdownConfig)
theme: ThemeConfig = field(default_factory=ThemeConfig) theme: ThemeConfig = field(default_factory=ThemeConfig)
build: BuildConfig = field(default_factory=BuildConfig) 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 pathlib import Path
from dataclasses import dataclass, asdict from dataclasses import dataclass, asdict
from zona.config import ZonaConfig from zona.config import ZonaConfig, find_config
import yaml import yaml
@ -30,16 +30,6 @@ class Layout:
return 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( def discover_layout(
cli_root: Path | None = None, cli_output: Path | None = None cli_root: Path | None = None, cli_output: Path | None = None
) -> Layout: ) -> Layout:

View file

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

View file

@ -34,7 +34,7 @@ class Item:
copy: bool = True copy: bool = True
@dataclass # @dataclass
class BuildCtx: # class BuildCtx:
layout: Layout # layout: Layout
item_map: dict[Path, Item] = field(default_factory=dict) # 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 pathlib import Path
from shutil import copy2 from shutil import copy2
import string 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): def ensure_parents(target: Path):
"""Ensure the target's parent directories exist.""" """Ensure the target's parent directories exist."""
@ -24,3 +32,38 @@ def normalize_url(url: str) -> str:
if not url.startswith("/"): if not url.startswith("/"):
url = "/" + url url = "/" + url
return 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