Compare commits

...
8 Commits
33 changed files with 701 additions and 93 deletions
+2 -1
View File
@@ -2,7 +2,8 @@ import argparse
import logging import logging
import sys import sys
from omicron.ssg.output import OutputError from omicron.ssg.output import OutputError
from omicron.ssg.site import ConfigError, Site from omicron.ssg.site import Site
from omicron.ssg.config import ConfigError
from pathlib import Path from pathlib import Path
+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,
)
+1 -1
View File
@@ -14,7 +14,7 @@ else:
class Directory(Output): class Directory(Output):
def __init__(self, site: Site, destination: Path): def __init__(self, site: Site, destination: Path):
super().__init__(site, destination) super().__init__(site, destination)
url_path = Path("/") / site.base_dir / destination url_path = Path("/") / site.config.base_dir / destination
# rebuild url because Output strips /index.html which is a valid dir # rebuild url because Output strips /index.html which is a valid dir
self.url = url_path.as_posix() self.url = url_path.as_posix()
+4 -3
View File
@@ -46,8 +46,9 @@ class Feed(Output):
return Path(label) / file return Path(label) / file
def write(self) -> None: def write(self) -> None:
feed_url = f"{self.site.origin}{self.url}" config = self.site.config
title = f"{self.site.name}{self.label}" if self.label else self.site.name feed_url = f"{config.origin}{self.url}"
title = f"{config.name}{self.label}" if self.label else config.name
updated = max( updated = max(
(c.updated or c.created for c in self.items), default=date.today() (c.updated or c.created for c in self.items), default=date.today()
) )
@@ -60,7 +61,7 @@ class Feed(Output):
for item in self.items: for item in self.items:
entry = SubElement(feed, "entry") entry = SubElement(feed, "entry")
item_url = f"{self.site.origin}{item.url}" item_url = f"{config.origin}{item.url}"
SubElement(entry, "title").text = item.title SubElement(entry, "title").text = item.title
SubElement(entry, "id").text = item_url SubElement(entry, "id").text = item_url
SubElement(entry, "link", href=item_url) SubElement(entry, "link", href=item_url)
+4 -5
View File
@@ -89,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
+1 -1
View File
@@ -26,7 +26,7 @@ class Output(ABC):
raise OutputError("destination path must be normalized") raise OutputError("destination path must be normalized")
self.destination = destination self.destination = destination
url = site.base_dir / destination url = site.config.base_dir / destination
if url.name == "index.html": if url.name == "index.html":
url = url.parent url = url.parent
self.url = url.as_posix() self.url = url.as_posix()
+1 -1
View File
@@ -27,7 +27,7 @@ class Sitemap(Output):
continue continue
url = SubElement(urlset, "url") url = SubElement(urlset, "url")
SubElement(url, "loc").text = f"{self.site.origin}{entry.url}" SubElement(url, "loc").text = f"{self.site.config.origin}{entry.url}"
if isinstance(entry, Content): if isinstance(entry, Content):
lastmod = entry.updated or entry.created lastmod = entry.updated or entry.created
SubElement(url, "lastmod").text = lastmod.isoformat() SubElement(url, "lastmod").text = lastmod.isoformat()
+8 -54
View File
@@ -1,7 +1,6 @@
import logging import logging
import shutil import shutil
from typing import Any, Literal, cast from typing import Literal
from urllib.parse import urlparse
from omicron.ssg.output import ( from omicron.ssg.output import (
Output, Output,
@@ -22,18 +21,14 @@ from omicron.ssg.output import (
discover_feeds, 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, drafts: bool = False): def __init__(self, site: Path, drafts: bool = False):
self.site_path = site.resolve() self.site_path = site.resolve()
@@ -47,50 +42,11 @@ class Site:
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)
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 @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
@@ -137,12 +93,12 @@ class Site:
self.by_source[content.source] = content self.by_source[content.source] = content
def home_url(self) -> str: def home_url(self) -> str:
return self.base_dir.as_posix() return self.config.base_dir.as_posix()
def url_for(self, path: str) -> str: def url_for(self, path: str) -> str:
if Path(path).is_absolute(): if Path(path).is_absolute():
raise OutputError("path must be relative") raise OutputError("path must be relative")
return (self.base_dir / path).as_posix() 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)
@@ -204,7 +160,7 @@ 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.origin: if self.config.origin:
sitemap = Sitemap(self) sitemap = Sitemap(self)
self.add_by_path(sitemap) self.add_by_path(sitemap)
self.other_outputs.append(sitemap) self.other_outputs.append(sitemap)
@@ -222,13 +178,11 @@ class Site:
log.info("Removed %s", 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 not self.base_dir.is_absolute():
raise ConfigError("base_url config value must be an absolute path")
self.discover() self.discover()
outputs: list[Output] = [*self.directories, *self.content] outputs: list[Output] = [*self.directories, *self.content]
for output in sorted(outputs, key=lambda o: o.destination.as_posix()): for output in sorted(outputs, key=lambda o: o.destination.as_posix()):
+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>
+5 -5
View File
@@ -3,22 +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.url_for('assets/style.css') }}"> <link rel="stylesheet" href="{{ site.url_for('assets/style.css') }}">
<link rel="stylesheet" href="{{ site.url_for('assets/pygments.css') }}"> <link rel="stylesheet" href="{{ site.url_for('assets/pygments.css') }}">
{% if site.origin %}<link rel="canonical" href="{{ site.origin }}{{ page.url }}">{% endif %} {% 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.name }}" href="{{ site.feed_url('root', None) }}">{% 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>
+3 -3
View File
@@ -1,6 +1,6 @@
{% 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 %}
@@ -12,12 +12,12 @@
{% 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 %}
+1
View File
@@ -41,6 +41,7 @@ source = ["omicron"]
[tool.coverage.report] [tool.coverage.report]
show_missing = true show_missing = true
exclude_also = ["@(abc\\.)?abstractmethod"]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["."] where = ["."]
@@ -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.
+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
+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"
+188 -15
View File
@@ -1,19 +1,192 @@
from pathlib import Path from pathlib import Path
import pytest import pytest
from omicron.ssg.site import Site from omicron.ssg.output import ContentError, Directory, OutputError
from omicron.ssg.site import ConfigError, Site
@pytest.mark.parametrize( def test_discover_raises_on_duplicate_destination(make_site):
"value, expected_origin, expected_path", site = make_site("duplicate_slug")
[ with pytest.raises(ContentError):
(None, "", Path("/")), site.discover()
("http://example.com/blog", "http://example.com", Path("/blog")),
("http://example.com", "http://example.com", Path("/")),
("//example.com/blog", "//example.com", Path("/blog")), def test_discover_creates_content_directories(make_site):
("/blog", "", Path("/blog")), site = make_site("main")
], site.discover()
)
def test_parse_base_url(value, expected_origin, expected_path): assert isinstance(site.by_path[Path("posts")], Directory)
origin, path = Site.parse_base_url(value) assert isinstance(site.by_path[Path("posts/first-post")], Directory)
assert origin == expected_origin
assert path == expected_path
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"}