Implement basic article rendering

This commit is contained in:
2026-08-04 04:00:25 +02:00
parent e7c41cd68e
commit cb48dc035a
5 changed files with 211 additions and 11 deletions
+124 -2
View File
@@ -1,8 +1,130 @@
import logging 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__) log = logging.getLogger(__name__)
FRONTMATTER_CONTENT = {".md"}
CONTENT_EXTENSIONS = FRONTMATTER_CONTENT | {".yml"}
class Content:
def __init__(self, path): class ContentError(RuntimeError):
pass 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)
+38 -8
View File
@@ -1,35 +1,65 @@
import logging 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 from pathlib import Path
import yaml import yaml
from jinja2 import Environment, FileSystemLoader
from pygments.formatters import HtmlFormatter
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
class ConfigError(RuntimeError):
pass
class Site: class Site:
def __init__(self, site: Path): def __init__(self, site: Path):
self.site_path = site.absolute() self.site_path = site.absolute()
self.content: list[Content] = [] self.content: list[Content] = []
self.config: dict = {}
self.by_tag: dict[str, list[Content]] self.by_tag: dict[str, list[Content]]
self.by_uri: dict[str, 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" config_path = self.site_path / "config.yml"
log.debug("Reading config file %s", config_path) log.debug("Reading config file %s", config_path)
with open(config_path, "r") as f: 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: def discover(self) -> None:
log.info("Discovering content...") log.info("Discovering content...")
content_path = self.site_path / "content" content_path = self.site_path / "content"
for file in content_path.rglob("*"): for file in content_path.rglob("*"):
if file.is_file(): if file.is_file() and file.suffix in CONTENT_EXTENSIONS:
self.content.append(Content(file)) self.content.append(create_content(file))
log.info("Discovered %d content items", len(self.content)) log.info("Discovered %d content items", len(self.content))
def build(self): def build(self):
self.discover() 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)
+25
View File
@@ -0,0 +1,25 @@
{% extends "base.html" %}
{% block title %}{{ article.title }} — {{ site.name }}{% endblock %}
{% block main %}
<article>
<header>
<h1>{{ article.title }}</h1>
<p>
<time datetime="{{ article.created }}">{{ article.created }}</time>
{% if article.updated %}
· Updated <time datetime="{{ article.updated }}">{{ article.updated }}</time>
{% endif %}
</p>
{% if article.tags %}
<ul>
{% for tag in article.tags | sort %}
<li>{{ tag }}</li>
{% endfor %}
</ul>
{% endif %}
</header>
{{ article.content }}
</article>
{% endblock %}
+20
View File
@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}{{ site.name }}{% endblock %}</title>
<link rel="stylesheet" href="/pygments.css">
</head>
<body>
<header>
<a href="/">{{ site.name }}</a>
</header>
<main>
{% block main %}{% endblock %}
</main>
<footer>
<p>{{ site.name }}</p>
</footer>
</body>
</html>
+4 -1
View File
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "omicron-ssg" name = "omicron-ssg"
version = "0.1.0" version = "0.1.0"
requires-python = ">=3.11" requires-python = ">=3.14"
dependencies = [ dependencies = [
"Jinja2", "Jinja2",
"markdown-it-py", "markdown-it-py",
@@ -19,3 +19,6 @@ ossg = "omicron.ssg.cli:main"
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["."] where = ["."]
include = ["omicron.ssg*"] include = ["omicron.ssg*"]
[tool.setuptools.package-data]
"omicron.ssg" = ["templates/**/*"]