Files
omicron-ssg/omicron/ssg/site.py
T
omicron 58eebac637 Add Site reference to Output, clean up template url handling
The following changes have been made all with template cleanup in mind:
- Make Output.url relative to the origin root
- Keep weakref to Site in all Output objects.
- Add several helpers to get urls from Site: home_url(), tag_url(tag),
  section_url(section) and tags_url().
- Add helpers to Index to get pagination urls: pagination_url(n)
- Clean up templates by making use of these new facilities
2026-08-14 16:45:16 +02:00

171 lines
6.0 KiB
Python

import logging
from typing import Any, cast
from urllib.parse import urlparse
from omicron.ssg.output import (
Output,
Content,
ContentError,
is_content,
create_content,
File,
Index,
Tags,
Memory,
discover_index,
discover_tags,
)
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):
self.site_path = site.absolute()
self.content: list[Content] = []
self.other_outputs: list[Output] = []
self.by_tag: dict[str, list[Content]] = {}
self.by_path: dict[Path, Output] = {}
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)
with open(config_path, "r") as f:
config = yaml.safe_load(f)
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, str]:
if not value:
return ("", "")
parsed = urlparse(value)
if parsed.scheme and parsed.netloc:
origin = f"{parsed.scheme}://{parsed.netloc}"
elif parsed.netloc:
origin = f"//{parsed.netloc}"
else:
origin = ""
path = parsed.path.removesuffix("/")
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"
)
self.by_path[output.destination] = output
def home_url(self) -> str:
return self.base_dir or "/"
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 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"
for file in content_path.rglob("*"):
if file.is_file() and is_content(file):
content = create_content(self, file)
self.add_by_path(content)
self.add_by_section(content)
self.add_by_tag(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)
log.info("Discovered %d content items", len(self.content))
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 self.base_dir and not self.base_dir.startswith("/"):
raise ConfigError("base_url config value must be an absolute path")
self.discover()
self.output_path.mkdir(parents=True, exist_ok=True)
for content in self.content:
content.write()
for output in self.other_outputs:
output.write()