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 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
+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):
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()
+4 -3
View File
@@ -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)
+4 -5
View File
@@ -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
+1 -1
View File
@@ -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()
+1 -1
View File
@@ -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
View File
@@ -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()):
+2 -2
View File
@@ -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>
+5 -5
View File
@@ -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>
+3 -3
View File
@@ -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>
+2 -2
View File
@@ -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 %}
+1
View File
@@ -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.
+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
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"}