diff --git a/omicron/ssg/cli.py b/omicron/ssg/cli.py index 1ce5473..063e7c6 100644 --- a/omicron/ssg/cli.py +++ b/omicron/ssg/cli.py @@ -2,7 +2,8 @@ import argparse import logging import sys from omicron.ssg.output import OutputError -from omicron.ssg.site import ConfigError, Site +from omicron.ssg.site import Site +from omicron.ssg.config import ConfigError from pathlib import Path diff --git a/omicron/ssg/config.py b/omicron/ssg/config.py new file mode 100644 index 0000000..92432c2 --- /dev/null +++ b/omicron/ssg/config.py @@ -0,0 +1,98 @@ +from dataclasses import dataclass +from pathlib import Path +import logging +import yaml +from urllib.parse import urlparse + +log = logging.getLogger(__name__) + + +class ConfigError(RuntimeError): + pass + + +DEFAULT_ITEMS_PER_PAGE = 20 + + +@dataclass +class SiteConfig: + name: str + origin: str + base_dir: Path + items_per_page: int + template: str + + @staticmethod + def parse_base_url(value: str | None) -> tuple[str, Path]: + if not value: + return ("", Path("/")) + try: + parsed = urlparse(value) + except ValueError as e: + raise ConfigError("invalid url format in base_url") from e + if parsed.scheme and parsed.netloc: + origin = f"{parsed.scheme}://{parsed.netloc}" + elif parsed.netloc: + origin = f"//{parsed.netloc}" + else: + origin = "" + + if parsed.path: + path = Path(parsed.path) + else: + path = Path("/") + + return (origin, path) + + @staticmethod + def from_file(path: Path) -> SiteConfig: + log.debug("Reading config file %s", path) + try: + with open(path, "r") as f: + config = yaml.safe_load(f) + except FileNotFoundError as e: + raise ConfigError("config file missing, not a valid ossg directory") from e + except yaml.YAMLError as e: + raise ConfigError("config file must be a valid YAML file") from e + if not isinstance(config, dict): + raise ConfigError( + "config file must be a YAML mapping with string keys and int/str values" + ) + for k, v in config.items(): + if type(k) != str or type(v) not in [str, int]: + raise ConfigError( + "config file must be a YAML mapping with string keys and int/str values" + ) + return SiteConfig.from_dict(config) + + @staticmethod + def from_dict(config: dict[str, str | int]) -> SiteConfig: + name = config.get("name") + template = config.get("template") + base_url = config.get("base_url") + items_per_page = config.get("items_per_page", DEFAULT_ITEMS_PER_PAGE) + + if name is None: + raise ConfigError("config file must contain a name property") + if template is None: + raise ConfigError("config file must contain a template property") + if base_url is not None and not isinstance(base_url, str): + raise ConfigError("base_url config property must be a string") + if not isinstance(name, str): + raise ConfigError("name config property must be a string") + if not isinstance(template, str): + raise ConfigError("template config property must be a string") + if not isinstance(items_per_page, int) or items_per_page < 1: + raise ConfigError( + "items_per_page config property must be a positive integer" + ) + + origin, base_dir = SiteConfig.parse_base_url(base_url) + + return SiteConfig( + name=name, + origin=origin, + base_dir=base_dir, + items_per_page=items_per_page, + template=template, + ) diff --git a/omicron/ssg/output/directory.py b/omicron/ssg/output/directory.py index d02a333..bbfe11c 100644 --- a/omicron/ssg/output/directory.py +++ b/omicron/ssg/output/directory.py @@ -14,7 +14,7 @@ else: class Directory(Output): def __init__(self, site: Site, destination: Path): super().__init__(site, destination) - url_path = Path("/") / site.base_dir / destination + url_path = Path("/") / site.config.base_dir / destination # rebuild url because Output strips /index.html which is a valid dir self.url = url_path.as_posix() diff --git a/omicron/ssg/output/feed.py b/omicron/ssg/output/feed.py index 1a0b533..30bc033 100644 --- a/omicron/ssg/output/feed.py +++ b/omicron/ssg/output/feed.py @@ -46,8 +46,9 @@ class Feed(Output): return Path(label) / file def write(self) -> None: - feed_url = f"{self.site.origin}{self.url}" - title = f"{self.site.name} — {self.label}" if self.label else self.site.name + config = self.site.config + feed_url = f"{config.origin}{self.url}" + title = f"{config.name} — {self.label}" if self.label else config.name updated = max( (c.updated or c.created for c in self.items), default=date.today() ) @@ -60,7 +61,7 @@ class Feed(Output): for item in self.items: entry = SubElement(feed, "entry") - item_url = f"{self.site.origin}{item.url}" + item_url = f"{config.origin}{item.url}" SubElement(entry, "title").text = item.title SubElement(entry, "id").text = item_url SubElement(entry, "link", href=item_url) diff --git a/omicron/ssg/output/index.py b/omicron/ssg/output/index.py index c831742..c5ca4ba 100644 --- a/omicron/ssg/output/index.py +++ b/omicron/ssg/output/index.py @@ -89,14 +89,13 @@ def make_index_pages( def discover_index(site: Site) -> list[Index]: + items_per_page = site.config.items_per_page outputs: list[Index] = [] - outputs.extend( - make_index_pages(site, site.content, "root", None, site.items_per_page) - ) + outputs.extend(make_index_pages(site, site.content, "root", None, items_per_page)) for section, items in site.by_section.items(): outputs.extend( - make_index_pages(site, items, "section", section, site.items_per_page) + make_index_pages(site, items, "section", section, items_per_page) ) for tag, items in site.by_tag.items(): - outputs.extend(make_index_pages(site, items, "tag", tag, site.items_per_page)) + outputs.extend(make_index_pages(site, items, "tag", tag, items_per_page)) return outputs diff --git a/omicron/ssg/output/output.py b/omicron/ssg/output/output.py index 1f3f38b..d3ddfb6 100644 --- a/omicron/ssg/output/output.py +++ b/omicron/ssg/output/output.py @@ -26,7 +26,7 @@ class Output(ABC): raise OutputError("destination path must be normalized") self.destination = destination - url = site.base_dir / destination + url = site.config.base_dir / destination if url.name == "index.html": url = url.parent self.url = url.as_posix() diff --git a/omicron/ssg/output/sitemap.py b/omicron/ssg/output/sitemap.py index 78641fc..c1ff225 100644 --- a/omicron/ssg/output/sitemap.py +++ b/omicron/ssg/output/sitemap.py @@ -27,7 +27,7 @@ class Sitemap(Output): continue url = SubElement(urlset, "url") - SubElement(url, "loc").text = f"{self.site.origin}{entry.url}" + SubElement(url, "loc").text = f"{self.site.config.origin}{entry.url}" if isinstance(entry, Content): lastmod = entry.updated or entry.created SubElement(url, "lastmod").text = lastmod.isoformat() diff --git a/omicron/ssg/site.py b/omicron/ssg/site.py index 1838e07..f944d2d 100644 --- a/omicron/ssg/site.py +++ b/omicron/ssg/site.py @@ -1,7 +1,6 @@ import logging import shutil -from typing import Any, Literal, cast -from urllib.parse import urlparse +from typing import Literal from omicron.ssg.output import ( Output, @@ -22,18 +21,14 @@ from omicron.ssg.output import ( discover_feeds, ) from omicron.ssg.markdown import highlight_style +from omicron.ssg.config import SiteConfig, ConfigError from pathlib import Path -import yaml from jinja2 import Environment, FileSystemLoader log = logging.getLogger(__name__) -class ConfigError(RuntimeError): - pass - - class Site: def __init__(self, site: Path, drafts: bool = False): self.site_path = site.resolve() @@ -47,50 +42,11 @@ class Site: self.by_section: dict[str, list[Content]] = {} self.tags_by_count: list[tuple[str, int]] = [] - config = self.read_config() - self.name: str = config["name"] - self.template: str = config["template"] - self.items_per_page: int = int(config.get("items_per_page", 20)) - self.origin, self.base_dir = Site.parse_base_url(config.get("base_url")) - self.template_path: Path = Site.resolve_template_path(self.template) + self.config = SiteConfig.from_file(site / "config.yml") + self.template_path: Path = Site.resolve_template_path(self.config.template) self.output_path: Path = self.site_path / "output" self.jinja_env = Environment(loader=FileSystemLoader(self.template_path)) - def read_config(self) -> dict[str, Any]: - config_path = self.site_path / "config.yml" - log.debug("Reading config file %s", config_path) - try: - with open(config_path, "r") as f: - config = yaml.safe_load(f) - except FileNotFoundError: - raise ConfigError("config file missing, not a valid ossg directory") - if not isinstance(config, dict) or not all(isinstance(k, str) for k in config): - raise ConfigError("config file must be a YAML mapping with string keys") - if "name" not in config: - raise ConfigError("name value missing from config") - if "template" not in config: - raise ConfigError("template value missing from config") - return cast(dict[str, Any], config) - - @staticmethod - def parse_base_url(value: str | None) -> tuple[str, Path]: - if not value: - return ("", Path("/")) - parsed = urlparse(value) - if parsed.scheme and parsed.netloc: - origin = f"{parsed.scheme}://{parsed.netloc}" - elif parsed.netloc: - origin = f"//{parsed.netloc}" - else: - origin = "" - - if parsed.path: - path = Path(parsed.path) - else: - path = Path("/") - - return (origin, path) - @staticmethod def resolve_template_path(template: str) -> Path: path = Path(__file__).parent / "templates" / template @@ -137,12 +93,12 @@ class Site: self.by_source[content.source] = content def home_url(self) -> str: - return self.base_dir.as_posix() + return self.config.base_dir.as_posix() def url_for(self, path: str) -> str: if Path(path).is_absolute(): raise OutputError("path must be relative") - return (self.base_dir / path).as_posix() + return (self.config.base_dir / path).as_posix() def section_url(self, section: str) -> str: path = Index.build_path("section", 1, section) @@ -204,7 +160,7 @@ class Site: tags_page = discover_tags(self) self.add_by_path(tags_page) self.other_outputs.append(tags_page) - if self.origin: + if self.config.origin: sitemap = Sitemap(self) self.add_by_path(sitemap) self.other_outputs.append(sitemap) @@ -222,12 +178,12 @@ class Site: log.info("Removed %s", self.output_path) def build(self) -> None: - if not self.origin: + if not self.config.origin: log.warning( "base_url config value is missing a domain name, " "can't add canonical url to content" ) - if not self.base_dir.is_absolute(): + if not self.config.base_dir.is_absolute(): raise ConfigError("base_url config value must be an absolute path") self.discover() outputs: list[Output] = [*self.directories, *self.content] diff --git a/omicron/ssg/templates/plain/article.html b/omicron/ssg/templates/plain/article.html index 18fa936..feb48ee 100644 --- a/omicron/ssg/templates/plain/article.html +++ b/omicron/ssg/templates/plain/article.html @@ -1,10 +1,10 @@ {% extends "base.html" %} -{% block title %}{{ page.title }} — {{ site.name }}{% endblock %} +{% block title %}{{ page.title }} — {{ site.config.name }}{% endblock %} {% block breadcrumb %} diff --git a/omicron/ssg/templates/plain/base.html b/omicron/ssg/templates/plain/base.html index c96278a..46351e3 100644 --- a/omicron/ssg/templates/plain/base.html +++ b/omicron/ssg/templates/plain/base.html @@ -3,22 +3,22 @@
-