Implement basic article rendering
This commit is contained in:
+124
-2
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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
@@ -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/**/*"]
|
||||||
|
|||||||
Reference in New Issue
Block a user