{{ article.title }}
++ + {% if article.updated %} + · Updated + {% endif %} +
+ {% if article.tags %} +-
+ {% for tag in article.tags | sort %}
+
- {{ tag }} + {% endfor %} +
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 %}
+
+
+ {% if article.updated %}
+ · Updated
+ {% endif %}
+ {{ article.title }}
+
+ {% for tag in article.tags | sort %}
+
+ {% endif %}
+