Compare commits

...
3 Commits
Author SHA1 Message Date
omicron a1ecd0f480 Allow local template overrides in the site directory
closes #13
2026-10-20 19:25:55 +02:00
omicron 7bf04ff064 Implement cli init command
closes #1
2026-10-20 18:05:15 +02:00
omicron 278d3e937e Add to_dict and to_file API to SiteConfig 2026-10-19 01:23:21 +02:00
6 changed files with 213 additions and 9 deletions

No files matched your search

+76 -2
View File
@@ -1,11 +1,14 @@
import argparse
import logging
import sys
from collections.abc import Callable
from omicron.ssg.output import OutputError
from omicron.ssg.site import Site
from omicron.ssg.config import ConfigError
from omicron.ssg.config import ConfigError, SiteConfig, DEFAULT_ITEMS_PER_PAGE
from pathlib import Path
log = logging.getLogger(__name__)
class CliError(RuntimeError):
pass
@@ -46,8 +49,15 @@ def setup_argparse() -> argparse.ArgumentParser:
default=False,
help="include draft content",
)
# clean command
subparsers.add_parser("clean", help="remove the output directory")
# init command
subparsers.add_parser(
"init", help="create a new site directory", formatter_class=help_formatter
)
return parser
@@ -84,15 +94,79 @@ def clean(args: argparse.Namespace) -> None:
site.clean()
def prompt(
label: str,
explanation: str | None = None,
default: str | None = None,
required: bool = True,
validate: Callable[[str], bool] | None = None,
error: str = "Invalid value.",
) -> str:
if explanation:
print(explanation)
hint = f" [{default}]" if default is not None else ""
while True:
value = input(f"{label}{hint}: ").strip()
if not value and default is not None:
value = default
if not value and not required:
return ""
if not value:
print("This field is required.")
continue
if validate and not validate(value):
print(error)
continue
return value
def init(args: argparse.Namespace) -> None:
try:
args.site.mkdir(parents=True, exist_ok=True)
except (FileExistsError, NotADirectoryError) as e:
raise CliError(f"{args.site} is not a directory") from e
if any(args.site.iterdir()):
raise CliError(f"{args.site} is not empty")
config_path = args.site / "config.yml"
name = prompt("Site name")
base_url = prompt(
"Base URL", "Optional, e.g. https://example.com/blog", required=False
)
template = prompt("Template", default="plain")
items_per_page = prompt(
"Items per page",
default=str(DEFAULT_ITEMS_PER_PAGE),
validate=lambda v: v.isdigit() and int(v) > 0,
error="Items per page must be a positive integer.",
)
config = SiteConfig.from_dict(
{
"name": name,
"base_url": base_url,
"template": template,
"items_per_page": int(items_per_page),
}
)
config.to_file(config_path)
(args.site / "content").mkdir(exist_ok=True)
log.info("Created new site at %s", args.site)
def cli() -> None:
parser = setup_argparse()
args = parser.parse_args()
log_file = getattr(args, "log_file", args.site / "build.log")
default_log_file = None if args.command == "init" else args.site / "build.log"
log_file = getattr(args, "log_file", default_log_file)
setup_logging(verbose=bool(args.verbose), log_file=log_file)
if args.command == "build":
build(args)
elif args.command == "clean":
clean(args)
elif args.command == "init":
init(args)
else:
raise NotImplementedError(f"'{args.command}' command not implemented yet")
+13
View File
@@ -99,3 +99,16 @@ class SiteConfig:
items_per_page=items_per_page,
template=template,
)
def to_dict(self) -> dict[str, str | int]:
return {
"name": self.name,
"template": self.template,
"items_per_page": self.items_per_page,
"base_url": f"{self.origin}{self.base_dir}",
}
def to_file(self, path: Path) -> None:
log.debug("Writing config file %s", path)
with open(path, "w") as f:
yaml.safe_dump(self.to_dict(), f, default_flow_style=False)
+9 -5
View File
@@ -27,6 +27,7 @@ from pathlib import Path
from jinja2 import Environment, FileSystemLoader
log = logging.getLogger(__name__)
builtin_templates = Path(__file__).parent / "templates"
class Site:
@@ -43,15 +44,18 @@ class Site:
self.tags_by_count: list[tuple[str, int]] = []
self.config = SiteConfig.from_file(site / "config.yml")
self.template_path: Path = Site.resolve_template_path(self.config.template)
self.template_path: Path = self.resolve_template_path(self.config.template)
self.output_path: Path = self.site_path / "output"
self.jinja_env = Environment(loader=FileSystemLoader(self.template_path))
@staticmethod
def resolve_template_path(template: str) -> Path:
path = Path(__file__).parent / "templates" / template
def resolve_template_path(self, template: str) -> Path:
path = self.site_path / "templates" / template
if path.is_dir():
return path
path = builtin_templates / template
if not path.is_dir():
raise ConfigError(f"template '{template}' not found at {path}")
raise ConfigError(f"template '{template}' not found.")
return path
def add_by_section(self, content: Content) -> None:
+36
View File
@@ -0,0 +1,36 @@
import subprocess
import yaml
def test_init_creates_site(tmp_path):
site_dir = tmp_path / "newsite"
result = subprocess.run(
["ossg", "--site", str(site_dir), "init"],
input="My Site\nhttps://example.com\n\n\n",
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stderr
config = yaml.safe_load((site_dir / "config.yml").read_text())
assert config == {
"name": "My Site",
"base_url": "https://example.com/",
"template": "plain",
"items_per_page": 20,
}
assert (site_dir / "content").is_dir()
def test_init_refuses_non_empty_dir(tmp_path):
(tmp_path / "some_file").touch()
result = subprocess.run(
["ossg", "--site", str(tmp_path), "init"],
input="My Site\n\n\n\n",
capture_output=True,
text=True,
)
assert result.returncode == 1
assert "is not empty" in result.stdout
+57
View File
@@ -58,6 +58,48 @@ def test_from_dict_full():
assert config.base_dir == Path("/blog")
def test_to_dict():
config = SiteConfig(
name="My Site",
origin="http://example.com",
base_dir=Path("/blog"),
items_per_page=42,
template="plain",
)
assert config.to_dict() == {
"name": "My Site",
"template": "plain",
"items_per_page": 42,
"base_url": "http://example.com/blog",
}
def test_to_dict_default_base_url():
config = SiteConfig(
name="My Site",
origin="",
base_dir=Path("/"),
items_per_page=DEFAULT_ITEMS_PER_PAGE,
template="plain",
)
assert config.to_dict()["base_url"] == "/"
def test_to_dict_round_trip():
original = SiteConfig.from_dict(
{
"name": "My Site",
"template": "plain",
"base_url": "http://example.com/blog",
"items_per_page": 42,
}
)
assert SiteConfig.from_dict(original.to_dict()) == original
MISSING = object()
@@ -128,6 +170,21 @@ def test_from_file_full(tmp_path):
assert config.base_dir == Path("/blog")
def test_to_file(tmp_path):
path = tmp_path / "config.yml"
config = SiteConfig(
name="My Site",
origin="http://example.com",
base_dir=Path("/blog"),
items_per_page=42,
template="plain",
)
config.to_file(path)
assert SiteConfig.from_file(path) == config
def test_from_file_missing(tmp_path):
path = tmp_path / "config.yml"
+22 -2
View File
@@ -78,9 +78,29 @@ def test_clean_removes_output_directory(make_site):
assert not site.output_path.exists()
def test_resolve_template_path_raises_for_missing_template():
def test_resolve_template_path_raises_for_missing_template(site_dir):
path = site_dir("thin_site")
(path / "config.yml").write_text("name: test\ntemplate: does-not-exist\n")
with pytest.raises(ConfigError, match="not found"):
Site.resolve_template_path("does-not-exist")
Site(path)
def test_resolve_template_path_prefers_local_override(site_dir):
path = site_dir("thin_site")
(path / "config.yml").write_text("name: test\ntemplate: plain\n")
local = path / "templates" / "plain"
local.mkdir(parents=True)
assert Site(path).template_path == local
def test_resolve_template_path_falls_back_to_builtin(site_dir):
path = site_dir("thin_site")
(path / "config.yml").write_text("name: test\ntemplate: plain\n")
assert Site(path).template_path.name == "plain"
assert not Site(path).template_path.is_relative_to(path)
def test_home_url(site_dir):