Files
omicron-ssg/omicron/ssg/site.py
T
omicron 34df950cbd Make site building output deterministic
In order to do snapshot testing of the site output we want the output to
always be the same regardless of how the filesystem tree was created
2026-08-28 01:08:58 +02:00

238 lines
8.4 KiB
Python

import logging
import shutil
from typing import Any, Literal, cast
from urllib.parse import urlparse
from omicron.ssg.output import (
Output,
OutputError,
Content,
ContentError,
Directory,
is_content,
create_content,
File,
Index,
Tags,
Memory,
Sitemap,
Feed,
discover_index,
discover_tags,
discover_feeds,
)
from omicron.ssg.markdown import highlight_style
from pathlib import Path
import yaml
from jinja2 import Environment, FileSystemLoader
log = logging.getLogger(__name__)
class ConfigError(RuntimeError):
pass
class Site:
def __init__(self, site: Path, drafts: bool = False):
self.site_path = site.resolve()
self.drafts = drafts
self.content: list[Content] = []
self.directories: list[Directory] = []
self.other_outputs: list[Output] = []
self.by_tag: dict[str, list[Content]] = {}
self.by_path: dict[Path, Output] = {}
self.by_source: dict[Path, Content] = {}
self.by_section: dict[str, list[Content]] = {}
self.tags_by_count: list[tuple[str, int]] = []
config = self.read_config()
self.name: str = config["name"]
self.template: str = config["template"]
self.items_per_page: int = int(config.get("items_per_page", 20))
self.origin, self.base_dir = Site.parse_base_url(config.get("base_url"))
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)
try:
with open(config_path, "r") as f:
config = yaml.safe_load(f)
except FileNotFoundError:
raise ConfigError("config file missing, not a valid ossg directory")
if not isinstance(config, dict) or not all(isinstance(k, str) for k in config):
raise ConfigError("config file must be a YAML mapping with string keys")
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 cast(dict[str, Any], config)
@staticmethod
def parse_base_url(value: str | None) -> tuple[str, Path]:
if not value:
return ("", Path("/"))
parsed = urlparse(value)
if parsed.scheme and parsed.netloc:
origin = f"{parsed.scheme}://{parsed.netloc}"
elif parsed.netloc:
origin = f"//{parsed.netloc}"
else:
origin = ""
if parsed.path:
path = Path(parsed.path)
else:
path = Path("/")
return (origin, path)
@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 add_by_section(self, content: Content) -> None:
if content.section not in self.by_section:
self.by_section[content.section] = []
self.by_section[content.section].append(content)
def add_by_tag(self, content: Content) -> None:
for tag in content.tags:
if tag not in self.by_tag:
self.by_tag[tag] = []
self.by_tag[tag].append(content)
def add_by_path(self, output: Output) -> None:
if output.destination in self.by_path:
raise ContentError(
f"path '{output.destination}' is already claimed by another output"
)
new_dirs: list[Directory] = []
for parent in output.destination.parents:
if parent == Path("."):
break
if parent in self.by_path:
if not isinstance(self.by_path[parent], Directory):
raise OutputError(
f"path '{parent}' is claimed by a file but needed as a directory"
)
break
new_dirs.append(Directory(self, parent))
self.by_path[output.destination] = output
if isinstance(output, Directory):
self.directories.append(output)
for d in new_dirs:
self.by_path[d.destination] = d
self.directories.append(d)
def add_by_source(self, content: Content) -> None:
assert content.source.is_absolute(), "Expecting content to have absolute source"
self.by_source[content.source] = content
def home_url(self) -> str:
return self.base_dir.as_posix()
def url_for(self, path: str) -> str:
if Path(path).is_absolute():
raise OutputError("path must be relative")
return (self.base_dir / path).as_posix()
def section_url(self, section: str) -> str:
path = Index.build_path("section", 1, section)
return self.by_path[path].url
def tag_url(self, tag: str) -> str:
path = Index.build_path("tag", 1, tag)
return self.by_path[path].url
def tags_url(self) -> str:
return self.by_path[Tags.PATH].url
def feed_url(
self, kind: Literal["root", "section"], label: str | None
) -> str | None:
output = self.by_path.get(Feed.build_path(kind, label))
return output.url if output else None
def update_tags_by_count(self) -> None:
tags = [(tag, len(items)) for tag, items in self.by_tag.items()]
tags.sort(key=lambda x: x[1], reverse=True)
self.tags_by_count = tags
def discover_assets(self) -> None:
assets_path = self.template_path / "assets"
if not assets_path.is_dir():
return
for file in assets_path.rglob("*"):
if file.is_file():
path = Path("assets") / file.relative_to(assets_path)
asset = File(self, path, file)
self.add_by_path(asset)
self.other_outputs.append(asset)
log.debug("Discovered template asset %s", path)
def discover(self) -> None:
log.info("Discovering content...")
self.discover_assets()
pygments = Memory(self, Path("assets/pygments.css"), highlight_style())
self.other_outputs.append(pygments)
self.add_by_path(pygments)
content_path = self.site_path / "content"
files = sorted(content_path.rglob("*"))
for file in files:
if file.is_file() and is_content(file):
content = create_content(self, file)
if content.draft and not self.drafts:
log.debug("Skipping draft content %s", file)
continue
self.add_by_path(content)
self.add_by_section(content)
self.add_by_tag(content)
self.add_by_source(content)
self.content.append(content)
self.update_tags_by_count()
for index in discover_index(self):
self.add_by_path(index)
self.other_outputs.append(index)
tags_page = discover_tags(self)
self.add_by_path(tags_page)
self.other_outputs.append(tags_page)
if self.origin:
sitemap = Sitemap(self)
self.add_by_path(sitemap)
self.other_outputs.append(sitemap)
for feed in discover_feeds(self):
self.add_by_path(feed)
self.other_outputs.append(feed)
else:
log.debug("Skipping sitemap and feeds, base_url has no domain name")
log.info("Discovered %d content items", len(self.content))
def clean(self) -> None:
if not self.output_path.exists():
return
shutil.rmtree(self.output_path)
log.info("Removed %s", self.output_path)
def build(self) -> None:
if not self.origin:
log.warning(
"base_url config value is missing a domain name, "
"can't add canonical url to content"
)
if not self.base_dir.is_absolute():
raise ConfigError("base_url config value must be an absolute path")
self.discover()
outputs: list[Output] = [*self.directories, *self.content]
for output in sorted(outputs, key=lambda o: o.destination.as_posix()):
output.write()
for output in self.other_outputs:
output.write()