Compare commits

...
28 Commits
Author SHA1 Message Date
omicron ab14def478 add tests for config, fix failing test on Site 2026-09-08 04:37:13 +02:00
omicron ea14de6200 Move relative path check from Site.build to SiteConfig 2026-09-08 04:02:17 +02:00
omicron 4351ea8e0d Refactor config reading code 2026-09-05 03:19:12 +02:00
omicron f991d3b110 Expand test Site tests 2026-09-04 11:06:39 +02:00
omicron 7297adb966 Expand tests for Site 2026-09-03 03:47:56 +02:00
omicron 1fe2bbd1db Consolidate test site fixture into a single make_site 2026-09-03 03:10:13 +02:00
omicron 2835b77146 Add tests for output.Output and output.File 2026-09-03 02:55:38 +02:00
omicron 088fc4d463 Exclude abstract methods from coverage reports 2026-09-03 02:55:20 +02:00
omicron 9d5387a40a Add Makefile with targets for common actions 2026-08-28 01:58:52 +02:00
omicron 82f60f3d75 Set up e2e testing 2026-08-28 01:44:38 +02:00
omicron 6afc8585db Set up pytest and coverage tooling 2026-08-28 01:15:28 +02:00
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
omicron a0fa7d8fe2 Add Site.feed_url helper and use it in the template 2026-08-26 05:07:40 +02:00
omicron f18392c899 Add Atom feed generation 2026-08-26 05:07:15 +02:00
omicron bfb0d371c8 Move title property from Article to Content 2026-08-26 05:02:23 +02:00
omicron a5de93576a Add sitemap generation 2026-08-26 04:11:56 +02:00
omicron 728b1ee1bf Add draft content support, excluded from build by default 2026-08-26 03:21:27 +02:00
omicron 48af9665f0 Improve cli, add subcommands and flags. 2026-08-26 02:36:07 +02:00
omicron 8c6c0d318e Add referenced file handling to Content 2026-08-25 03:08:07 +02:00
omicron 015f1375ec Add source index for Content, enforce stricter Content.source checks 2026-08-25 03:08:07 +02:00
omicron 3abb6ce8fe Expose tokens in markdown api, update article rendering 2026-08-25 03:08:01 +02:00
omicron 1250ead61a Change Content and Index to create pretty urls 2026-08-24 14:45:23 +02:00
omicron 7a1926113c Track url as an absolute Path and add Site.url_for helper 2026-08-24 14:34:08 +02:00
omicron 74925f1668 Automatically create parent Directory entries for each discovery
Before this change it was possible for a file and a directory have the
same path. This change resolves that. Conflicts between files and
directories are now correctly discovered.
2026-08-15 02:37:37 +02:00
omicron ce7ba6f893 Make output validate paths are absolute and normalized
Also update the checks in Symlink to use the same functions
2026-08-15 01:58:29 +02:00
omicron b51f445ed1 Add Directory(Output) class to create and track directories 2026-08-15 01:03:00 +02:00
omicron 5660ef2974 Add Symlink(Output) class 2026-08-14 23:55:48 +02:00
omicron 1a5f3bfc67 Stop rendering canonical links when origin is missing from config 2026-08-14 17:00:07 +02:00
44 changed files with 1240 additions and 95 deletions
+4
View File
@@ -2,3 +2,7 @@
__pycache__ __pycache__
*.egg-info *.egg-info
*.pyc *.pyc
.coverage
htmlcov/
tests/data/sites/*/output/
tests/data/sites/*/build.log
+29
View File
@@ -0,0 +1,29 @@
TEST_SITE := tests/data/sites/main
OUTPUT := $(TEST_SITE)/output
.PHONY: build build-drafts check test coverage clean serve
build:
OSSG_TYPECHECKED="1" ossg --site $(TEST_SITE) -v build
build-drafts:
OSSG_TYPECHECKED="1" ossg --site $(TEST_SITE) -v build --drafts
check:
mypy -p omicron.ssg
black --check omicron tests/unit tests/e2e
test:
pytest
coverage:
coverage run -m pytest tests/unit
coverage report
coverage html
@echo "file://$(CURDIR)/htmlcov/index.html"
clean:
ossg --site $(TEST_SITE) clean
serve:
python -m http.server --bind 127.0.0.1 --directory $(OUTPUT)
+89 -4
View File
@@ -1,10 +1,57 @@
import argparse
import logging import logging
import sys import sys
from omicron.ssg.output import OutputError
from omicron.ssg.site import Site from omicron.ssg.site import Site
from omicron.ssg.config import ConfigError
from pathlib import Path from pathlib import Path
def setup_logging(verbose: bool = False, log_file: str | None = None) -> None: class CliError(RuntimeError):
pass
def setup_argparse() -> argparse.ArgumentParser:
# base options
help_formatter = argparse.ArgumentDefaultsHelpFormatter
parser = argparse.ArgumentParser(prog="ossg", formatter_class=help_formatter)
parser.add_argument(
"--site", type=Path, default=Path("."), help="path to site directory"
)
parser.add_argument(
"--verbose", "-v", action="count", default=0, help="increase log verbosity"
)
parser.add_argument(
"--log-file",
type=Path,
default=argparse.SUPPRESS,
help="write logs to this file (default: <site>/build.log)",
)
subparsers = parser.add_subparsers(dest="command", required=True)
# build command
build = subparsers.add_parser(
"build", help="build the site", formatter_class=help_formatter
)
build.add_argument(
"--clean",
action=argparse.BooleanOptionalAction,
default=True,
help="remove output directory before building",
)
build.add_argument(
"--drafts",
action=argparse.BooleanOptionalAction,
default=False,
help="include draft content",
)
subparsers.add_parser("clean", help="remove the output directory")
return parser
def setup_logging(verbose: bool = False, log_file: Path | None = None) -> None:
logger = logging.getLogger("omicron.ssg") logger = logging.getLogger("omicron.ssg")
logger.setLevel(logging.DEBUG) logger.setLevel(logging.DEBUG)
@@ -13,8 +60,46 @@ def setup_logging(verbose: bool = False, log_file: str | None = None) -> None:
console.setFormatter(logging.Formatter("%(levelname)s: %(message)s")) console.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))
logger.addHandler(console) logger.addHandler(console)
if log_file is not None:
try:
file_handler = logging.FileHandler(log_file)
except FileNotFoundError as e:
raise CliError("log file can't be written") from e
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(
logging.Formatter("%(asctime)s %(levelname)s: %(message)s")
)
logger.addHandler(file_handler)
def build(args: argparse.Namespace) -> None:
site = Site(args.site, args.drafts)
if args.clean:
site.clean()
site.build()
def clean(args: argparse.Namespace) -> None:
site = Site(args.site)
site.clean()
def cli() -> None:
parser = setup_argparse()
args = parser.parse_args()
log_file = getattr(args, "log_file", args.site / "build.log")
setup_logging(verbose=bool(args.verbose), log_file=log_file)
if args.command == "build":
build(args)
elif args.command == "clean":
clean(args)
else:
raise NotImplementedError(f"'{args.command}' command not implemented yet")
def main() -> None: def main() -> None:
setup_logging(verbose=True) try:
site = Site(Path(".")) cli()
site.build() except (ConfigError, OutputError, CliError) as e:
print(e)
sys.exit(1)
+101
View File
@@ -0,0 +1,101 @@
from dataclasses import dataclass
from pathlib import Path
import logging
import yaml
from urllib.parse import urlparse
log = logging.getLogger(__name__)
class ConfigError(RuntimeError):
pass
DEFAULT_ITEMS_PER_PAGE = 20
@dataclass
class SiteConfig:
name: str
origin: str
base_dir: Path
items_per_page: int
template: str
@staticmethod
def parse_base_url(value: str | None) -> tuple[str, Path]:
if not value:
return ("", Path("/"))
try:
parsed = urlparse(value)
except ValueError as e:
raise ConfigError("invalid url format in base_url") from e
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("/")
if not path.is_absolute():
raise ConfigError("base_url config property must not be a relative path")
return (origin, path)
@staticmethod
def from_file(path: Path) -> SiteConfig:
log.debug("Reading config file %s", path)
try:
with open(path, "r") as f:
config = yaml.safe_load(f)
except FileNotFoundError as e:
raise ConfigError("config file missing, not a valid ossg directory") from e
except yaml.YAMLError as e:
raise ConfigError("config file must be a valid YAML file") from e
if not isinstance(config, dict):
raise ConfigError(
"config file must be a YAML mapping with string keys and int/str values"
)
for k, v in config.items():
if type(k) != str or type(v) not in [str, int]:
raise ConfigError(
"config file must be a YAML mapping with string keys and int/str values"
)
return SiteConfig.from_dict(config)
@staticmethod
def from_dict(config: dict[str, str | int]) -> SiteConfig:
name = config.get("name")
template = config.get("template")
base_url = config.get("base_url")
items_per_page = config.get("items_per_page", DEFAULT_ITEMS_PER_PAGE)
if name is None:
raise ConfigError("config file must contain a name property")
if template is None:
raise ConfigError("config file must contain a template property")
if base_url is not None and not isinstance(base_url, str):
raise ConfigError("base_url config property must be a string")
if not isinstance(name, str):
raise ConfigError("name config property must be a string")
if not isinstance(template, str):
raise ConfigError("template config property must be a string")
if not isinstance(items_per_page, int) or items_per_page < 1:
raise ConfigError(
"items_per_page config property must be a positive integer"
)
origin, base_dir = SiteConfig.parse_base_url(base_url)
return SiteConfig(
name=name,
origin=origin,
base_dir=base_dir,
items_per_page=items_per_page,
template=template,
)
+8 -10
View File
@@ -1,6 +1,6 @@
from typing import cast from typing import cast
from markdown_it import MarkdownIt from markdown_it import MarkdownIt
from markdown_it.token import Token from markdown_it.token import Token as Token
from pygments import highlight as pygments_highlight from pygments import highlight as pygments_highlight
from pygments.formatters import HtmlFormatter from pygments.formatters import HtmlFormatter
from pygments.lexers import get_lexer_by_name from pygments.lexers import get_lexer_by_name
@@ -32,7 +32,7 @@ def highlight(code: str, lang: str, attrs: str) -> str:
md = MarkdownIt(options_update={"highlight": highlight}) md = MarkdownIt(options_update={"highlight": highlight})
def extract_summary(tokens: list[Token]) -> Markup: def render_summary(tokens: list[Token]) -> Markup:
if not tokens or tokens[0].type != "paragraph_open" or tokens[0].hidden: if not tokens or tokens[0].type != "paragraph_open" or tokens[0].hidden:
raise MarkdownError( raise MarkdownError(
"Body does not start with a paragraph; set 'summary' in frontmatter" "Body does not start with a paragraph; set 'summary' in frontmatter"
@@ -43,11 +43,9 @@ def extract_summary(tokens: list[Token]) -> Markup:
raise MarkdownError("Content has no paragraph to use as summary") raise MarkdownError("Content has no paragraph to use as summary")
def parse(body: str, include_summary: bool = True) -> tuple[Markup, Markup | None]: def render(tokens: list[Token]) -> Markup:
tokens = md.parse(body) return Markup(md.renderer.render(tokens, md.options, {}))
if include_summary:
summary = extract_summary(tokens)
else: def parse(body: str) -> list[Token]:
summary = None return md.parse(body)
content = Markup(md.renderer.render(tokens, md.options, {}))
return content, summary
+11 -1
View File
@@ -1,13 +1,18 @@
from omicron.ssg.output.output import Output from omicron.ssg.output.output import Output, OutputError
from omicron.ssg.output.content import Content, ContentError from omicron.ssg.output.content import Content, ContentError
from omicron.ssg.output.factory import create_content, is_content from omicron.ssg.output.factory import create_content, is_content
from omicron.ssg.output.file import File from omicron.ssg.output.file import File
from omicron.ssg.output.index import Index, discover_index from omicron.ssg.output.index import Index, discover_index
from omicron.ssg.output.memory import Memory from omicron.ssg.output.memory import Memory
from omicron.ssg.output.directory import Directory
from omicron.ssg.output.symlink import Symlink
from omicron.ssg.output.tags import Tags, discover_tags from omicron.ssg.output.tags import Tags, discover_tags
from omicron.ssg.output.sitemap import Sitemap
from omicron.ssg.output.feed import Feed, discover_feeds
__all__ = [ __all__ = [
"Output", "Output",
"OutputError",
"Content", "Content",
"ContentError", "ContentError",
"create_content", "create_content",
@@ -15,7 +20,12 @@ __all__ = [
"File", "File",
"Index", "Index",
"discover_index", "discover_index",
"Directory",
"Memory", "Memory",
"Symlink",
"Tags", "Tags",
"discover_tags", "discover_tags",
"Sitemap",
"Feed",
"discover_feeds",
] ]
+41 -4
View File
@@ -1,6 +1,8 @@
import logging import logging
from urllib.parse import urlparse
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from omicron.ssg.output.content import Content from omicron.ssg.output.content import Content
from omicron.ssg.output.file import File
from pathlib import Path from pathlib import Path
import omicron.ssg.markdown as md import omicron.ssg.markdown as md
@@ -21,17 +23,52 @@ class Article(Content):
source, source,
meta["section"], meta["section"],
meta["slug"], meta["slug"],
meta["title"],
meta["date"], meta["date"],
meta.get("updated", None), meta.get("updated", None),
set(meta["tags"]) if "tags" in meta else None, set(meta["tags"]) if "tags" in meta else None,
meta.get("summary", None), meta.get("summary", None),
meta.get("draft", False),
) )
self.title: str = meta["title"]
def handle_reference(self, path: Path) -> Content | File:
site = self.site
if path in site.by_source:
return site.by_source[path]
return self.copy_referenced_file(path)
def resolve_references(self, tokens: list[md.Token]) -> None:
# MarkdownIt guarantees links are always in the second level
for token in tokens:
if token.type != "inline" or token.children is None:
continue
for child in token.children:
if child.type == "link_open":
attr = "href"
elif child.type == "image":
attr = "src"
else:
continue
href = child.attrs.get(attr)
assert type(href) is str, "unexpected link/image href type"
parsed = urlparse(href)
if parsed.scheme or parsed.netloc:
continue
if not parsed.path or parsed.path.startswith("/"):
continue
target = self.locate_reference(Path(parsed.path))
output = self.handle_reference(target)
child.attrs[attr] = parsed._replace(path=output.url).geturl()
def write(self) -> None: def write(self) -> None:
content, summary = md.parse(self.read_body(), self.summary is None) tokens = md.parse(self.read_body())
if summary is not None: self.resolve_references(tokens)
self.summary = summary if self.summary is None:
self.summary = md.render_summary(tokens)
content = md.render(tokens)
template = self.site.jinja_env.get_template("article.html") template = self.site.jinja_env.get_template("article.html")
html = template.render(page=self, content=content, site=self.site) html = template.render(page=self, content=content, site=self.site)
+36 -3
View File
@@ -1,6 +1,7 @@
import re import re
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from omicron.ssg.output.output import Output from omicron.ssg.output.output import Output, OutputError, is_normalized
from omicron.ssg.output.file import File
from pathlib import Path from pathlib import Path
from datetime import date from datetime import date
@@ -10,7 +11,7 @@ else:
Site = "omicron.ssg.site.Site" Site = "omicron.ssg.site.Site"
class ContentError(RuntimeError): class ContentError(OutputError):
pass pass
@@ -23,28 +24,60 @@ class Content(Output):
source: Path, source: Path,
section: str, section: str,
slug: str, slug: str,
title: str,
created: date, created: date,
updated: date | None = None, updated: date | None = None,
tags: set[str] | None = None, tags: set[str] | None = None,
summary: str | None = None, summary: str | None = None,
draft: bool = False,
): ):
super().__init__(site, Content.build_path(section, slug)) super().__init__(site, Content.build_path(section, slug))
if tags is None: if tags is None:
tags = set() tags = set()
if not source.is_absolute():
raise ContentError("content source path must be absolute")
if not is_normalized(source):
raise ContentError("content source path must be normalized")
self.source = source self.source = source
self.section: str = section self.section: str = section
self.title: str = title
self.created = created self.created = created
self.updated = updated self.updated = updated
self.tags: set[str] = tags self.tags: set[str] = tags
self.summary: str | None = summary self.summary: str | None = summary
self.draft: bool = draft
self.in_sitemap = True
@staticmethod @staticmethod
def build_path(section: str, slug: str) -> Path: def build_path(section: str, slug: str) -> Path:
if not Content._SECTION_RE.match(section): if not Content._SECTION_RE.match(section):
raise ContentError(f"Invalid section '{section}'") raise ContentError(f"Invalid section '{section}'")
path = Path(f"{section}/{slug}.html") path = Path(f"{section}/{slug}/index.html")
return path return path
def locate_reference(self, path: Path) -> Path:
if path.is_absolute():
raise ContentError("Can't resolve absolute references")
base = self.site.site_path / "content"
file = (self.source.parent / path).resolve()
if not file.is_relative_to(base):
raise ContentError("Reference escapes content directory")
return file
def copy_referenced_file(self, path: Path) -> File:
if not path.is_file():
raise ContentError(f"Referenced file '{path}' does not exist")
dest = self.destination.parent / path.name
existing = self.site.by_path.get(dest)
if isinstance(existing, File) and existing.source == path:
return existing
file = File(self.site, dest, path)
self.site.add_by_path(file)
file.write()
return file
def read_body(self) -> str: def read_body(self) -> str:
with self.source.open("r", encoding="utf-8") as f: with self.source.open("r", encoding="utf-8") as f:
f.readline() # opening --- f.readline() # opening ---
+24
View File
@@ -0,0 +1,24 @@
import logging
from typing import TYPE_CHECKING
from omicron.ssg.output.output import Output
from pathlib import Path
log = logging.getLogger(__name__)
if TYPE_CHECKING:
from omicron.ssg.site import Site # for static checking with mypy
else:
Site = "omicron.ssg.site.Site" # for runtime checking with beartype
class Directory(Output):
def __init__(self, site: Site, destination: Path):
super().__init__(site, destination)
url_path = Path("/") / site.config.base_dir / destination
# rebuild url because Output strips /index.html which is a valid dir
self.url = url_path.as_posix()
def write(self) -> None:
dest = self.site.output_path / self.destination
dest.mkdir(parents=True, exist_ok=True)
log.debug("Created directory %s", dest)
+84
View File
@@ -0,0 +1,84 @@
import logging
from datetime import date
from pathlib import Path
from typing import TYPE_CHECKING, Literal
from xml.etree.ElementTree import Element, ElementTree, SubElement
from omicron.ssg.output.output import Output
from omicron.ssg.output.content import Content
log = logging.getLogger(__name__)
if TYPE_CHECKING:
from omicron.ssg.site import Site # for static checking with mypy
else:
Site = "omicron.ssg.site.Site" # for runtime checking with beartype
ITEMS_PER_FEED = 20
def atom_date(d: date) -> str:
return f"{d.isoformat()}T00:00:00Z"
class Feed(Output):
NAMESPACE = "http://www.w3.org/2005/Atom"
def __init__(
self,
site: Site,
kind: Literal["root", "section"],
items: list[Content],
label: str | None,
):
super().__init__(site, Feed.build_path(kind, label))
self.kind = kind
self.label = label
self.items = sorted(items, key=lambda c: c.created, reverse=True)[
:ITEMS_PER_FEED
]
@staticmethod
def build_path(kind: Literal["root", "section"], label: str | None) -> Path:
file = Path("atom.xml")
if kind == "root":
return file
assert label, "label can't be None if kind is not root"
return Path(label) / file
def write(self) -> None:
config = self.site.config
feed_url = f"{config.origin}{self.url}"
title = f"{config.name}{self.label}" if self.label else config.name
updated = max(
(c.updated or c.created for c in self.items), default=date.today()
)
feed = Element("feed", xmlns=Feed.NAMESPACE)
SubElement(feed, "title").text = title
SubElement(feed, "id").text = feed_url
SubElement(feed, "link", rel="self", href=feed_url)
SubElement(feed, "updated").text = atom_date(updated)
for item in self.items:
entry = SubElement(feed, "entry")
item_url = f"{config.origin}{item.url}"
SubElement(entry, "title").text = item.title
SubElement(entry, "id").text = item_url
SubElement(entry, "link", href=item_url)
SubElement(entry, "published").text = atom_date(item.created)
SubElement(entry, "updated").text = atom_date(item.updated or item.created)
if item.summary:
summary = SubElement(entry, "summary", type="html")
summary.text = str(item.summary)
dest = self.site.output_path / self.destination
dest.parent.mkdir(parents=True, exist_ok=True)
ElementTree(feed).write(dest, encoding="utf-8", xml_declaration=True)
log.debug("Wrote %s", dest)
def discover_feeds(site: Site) -> list[Feed]:
outputs = [Feed(site, "root", site.content, None)]
for section, items in site.by_section.items():
outputs.append(Feed(site, "section", items, section))
return outputs
+6 -6
View File
@@ -30,6 +30,7 @@ class Index(Output):
self.page_num = page_num self.page_num = page_num
self.total_pages = total_pages self.total_pages = total_pages
self.label = label self.label = label
self.in_sitemap = kind != "tag" and page_num == 1
def pagination_url(self, n: int) -> str: def pagination_url(self, n: int) -> str:
path = Index.build_path(self.kind, n, self.label) path = Index.build_path(self.kind, n, self.label)
@@ -44,7 +45,7 @@ class Index(Output):
if page_num == 1: if page_num == 1:
file = Path("index.html") file = Path("index.html")
else: else:
file = Path(f"page-{page_num}.html") file = Path(f"page-{page_num}/index.html")
if kind == "root": if kind == "root":
path = file path = file
@@ -88,14 +89,13 @@ def make_index_pages(
def discover_index(site: Site) -> list[Index]: def discover_index(site: Site) -> list[Index]:
items_per_page = site.config.items_per_page
outputs: list[Index] = [] outputs: list[Index] = []
outputs.extend( outputs.extend(make_index_pages(site, site.content, "root", None, items_per_page))
make_index_pages(site, site.content, "root", None, site.items_per_page)
)
for section, items in site.by_section.items(): for section, items in site.by_section.items():
outputs.extend( outputs.extend(
make_index_pages(site, items, "section", section, site.items_per_page) make_index_pages(site, items, "section", section, items_per_page)
) )
for tag, items in site.by_tag.items(): for tag, items in site.by_tag.items():
outputs.extend(make_index_pages(site, items, "tag", tag, site.items_per_page)) outputs.extend(make_index_pages(site, items, "tag", tag, items_per_page))
return outputs return outputs
+19 -3
View File
@@ -9,13 +9,29 @@ else:
Site = "omicron.ssg.site.Site" # for runtime checking with beartype Site = "omicron.ssg.site.Site" # for runtime checking with beartype
def is_normalized(path: Path) -> bool:
return ".." not in path.parts
class OutputError(RuntimeError):
pass
class Output(ABC): class Output(ABC):
def __init__(self, site: Site, destination: Path): def __init__(self, site: Site, destination: Path):
self._site_ref = weakref.ref(site) self._site_ref = weakref.ref(site)
if destination.is_absolute():
raise OutputError("destination path must be relative")
if not is_normalized(destination):
raise OutputError("destination path must be normalized")
self.destination = destination self.destination = destination
url = site.base_dir + "/" + destination.as_posix()
url = url.removesuffix("/index.html") or "/" url = site.config.base_dir / destination
self.url = url if url.name == "index.html":
url = url.parent
self.url = url.as_posix()
self.in_sitemap = False
@property @property
def site(self) -> Site: def site(self) -> Site:
+38
View File
@@ -0,0 +1,38 @@
import logging
from pathlib import Path
from typing import TYPE_CHECKING
from xml.etree.ElementTree import Element, ElementTree, SubElement
from omicron.ssg.output.output import Output
from omicron.ssg.output.content import Content
log = logging.getLogger(__name__)
if TYPE_CHECKING:
from omicron.ssg.site import Site
else:
Site = "omicron.ssg.site.Site"
class Sitemap(Output):
PATH = Path("sitemap.xml")
NAMESPACE = "http://www.sitemaps.org/schemas/sitemap/0.9"
def __init__(self, site: Site):
super().__init__(site, Sitemap.PATH)
def write(self) -> None:
urlset = Element("urlset", xmlns=Sitemap.NAMESPACE)
for entry in self.site.by_path.values():
if not entry.in_sitemap:
continue
url = SubElement(urlset, "url")
SubElement(url, "loc").text = f"{self.site.config.origin}{entry.url}"
if isinstance(entry, Content):
lastmod = entry.updated or entry.created
SubElement(url, "lastmod").text = lastmod.isoformat()
dest = self.site.output_path / self.destination
dest.parent.mkdir(parents=True, exist_ok=True)
ElementTree(urlset).write(dest, encoding="utf-8", xml_declaration=True)
log.debug("Wrote %s", dest)
+34
View File
@@ -0,0 +1,34 @@
import logging
import os
from typing import TYPE_CHECKING
from omicron.ssg.output.output import Output, OutputError, is_normalized
from pathlib import Path
log = logging.getLogger(__name__)
if TYPE_CHECKING:
from omicron.ssg.site import Site # for static checking with mypy
else:
Site = "omicron.ssg.site.Site" # for runtime checking with beartype
class Symlink(Output):
def __init__(self, site: Site, link: Path, target: Path):
if target.is_absolute():
raise OutputError("Symlinks target must be relative")
if not is_normalized(target):
raise OutputError("Symlinks target must be normalized")
super().__init__(site, link)
self.target = target
def write(self) -> None:
site = self.site
if self.target not in site.by_path:
raise OutputError(f"Symlink target '{self.target}' is not a known output")
link = site.output_path / self.destination
link.parent.mkdir(parents=True, exist_ok=True)
if link.exists() or link.is_symlink():
link.unlink()
rel_target = os.path.relpath(site.output_path / self.target, link.parent)
link.symlink_to(rel_target)
log.debug("Symlinked %s -> %s", link, rel_target)
+1
View File
@@ -17,6 +17,7 @@ class Tags(Output):
def __init__(self, site: Site, tags: list[tuple[str, int]]): def __init__(self, site: Site, tags: list[tuple[str, int]]):
super().__init__(site, Tags.PATH) super().__init__(site, Tags.PATH)
self.tags = tags self.tags = tags
self.in_sitemap = True
def write(self) -> None: def write(self) -> None:
template = self.site.jinja_env.get_template("tags.html") template = self.site.jinja_env.get_template("tags.html")
+72 -51
View File
@@ -1,80 +1,52 @@
import logging import logging
from typing import Any, cast import shutil
from urllib.parse import urlparse from typing import Literal
from omicron.ssg.output import ( from omicron.ssg.output import (
Output, Output,
OutputError,
Content, Content,
ContentError, ContentError,
Directory,
is_content, is_content,
create_content, create_content,
File, File,
Index, Index,
Tags, Tags,
Memory, Memory,
Sitemap,
Feed,
discover_index, discover_index,
discover_tags, discover_tags,
discover_feeds,
) )
from omicron.ssg.markdown import highlight_style from omicron.ssg.markdown import highlight_style
from omicron.ssg.config import SiteConfig, ConfigError
from pathlib import Path from pathlib import Path
import yaml
from jinja2 import Environment, FileSystemLoader from jinja2 import Environment, FileSystemLoader
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, drafts: bool = False):
self.site_path = site.absolute() self.site_path = site.resolve()
self.drafts = drafts
self.content: list[Content] = [] self.content: list[Content] = []
self.directories: list[Directory] = []
self.other_outputs: list[Output] = [] self.other_outputs: list[Output] = []
self.by_tag: dict[str, list[Content]] = {} self.by_tag: dict[str, list[Content]] = {}
self.by_path: dict[Path, Output] = {} self.by_path: dict[Path, Output] = {}
self.by_source: dict[Path, Content] = {}
self.by_section: dict[str, list[Content]] = {} self.by_section: dict[str, list[Content]] = {}
self.tags_by_count: list[tuple[str, int]] = [] self.tags_by_count: list[tuple[str, int]] = []
config = self.read_config() self.config = SiteConfig.from_file(site / "config.yml")
self.name: str = config["name"] self.template_path: Path = Site.resolve_template_path(self.config.template)
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.output_path: Path = self.site_path / "output"
self.jinja_env = Environment(loader=FileSystemLoader(self.template_path)) 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 @staticmethod
def resolve_template_path(template: str) -> Path: def resolve_template_path(template: str) -> Path:
path = Path(__file__).parent / "templates" / template path = Path(__file__).parent / "templates" / template
@@ -98,10 +70,35 @@ class Site:
raise ContentError( raise ContentError(
f"path '{output.destination}' is already claimed by another output" 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 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: def home_url(self) -> str:
return self.base_dir or "/" return self.config.base_dir.as_posix()
def url_for(self, path: str) -> str:
if Path(path).is_absolute():
raise OutputError("path must be relative")
return (self.config.base_dir / path).as_posix()
def section_url(self, section: str) -> str: def section_url(self, section: str) -> str:
path = Index.build_path("section", 1, section) path = Index.build_path("section", 1, section)
@@ -114,6 +111,12 @@ class Site:
def tags_url(self) -> str: def tags_url(self) -> str:
return self.by_path[Tags.PATH].url 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: def update_tags_by_count(self) -> None:
tags = [(tag, len(items)) for tag, items in self.by_tag.items()] tags = [(tag, len(items)) for tag, items in self.by_tag.items()]
tags.sort(key=lambda x: x[1], reverse=True) tags.sort(key=lambda x: x[1], reverse=True)
@@ -138,12 +141,17 @@ class Site:
self.other_outputs.append(pygments) self.other_outputs.append(pygments)
self.add_by_path(pygments) self.add_by_path(pygments)
content_path = self.site_path / "content" content_path = self.site_path / "content"
for file in content_path.rglob("*"): files = sorted(content_path.rglob("*"))
for file in files:
if file.is_file() and is_content(file): if file.is_file() and is_content(file):
content = create_content(self, 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_path(content)
self.add_by_section(content) self.add_by_section(content)
self.add_by_tag(content) self.add_by_tag(content)
self.add_by_source(content)
self.content.append(content) self.content.append(content)
self.update_tags_by_count() self.update_tags_by_count()
for index in discover_index(self): for index in discover_index(self):
@@ -152,19 +160,32 @@ class Site:
tags_page = discover_tags(self) tags_page = discover_tags(self)
self.add_by_path(tags_page) self.add_by_path(tags_page)
self.other_outputs.append(tags_page) self.other_outputs.append(tags_page)
if self.config.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)) 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: def build(self) -> None:
if not self.origin: if not self.config.origin:
log.warning( log.warning(
"base_url config value is missing a domain name, " "base_url config value is missing a domain name, "
"can't add canonical url to content" "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.discover()
self.output_path.mkdir(parents=True, exist_ok=True) outputs: list[Output] = [*self.directories, *self.content]
for content in self.content: for output in sorted(outputs, key=lambda o: o.destination.as_posix()):
content.write() output.write()
for output in self.other_outputs: for output in self.other_outputs:
output.write() output.write()
+2 -2
View File
@@ -1,10 +1,10 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{ page.title }} — {{ site.name }}{% endblock %} {% block title %}{{ page.title }} — {{ site.config.name }}{% endblock %}
{% block breadcrumb %} {% block breadcrumb %}
<nav aria-label="breadcrumb"> <nav aria-label="breadcrumb">
<a href="{{ site.home_url() }}">{{ site.name }}</a> <a href="{{ site.home_url() }}">{{ site.config.name }}</a>
<a href="{{ site.section_url(page.section) }}">{{ page.section }}</a> <a href="{{ site.section_url(page.section) }}">{{ page.section }}</a>
{{ page.title }} {{ page.title }}
</nav> </nav>
+7 -6
View File
@@ -3,21 +3,22 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}{{ site.name }}{% endblock %}</title> <title>{% block title %}{{ site.config.name }}{% endblock %}</title>
<link rel="stylesheet" href="{{ site.base_dir }}/assets/style.css"> <link rel="stylesheet" href="{{ site.url_for('assets/style.css') }}">
<link rel="stylesheet" href="{{ site.base_dir }}/assets/pygments.css"> <link rel="stylesheet" href="{{ site.url_for('assets/pygments.css') }}">
<link rel="canonical" href="{{ site.origin }}{{ page.url }}"> {% if site.config.origin %}<link rel="canonical" href="{{ site.config.origin }}{{ page.url }}">{% endif %}
{% if site.feed_url("root", None) %}<link rel="alternate" type="application/atom+xml" title="{{ site.config.name }}" href="{{ site.feed_url('root', None) }}">{% endif %}
{% block head %}{% endblock %} {% block head %}{% endblock %}
</head> </head>
<body> <body>
<header> <header>
{% block breadcrumb %}<a href="{{ site.home_url() }}">{{ site.name }}</a>{% endblock %} {% block breadcrumb %}<a href="{{ site.home_url() }}">{{ site.config.name }}</a>{% endblock %}
</header> </header>
<main> <main>
{% block main %}{% endblock %} {% block main %}{% endblock %}
</main> </main>
<footer> <footer>
<p>{{ site.name }}</p> <p>{{ site.config.name }}</p>
</footer> </footer>
</body> </body>
</html> </html>
+6 -3
View File
@@ -1,20 +1,23 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{% if page.label %}{{ page.label }} — {% endif %}{{ site.name }}{% if page.page_num > 1 %} — Page {{ page.page_num }}{% endif %}{% endblock %} {% block title %}{% if page.label %}{{ page.label }} — {% endif %}{{ site.config.name }}{% if page.page_num > 1 %} — Page {{ page.page_num }}{% endif %}{% endblock %}
{% block head %} {% block head %}
{% if page.page_num > 1 %}<meta name="robots" content="noindex, follow">{% endif %} {% if page.page_num > 1 %}<meta name="robots" content="noindex, follow">{% endif %}
{% if page.kind == "section" %}
<link rel="alternate" type="application/atom+xml" title="{{ page.label }}" href="{{ site.feed_url('section', page.label) }}">
{% endif %}
{% endblock %} {% endblock %}
{% block breadcrumb %} {% block breadcrumb %}
{% if page.kind == "section" %} {% if page.kind == "section" %}
<nav aria-label="breadcrumb"> <nav aria-label="breadcrumb">
<a href="{{ site.home_url() }}">{{ site.name }}</a> <a href="{{ site.home_url() }}">{{ site.config.name }}</a>
{{ page.label }} {{ page.label }}
</nav> </nav>
{% elif page.kind == "tag" %} {% elif page.kind == "tag" %}
<nav aria-label="breadcrumb"> <nav aria-label="breadcrumb">
<a href="{{ site.home_url() }}">{{ site.name }}</a> <a href="{{ site.home_url() }}">{{ site.config.name }}</a>
<a href="{{ site.tags_url() }}">Tags</a> <a href="{{ site.tags_url() }}">Tags</a>
{{ page.label }} {{ page.label }}
</nav> </nav>
+2 -2
View File
@@ -1,10 +1,10 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Tags — {{ site.name }}{% endblock %} {% block title %}Tags — {{ site.config.name }}{% endblock %}
{% block breadcrumb %} {% block breadcrumb %}
<nav aria-label="breadcrumb"> <nav aria-label="breadcrumb">
<a href="{{ site.home_url() }}">{{ site.name }}</a> <a href="{{ site.home_url() }}">{{ site.config.name }}</a>
Tags Tags
</nav> </nav>
{% endblock %} {% endblock %}
+12
View File
@@ -17,7 +17,9 @@ dependencies = [
dev = [ dev = [
"beartype", "beartype",
"black", "black",
"coverage",
"mypy", "mypy",
"pytest",
"types-PyYAML", "types-PyYAML",
"types-Pygments", "types-Pygments",
] ]
@@ -31,6 +33,16 @@ target-version = ["py314"]
[tool.mypy] [tool.mypy]
strict = true strict = true
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.coverage.run]
source = ["omicron"]
[tool.coverage.report]
show_missing = true
exclude_also = ["@(abc\\.)?abstractmethod"]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["."] where = ["."]
include = ["omicron.ssg*"] include = ["omicron.ssg*"]
@@ -0,0 +1,2 @@
name: Draft Content Test
template: plain
@@ -0,0 +1,9 @@
---
type: article
section: posts
slug: draft-post
title: Draft
date: 2026-08-28
draft: true
---
Draft.
@@ -0,0 +1,8 @@
---
type: article
section: posts
slug: published
title: Published
date: 2026-08-28
---
Published.
@@ -0,0 +1,2 @@
name: Duplicate Slug Test
template: plain
@@ -0,0 +1,8 @@
---
type: article
section: posts
slug: hello
title: First
date: 2026-08-28
---
First.
@@ -0,0 +1,8 @@
---
type: article
section: posts
slug: hello
title: Second
date: 2026-08-28
---
Second.
+3
View File
@@ -0,0 +1,3 @@
name: Main Test Site
base_url: http://127.0.0.1:8000
template: plain
@@ -0,0 +1,10 @@
---
type: article
section: posts
slug: first-post
title: First Post
date: 2026-08-28
tags: [meta]
---
Welcome to the test site. This site will contain some test content for the
static site generator, the content won't matter a lot.
+2
View File
@@ -0,0 +1,2 @@
name: Sections Test
template: plain
@@ -0,0 +1,8 @@
---
type: article
section: notes
slug: first-note
title: First Note
date: 2026-08-28
---
First note.
@@ -0,0 +1,8 @@
---
type: article
section: posts
slug: first-post
title: First Post
date: 2026-08-28
---
First post.
@@ -0,0 +1,8 @@
---
type: article
section: posts
slug: second-post
title: Second Post
date: 2026-08-28
---
Second post.
+2
View File
@@ -0,0 +1,2 @@
name: Tags Test
template: plain
+8
View File
@@ -0,0 +1,8 @@
---
type: article
section: posts
slug: no-tags
title: No Tags
date: 2026-08-28
---
No tags.
+9
View File
@@ -0,0 +1,9 @@
---
type: article
section: posts
slug: one-tag
title: One Tag
date: 2026-08-28
tags: [python]
---
One tag.
@@ -0,0 +1,9 @@
---
type: article
section: posts
slug: two-tags
title: Two Tags
date: 2026-08-28
tags: [python, testing]
---
Two tags.
+2
View File
@@ -0,0 +1,2 @@
name: test
template: plain
+31
View File
@@ -0,0 +1,31 @@
import shutil
import subprocess
from pathlib import Path
import pytest
MAIN_SITE = Path(__file__).parent.parent / "data" / "sites" / "main"
@pytest.fixture(scope="module")
def built_site(tmp_path_factory):
site_dir = tmp_path_factory.mktemp("main")
shutil.copytree(
MAIN_SITE,
site_dir,
dirs_exist_ok=True,
ignore=shutil.ignore_patterns("output", "build.log"),
)
result = subprocess.run(
["ossg", "--site", str(site_dir), "build"],
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stderr
return site_dir / "output"
def test_article_renders(built_site):
output = built_site / "posts" / "first-post" / "index.html"
assert output.exists()
assert "First Post" in output.read_text()
+28
View File
@@ -0,0 +1,28 @@
import shutil
from pathlib import Path
import pytest
from omicron.ssg.site import Site
DATA_SITES = Path(__file__).parent.parent / "data" / "sites"
@pytest.fixture
def site_dir(tmp_path):
def _make(name):
path = tmp_path / name
shutil.copytree(
DATA_SITES / name,
path,
ignore=shutil.ignore_patterns("output", "build.log"),
)
return path
return _make
@pytest.fixture
def make_site(site_dir):
def _make(name, **kwargs):
return Site(site_dir(name), **kwargs)
return _make
+188
View File
@@ -0,0 +1,188 @@
from pathlib import Path
import pytest
from omicron.ssg.config import SiteConfig, ConfigError, DEFAULT_ITEMS_PER_PAGE
@pytest.mark.parametrize(
"value, expected_origin, expected_path",
[
(None, "", Path("/")),
("http://example.com/blog", "http://example.com", Path("/blog")),
("http://example.com", "http://example.com", Path("/")),
("//example.com/blog", "//example.com", Path("/blog")),
("/blog", "", Path("/blog")),
],
)
def test_parse_base_url(value, expected_origin, expected_path):
origin, path = SiteConfig.parse_base_url(value)
assert origin == expected_origin
assert path == expected_path
@pytest.mark.parametrize(
"value, expected_message",
[
("https://[::1", "invalid url format"),
("relative/path", "relative path"),
],
)
def test_parse_base_url_error(value, expected_message):
with pytest.raises(ConfigError, match=expected_message):
origin, path = SiteConfig.parse_base_url(value)
def test_from_dict_minimal():
config = SiteConfig.from_dict({"name": "My Site", "template": "plain"})
assert config.name == "My Site"
assert config.template == "plain"
assert config.items_per_page == DEFAULT_ITEMS_PER_PAGE
assert config.origin == ""
assert config.base_dir == Path("/")
def test_from_dict_full():
config = SiteConfig.from_dict(
{
"name": "My Site",
"template": "plain",
"base_url": "http://example.com/blog",
"items_per_page": 42,
}
)
assert config.name == "My Site"
assert config.template == "plain"
assert config.items_per_page == 42
assert config.origin == "http://example.com"
assert config.base_dir == Path("/blog")
MISSING = object()
@pytest.mark.parametrize(
"overrides, expected_message",
[
({"name": MISSING}, "must contain a name property"),
({"template": MISSING}, "must contain a template property"),
({"name": 123}, "name config property must be a string"),
({"template": 123}, "template config property must be a string"),
({"base_url": 123}, "base_url config property must be a string"),
({"base_url": "relative/path"}, "relative path"),
({"base_url": "https://[::1"}, "invalid url format"),
(
{"items_per_page": "10"},
"items_per_page config property must be a positive integer",
),
(
{"items_per_page": 0},
"items_per_page config property must be a positive integer",
),
(
{"items_per_page": -1},
"items_per_page config property must be a positive integer",
),
],
)
def test_from_dict_error(overrides, expected_message):
config = {"name": "My Site", "template": "plain"}
for key, value in overrides.items():
if value is MISSING:
config.pop(key, None)
else:
config[key] = value
with pytest.raises(ConfigError, match=expected_message):
SiteConfig.from_dict(config)
def test_from_file_minimal(tmp_path):
path = tmp_path / "config.yml"
path.write_text("name: My Site\ntemplate: plain\n")
config = SiteConfig.from_file(path)
assert config.name == "My Site"
assert config.template == "plain"
assert config.items_per_page == DEFAULT_ITEMS_PER_PAGE
assert config.origin == ""
assert config.base_dir == Path("/")
def test_from_file_full(tmp_path):
path = tmp_path / "config.yml"
path.write_text(
"name: My Site\n"
"template: plain\n"
"base_url: http://example.com/blog\n"
"items_per_page: 42\n"
)
config = SiteConfig.from_file(path)
assert config.name == "My Site"
assert config.template == "plain"
assert config.items_per_page == 42
assert config.origin == "http://example.com"
assert config.base_dir == Path("/blog")
def test_from_file_missing(tmp_path):
path = tmp_path / "config.yml"
with pytest.raises(ConfigError, match="config file missing"):
SiteConfig.from_file(path)
@pytest.mark.parametrize(
"yaml_text, expected_message",
[
("- a\n- b\n", "must be a YAML mapping"),
("just a string\n", "must be a YAML mapping"),
("123: value\n", "must be a YAML mapping"),
("name: [1, 2]\n", "must be a YAML mapping"),
("name: true\n", "must be a YAML mapping"),
(":\n bad: yaml: here\n", "must be a valid YAML file"),
("template: plain\n", "must contain a name property"),
("name: My Site\n", "must contain a template property"),
(
"name: 123\ntemplate: plain\n",
"name config property must be a string",
),
(
"name: My Site\ntemplate: 123\n",
"template config property must be a string",
),
(
"name: My Site\ntemplate: plain\nbase_url: 123\n",
"base_url config property must be a string",
),
(
"name: My Site\ntemplate: plain\nbase_url: relative/path\n",
"relative path",
),
(
"name: My Site\ntemplate: plain\nbase_url: https://[::1\n",
"invalid url format",
),
(
'name: My Site\ntemplate: plain\nitems_per_page: "10"\n',
"items_per_page config property must be a positive integer",
),
(
"name: My Site\ntemplate: plain\nitems_per_page: 0\n",
"items_per_page config property must be a positive integer",
),
(
"name: My Site\ntemplate: plain\nitems_per_page: -1\n",
"items_per_page config property must be a positive integer",
),
],
)
def test_from_file_error(tmp_path, yaml_text, expected_message):
path = tmp_path / "config.yml"
path.write_text(yaml_text)
with pytest.raises(ConfigError, match=expected_message):
SiteConfig.from_file(path)
+31
View File
@@ -0,0 +1,31 @@
from pathlib import Path
from omicron.ssg.output.file import File
def test_write_copies_content(make_site, tmp_path):
site = make_site("thin_site")
source = tmp_path / "source.txt"
source.write_text("hello")
dest_dir = site.output_path / "assets"
dest_dir.mkdir(parents=True)
file = File(site, Path("assets/copy.txt"), source)
file.write()
dest = dest_dir / "copy.txt"
assert dest.read_text() == "hello"
def test_write_overwrites_existing_destination(make_site, tmp_path):
site = make_site("thin_site")
dest = site.output_path / "copy.txt"
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text("old")
source = tmp_path / "source.txt"
source.write_text("new")
file = File(site, Path("copy.txt"), source)
file.write()
assert dest.read_text() == "new"
+38
View File
@@ -0,0 +1,38 @@
from pathlib import Path
import pytest
from omicron.ssg.output.file import File
from omicron.ssg.output.output import OutputError
def test_absolute_destination_raises(make_site, tmp_path):
site = make_site("thin_site")
with pytest.raises(OutputError):
File(site, Path("/copy.txt"), tmp_path / "source.txt")
def test_non_normalized_destination_raises(make_site, tmp_path):
site = make_site("thin_site")
with pytest.raises(OutputError):
File(site, Path("assets/../copy.txt"), tmp_path / "source.txt")
def test_site_raises_after_garbage_collection(make_site, tmp_path):
site = make_site("thin_site")
file = File(site, Path("copy.txt"), tmp_path / "source.txt")
del site
with pytest.raises(ReferenceError):
file.site
def test_url(make_site, tmp_path):
site = make_site("thin_site")
file = File(site, Path("assets/copy.txt"), tmp_path / "source.txt")
assert file.url == "/assets/copy.txt"
def test_url_strips_index_html(make_site, tmp_path):
site = make_site("thin_site")
file = File(site, Path("posts/index.html"), tmp_path / "source.txt")
assert file.url == "/posts"
+192
View File
@@ -0,0 +1,192 @@
from pathlib import Path
import pytest
from omicron.ssg.output import ContentError, Directory, OutputError
from omicron.ssg.site import ConfigError, Site
def test_discover_raises_on_duplicate_destination(make_site):
site = make_site("duplicate_slug")
with pytest.raises(ContentError):
site.discover()
def test_discover_creates_content_directories(make_site):
site = make_site("main")
site.discover()
assert isinstance(site.by_path[Path("posts")], Directory)
assert isinstance(site.by_path[Path("posts/first-post")], Directory)
def test_read_config_errors(site_dir):
path = site_dir("thin_site")
config_path = path / "config.yml"
config_path.unlink()
with pytest.raises(ConfigError, match="config file missing"):
Site(path)
config_path.write_text("- a\n- b\n")
with pytest.raises(ConfigError, match="YAML mapping with string keys"):
Site(path)
config_path.write_text("123: value\n")
with pytest.raises(ConfigError, match="YAML mapping with string keys"):
Site(path)
config_path.write_text("template: plain\n")
with pytest.raises(ConfigError, match="must contain a name property"):
Site(path)
config_path.write_text("name: test\n")
with pytest.raises(ConfigError, match="must contain a template property"):
Site(path)
def test_site_relative_base_url(site_dir):
path = site_dir("thin_site")
(path / "config.yml").write_text("name: test\ntemplate: plain\nbase_url: blog\n")
with pytest.raises(ConfigError, match="relative path"):
site = Site(path)
def test_discover_skips_drafts_by_default(make_site):
site = make_site("draft_content")
site.discover()
assert len(site.content) == 1
assert site.content[0].title == "Published"
def test_discover_includes_drafts_when_enabled(make_site):
site = make_site("draft_content", drafts=True)
site.discover()
assert len(site.content) == 2
def test_clean_removes_output_directory(make_site):
site = make_site("main")
site.build()
assert site.output_path.exists()
site.clean()
assert not site.output_path.exists()
site.clean()
assert not site.output_path.exists()
def test_resolve_template_path_raises_for_missing_template():
with pytest.raises(ConfigError, match="not found"):
Site.resolve_template_path("does-not-exist")
def test_home_url(site_dir):
path = site_dir("thin_site")
(path / "config.yml").write_text("name: test\ntemplate: plain\n")
assert Site(path).home_url() == "/"
(path / "config.yml").write_text(
"name: test\ntemplate: plain\nbase_url: http://example.com/blog\n"
)
assert Site(path).home_url() == "/blog"
def test_url_for(site_dir):
path = site_dir("thin_site")
(path / "config.yml").write_text(
"name: test\ntemplate: plain\nbase_url: http://example.com/blog\n"
)
site = Site(path)
assert site.url_for("assets/style.css") == "/blog/assets/style.css"
with pytest.raises(OutputError, match="must be relative"):
site.url_for("/assets/style.css")
def test_section_url(site_dir):
path = site_dir("sections")
site = Site(path)
site.discover()
assert site.section_url("posts") == "/posts"
assert site.section_url("notes") == "/notes"
(path / "config.yml").write_text("name: test\ntemplate: plain\nbase_url: /blog\n")
site = Site(path)
site.discover()
assert site.section_url("posts") == "/blog/posts"
assert site.section_url("notes") == "/blog/notes"
def test_add_by_section(make_site):
site = make_site("sections")
site.discover()
assert set(site.by_section) == {"posts", "notes"}
assert {c.title for c in site.by_section["posts"]} == {"First Post", "Second Post"}
assert {c.title for c in site.by_section["notes"]} == {"First Note"}
def test_feed_url(site_dir):
path = site_dir("tags")
(path / "config.yml").write_text(
"name: test\ntemplate: plain\nbase_url: http://example.com\n"
)
site = Site(path)
site.discover()
assert site.feed_url("root", None) == "/atom.xml"
(path / "config.yml").write_text("name: test\ntemplate: plain\n")
site = Site(path)
site.discover()
assert site.feed_url("root", None) is None
def test_tag_url(site_dir):
path = site_dir("tags")
site = Site(path)
site.discover()
assert site.tag_url("python") == "/tags/python"
(path / "config.yml").write_text("name: test\ntemplate: plain\nbase_url: /blog\n")
site = Site(path)
site.discover()
assert site.tag_url("python") == "/blog/tags/python"
def test_tags_url(make_site):
site = make_site("tags")
site.discover()
assert site.tags_url() == "/tags"
def test_add_by_source(make_site):
site = make_site("tags")
site.discover()
assert site.by_source
for content in site.content:
assert site.by_source[content.source] is content
def test_tags_by_count(make_site):
site = make_site("tags")
site.discover()
assert site.tags_by_count == [("python", 2), ("testing", 1)]
def test_add_by_tag(make_site):
site = make_site("tags")
site.discover()
assert set(site.by_tag) == {"python", "testing"}
assert {c.title for c in site.by_tag["python"]} == {"One Tag", "Two Tags"}
assert {c.title for c in site.by_tag["testing"]} == {"Two Tags"}