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
238 lines
8.4 KiB
Python
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()
|