From cb48dc035a48eb2e9b686575ef0feac2cd7fbcec Mon Sep 17 00:00:00 2001 From: omicron Date: Tue, 4 Aug 2026 04:00:25 +0200 Subject: [PATCH] Implement basic article rendering --- omicron/ssg/content.py | 126 ++++++++++++++++++++++- omicron/ssg/site.py | 46 +++++++-- omicron/ssg/templates/plain/article.html | 25 +++++ omicron/ssg/templates/plain/base.html | 20 ++++ pyproject.toml | 5 +- 5 files changed, 211 insertions(+), 11 deletions(-) create mode 100644 omicron/ssg/templates/plain/article.html create mode 100644 omicron/ssg/templates/plain/base.html diff --git a/omicron/ssg/content.py b/omicron/ssg/content.py index 39ea6be..8e47f88 100644 --- a/omicron/ssg/content.py +++ b/omicron/ssg/content.py @@ -1,8 +1,130 @@ import logging +import re +from abc import ABC, abstractmethod +from pathlib import Path +from datetime import date +import typing + +if typing.TYPE_CHECKING: + from omicron.ssg.site import Site + +import yaml +from markupsafe import Markup +from markdown_it import MarkdownIt +from pygments import highlight as pygments_highlight +from pygments.formatters import HtmlFormatter +from pygments.lexers import get_lexer_by_name +from pygments.lexers.special import TextLexer log = logging.getLogger(__name__) +FRONTMATTER_CONTENT = {".md"} +CONTENT_EXTENSIONS = FRONTMATTER_CONTENT | {".yml"} -class Content: - def __init__(self, path): + +class ContentError(RuntimeError): + pass + + +def _highlight_code(code: str, lang: str, attrs: str) -> str: + try: + lexer = get_lexer_by_name(lang) if lang else TextLexer() + except Exception: + lexer = TextLexer() + return pygments_highlight(code, lexer, HtmlFormatter()) + + +def read_frontmatter(path) -> dict: + lines = [] + with path.open("r", encoding="utf-8") as f: + if f.readline() != "---\n": + raise ContentError(f"Frontmatter opening delimiter not found for {path}") + while (line := f.readline()) != "---\n": + if line == "": + raise ContentError(f"Frontmatter closing delimiter not found for {path}") + lines.append(line) + return yaml.safe_load("".join(lines)) + + +def create_content(path) -> Content: + if path.suffix in FRONTMATTER_CONTENT: + meta = read_frontmatter(path) + elif path.suffix == ".yml": + meta = yaml.safe_load(path.read_text()) + else: + raise ContentError(f"Unhandled file type for '{path}'") + + if meta["type"] == "article": + return Article(path, meta) + else: + raise ContentError(f"Invalid type '{meta['type']}' for '{path}'") + + +class Content(ABC): + _SECTION_RE = re.compile(r"^[a-z0-9_-]+(/[a-z0-9_-]+)*$") + + def __init__( + self, + path: Path, + section: str, + slug: str, + created: date, + updated: date | None = None, + tags: set[str] | None = None, + ): + if tags is None: + tags = set() + self.source = path + self.section: str = section + self.created = created + self.updated = updated + self.tags: set[str] = tags + self.uri: str = self.build_uri(section, slug) + + @staticmethod + def build_uri(section: str, slug: str) -> str: + if not Content._SECTION_RE.match(section): + raise ContentError(f"Invalid section '{section}'") + uri = f"{section}/{slug}.html" + return uri + + def read_body(self) -> str: + with self.source.open("r", encoding="utf-8") as f: + f.readline() # opening --- + while (line := f.readline()) != "---\n": + if line == "": + raise ContentError(f"Frontmatter closing delimiter not found for {self.source}") + return f.read() + + @abstractmethod + def render(self, site: Site) -> None: pass + + +class Article(Content): + def __init__(self, path: Path, meta: dict): + if "tags" not in meta: + log.warning("no tags for article in '%s'", path) + super().__init__( + path, + meta["section"], + meta["slug"], + meta["date"], + meta.get("updated", None), + meta.get("tags", None), + ) + self.title: str = meta["title"] + self.content: Markup = Markup("") + + def render(self, site: Site) -> None: + md = MarkdownIt() + md.options["highlight"] = _highlight_code + self.content = Markup(md.render(self.read_body())) + + template = site.jinja_env.get_template("article.html") + html = template.render(article=self, site=site) + + out_path = site.output_path / self.uri + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(html, encoding="utf-8") + log.debug("Rendered %s -> %s", self.source, out_path) diff --git a/omicron/ssg/site.py b/omicron/ssg/site.py index b4d7b2c..645defe 100644 --- a/omicron/ssg/site.py +++ b/omicron/ssg/site.py @@ -1,35 +1,65 @@ import logging +from typing import Any -from omicron.ssg.content import Content +from omicron.ssg.content import Content, create_content, CONTENT_EXTENSIONS from pathlib import Path import yaml +from jinja2 import Environment, FileSystemLoader +from pygments.formatters import HtmlFormatter log = logging.getLogger(__name__) +class ConfigError(RuntimeError): + pass + + class Site: def __init__(self, site: Path): self.site_path = site.absolute() self.content: list[Content] = [] - self.config: dict = {} self.by_tag: dict[str, list[Content]] self.by_uri: dict[str, Content] - self.read_config() + self.by_section: dict[str, Content] - def read_config(self) -> None: + config = self.read_config() + self.name: str = config["name"] + self.template: str = config["template"] + self.template_path: Path = Site.resolve_template_path(self.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) with open(config_path, "r") as f: - self.config = yaml.safe_load(f) + config = yaml.safe_load(f) + 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 config + + @staticmethod + def resolve_template_path(template: str) -> Path: + path = Path(__file__).parent / "templates" / template + if not path.is_dir(): + raise ConfigError(f"template '{template}' not found at {path}") + return path def discover(self) -> None: log.info("Discovering content...") content_path = self.site_path / "content" for file in content_path.rglob("*"): - if file.is_file(): - self.content.append(Content(file)) + if file.is_file() and file.suffix in CONTENT_EXTENSIONS: + self.content.append(create_content(file)) log.info("Discovered %d content items", len(self.content)) def build(self): self.discover() - pass + self.output_path.mkdir(parents=True, exist_ok=True) + (self.output_path / "pygments.css").write_text( + HtmlFormatter().get_style_defs(".highlight"), encoding="utf-8" + ) + for content in self.content: + content.render(self) diff --git a/omicron/ssg/templates/plain/article.html b/omicron/ssg/templates/plain/article.html new file mode 100644 index 0000000..b160862 --- /dev/null +++ b/omicron/ssg/templates/plain/article.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} + +{% block title %}{{ article.title }} — {{ site.name }}{% endblock %} + +{% block main %} +
+
+

{{ article.title }}

+

+ + {% if article.updated %} + · Updated + {% endif %} +

+ {% if article.tags %} +
    + {% for tag in article.tags | sort %} +
  • {{ tag }}
  • + {% endfor %} +
+ {% endif %} +
+ {{ article.content }} +
+{% endblock %} diff --git a/omicron/ssg/templates/plain/base.html b/omicron/ssg/templates/plain/base.html new file mode 100644 index 0000000..60cb912 --- /dev/null +++ b/omicron/ssg/templates/plain/base.html @@ -0,0 +1,20 @@ + + + + + + {% block title %}{{ site.name }}{% endblock %} + + + +
+ {{ site.name }} +
+
+ {% block main %}{% endblock %} +
+ + + diff --git a/pyproject.toml b/pyproject.toml index e2c020e..d2444d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "omicron-ssg" version = "0.1.0" -requires-python = ">=3.11" +requires-python = ">=3.14" dependencies = [ "Jinja2", "markdown-it-py", @@ -19,3 +19,6 @@ ossg = "omicron.ssg.cli:main" [tool.setuptools.packages.find] where = ["."] include = ["omicron.ssg*"] + +[tool.setuptools.package-data] +"omicron.ssg" = ["templates/**/*"]