diff --git a/src/zona/builder.py b/src/zona/builder.py index 8e48a69..3f72992 100644 --- a/src/zona/builder.py +++ b/src/zona/builder.py @@ -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() diff --git a/src/zona/cli.py b/src/zona/cli.py index d02b840..9b5d372 100644 --- a/src/zona/cli.py +++ b/src/zona/cli.py @@ -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() diff --git a/src/zona/config.py b/src/zona/config.py index 5480cea..7308f21 100644 --- a/src/zona/config.py +++ b/src/zona/config.py @@ -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) diff --git a/src/zona/layout.py b/src/zona/layout.py index 2922418..facce4b 100644 --- a/src/zona/layout.py +++ b/src/zona/layout.py @@ -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: diff --git a/src/zona/markdown.py b/src/zona/markdown.py index d31a4ef..d56cabc 100644 --- a/src/zona/markdown.py +++ b/src/zona/markdown.py @@ -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) diff --git a/src/zona/models.py b/src/zona/models.py index 4c703ed..faf402d 100644 --- a/src/zona/models.py +++ b/src/zona/models.py @@ -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) diff --git a/src/zona/util.py b/src/zona/util.py index d610ed3..c67a92a 100644 --- a/src/zona/util.py +++ b/src/zona/util.py @@ -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