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 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)
|
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue