refactor builder
This commit is contained in:
parent
8fe55bf3f4
commit
42a8b51914
7 changed files with 168 additions and 117 deletions
|
@ -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()
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue