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 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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
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()
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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()):
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
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"}
|
||||||
|
|||||||
Reference in New Issue
Block a user