Implement basic article rendering
This commit is contained in:
+124
-2
@@ -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)
|
||||
|
||||
+38
-8
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user