Compare commits
8
Commits
9d5387a40a
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab14def478 | ||
|
|
ea14de6200 | ||
|
|
4351ea8e0d | ||
|
|
f991d3b110 | ||
|
|
7297adb966 | ||
|
|
1fe2bbd1db | ||
|
|
2835b77146 | ||
|
|
088fc4d463 |
+2
-1
@@ -2,7 +2,8 @@ import argparse
|
||||
import logging
|
||||
import sys
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -14,7 +14,7 @@ else:
|
||||
class Directory(Output):
|
||||
def __init__(self, site: Site, destination: Path):
|
||||
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
|
||||
self.url = url_path.as_posix()
|
||||
|
||||
|
||||
@@ -46,8 +46,9 @@ class Feed(Output):
|
||||
return Path(label) / file
|
||||
|
||||
def write(self) -> None:
|
||||
feed_url = f"{self.site.origin}{self.url}"
|
||||
title = f"{self.site.name} — {self.label}" if self.label else self.site.name
|
||||
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()
|
||||
)
|
||||
@@ -60,7 +61,7 @@ class Feed(Output):
|
||||
|
||||
for item in self.items:
|
||||
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, "id").text = item_url
|
||||
SubElement(entry, "link", href=item_url)
|
||||
|
||||
@@ -89,14 +89,13 @@ def make_index_pages(
|
||||
|
||||
|
||||
def discover_index(site: Site) -> list[Index]:
|
||||
items_per_page = site.config.items_per_page
|
||||
outputs: list[Index] = []
|
||||
outputs.extend(
|
||||
make_index_pages(site, site.content, "root", None, site.items_per_page)
|
||||
)
|
||||
outputs.extend(make_index_pages(site, site.content, "root", None, items_per_page))
|
||||
for section, items in site.by_section.items():
|
||||
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():
|
||||
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
|
||||
|
||||
@@ -26,7 +26,7 @@ class Output(ABC):
|
||||
raise OutputError("destination path must be normalized")
|
||||
self.destination = destination
|
||||
|
||||
url = site.base_dir / destination
|
||||
url = site.config.base_dir / destination
|
||||
if url.name == "index.html":
|
||||
url = url.parent
|
||||
self.url = url.as_posix()
|
||||
|
||||
@@ -27,7 +27,7 @@ class Sitemap(Output):
|
||||
continue
|
||||
|
||||
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):
|
||||
lastmod = entry.updated or entry.created
|
||||
SubElement(url, "lastmod").text = lastmod.isoformat()
|
||||
|
||||
+8
-54
@@ -1,7 +1,6 @@
|
||||
import logging
|
||||
import shutil
|
||||
from typing import Any, Literal, cast
|
||||
from urllib.parse import urlparse
|
||||
from typing import Literal
|
||||
|
||||
from omicron.ssg.output import (
|
||||
Output,
|
||||
@@ -22,18 +21,14 @@ from omicron.ssg.output import (
|
||||
discover_feeds,
|
||||
)
|
||||
from omicron.ssg.markdown import highlight_style
|
||||
from omicron.ssg.config import SiteConfig, ConfigError
|
||||
|
||||
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()
|
||||
@@ -47,50 +42,11 @@ class Site:
|
||||
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.config = SiteConfig.from_file(site / "config.yml")
|
||||
self.template_path: Path = Site.resolve_template_path(self.config.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
|
||||
@@ -137,12 +93,12 @@ class Site:
|
||||
self.by_source[content.source] = content
|
||||
|
||||
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:
|
||||
if Path(path).is_absolute():
|
||||
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:
|
||||
path = Index.build_path("section", 1, section)
|
||||
@@ -204,7 +160,7 @@ class Site:
|
||||
tags_page = discover_tags(self)
|
||||
self.add_by_path(tags_page)
|
||||
self.other_outputs.append(tags_page)
|
||||
if self.origin:
|
||||
if self.config.origin:
|
||||
sitemap = Sitemap(self)
|
||||
self.add_by_path(sitemap)
|
||||
self.other_outputs.append(sitemap)
|
||||
@@ -222,13 +178,11 @@ class Site:
|
||||
log.info("Removed %s", self.output_path)
|
||||
|
||||
def build(self) -> None:
|
||||
if not self.origin:
|
||||
if not self.config.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()):
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ page.title }} — {{ site.name }}{% endblock %}
|
||||
{% block title %}{{ page.title }} — {{ site.config.name }}{% endblock %}
|
||||
|
||||
{% block 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> ›
|
||||
{{ page.title }}
|
||||
</nav>
|
||||
|
||||
@@ -3,22 +3,22 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<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/pygments.css') }}">
|
||||
{% if site.origin %}<link rel="canonical" href="{{ site.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.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 %}
|
||||
</head>
|
||||
<body>
|
||||
<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>
|
||||
<main>
|
||||
{% block main %}{% endblock %}
|
||||
</main>
|
||||
<footer>
|
||||
<p>{{ site.name }}</p>
|
||||
<p>{{ site.config.name }}</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% 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 %}
|
||||
{% if page.page_num > 1 %}<meta name="robots" content="noindex, follow">{% endif %}
|
||||
@@ -12,12 +12,12 @@
|
||||
{% block breadcrumb %}
|
||||
{% if page.kind == "section" %}
|
||||
<nav aria-label="breadcrumb">
|
||||
<a href="{{ site.home_url() }}">{{ site.name }}</a> ›
|
||||
<a href="{{ site.home_url() }}">{{ site.config.name }}</a> ›
|
||||
{{ page.label }}
|
||||
</nav>
|
||||
{% elif page.kind == "tag" %}
|
||||
<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> ›
|
||||
{{ page.label }}
|
||||
</nav>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Tags — {{ site.name }}{% endblock %}
|
||||
{% block title %}Tags — {{ site.config.name }}{% endblock %}
|
||||
|
||||
{% block breadcrumb %}
|
||||
<nav aria-label="breadcrumb">
|
||||
<a href="{{ site.home_url() }}">{{ site.name }}</a> ›
|
||||
<a href="{{ site.home_url() }}">{{ site.config.name }}</a> ›
|
||||
Tags
|
||||
</nav>
|
||||
{% endblock %}
|
||||
|
||||
@@ -41,6 +41,7 @@ source = ["omicron"]
|
||||
|
||||
[tool.coverage.report]
|
||||
show_missing = true
|
||||
exclude_also = ["@(abc\\.)?abstractmethod"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,2 @@
|
||||
name: Tags Test
|
||||
template: plain
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
type: article
|
||||
section: posts
|
||||
slug: no-tags
|
||||
title: No Tags
|
||||
date: 2026-08-28
|
||||
---
|
||||
No tags.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,2 @@
|
||||
name: test
|
||||
template: plain
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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
@@ -1,19 +1,192 @@
|
||||
from pathlib import Path
|
||||
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(
|
||||
"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 = Site.parse_base_url(value)
|
||||
assert origin == expected_origin
|
||||
assert path == expected_path
|
||||
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"}
|
||||
|
||||
Reference in New Issue
Block a user