Compare commits

..
6 Commits
12 changed files with 266 additions and 10 deletions
+89 -5
View File
@@ -1,10 +1,56 @@
import argparse
import logging
import sys
from omicron.ssg.site import Site
from omicron.ssg.output import OutputError
from omicron.ssg.site import ConfigError, Site
from pathlib import Path
def setup_logging(verbose: bool = False, log_file: str | None = None) -> None:
class CliError(RuntimeError):
pass
def setup_argparse() -> argparse.ArgumentParser:
# base options
help_formatter = argparse.ArgumentDefaultsHelpFormatter
parser = argparse.ArgumentParser(prog="ossg", formatter_class=help_formatter)
parser.add_argument(
"--site", type=Path, default=Path("."), help="path to site directory"
)
parser.add_argument(
"--verbose", "-v", action="count", default=0, help="increase log verbosity"
)
parser.add_argument(
"--log-file",
type=Path,
default=argparse.SUPPRESS,
help="write logs to this file (default: <site>/build.log)",
)
subparsers = parser.add_subparsers(dest="command", required=True)
# build command
build = subparsers.add_parser(
"build", help="build the site", formatter_class=help_formatter
)
build.add_argument(
"--clean",
action=argparse.BooleanOptionalAction,
default=True,
help="remove output directory before building",
)
build.add_argument(
"--drafts",
action=argparse.BooleanOptionalAction,
default=False,
help="include draft content",
)
subparsers.add_parser("clean", help="remove the output directory")
return parser
def setup_logging(verbose: bool = False, log_file: Path | None = None) -> None:
logger = logging.getLogger("omicron.ssg")
logger.setLevel(logging.DEBUG)
@@ -13,8 +59,46 @@ def setup_logging(verbose: bool = False, log_file: str | None = None) -> None:
console.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))
logger.addHandler(console)
if log_file is not None:
try:
file_handler = logging.FileHandler(log_file)
except FileNotFoundError as e:
raise CliError("log file can't be written") from e
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(
logging.Formatter("%(asctime)s %(levelname)s: %(message)s")
)
logger.addHandler(file_handler)
def build(args: argparse.Namespace) -> None:
site = Site(args.site, args.drafts)
if args.clean:
site.clean()
site.build()
def clean(args: argparse.Namespace) -> None:
site = Site(args.site)
site.clean()
def cli() -> None:
parser = setup_argparse()
args = parser.parse_args()
log_file = getattr(args, "log_file", args.site / "build.log")
setup_logging(verbose=bool(args.verbose), log_file=log_file)
if args.command == "build":
build(args)
elif args.command == "clean":
clean(args)
else:
raise NotImplementedError(f"'{args.command}' command not implemented yet")
def main() -> None:
setup_logging(verbose=True)
site = Site(Path("."))
site.build()
try:
cli()
except (ConfigError, OutputError, CliError) as e:
print(e)
sys.exit(1)
+5
View File
@@ -7,6 +7,8 @@ from omicron.ssg.output.memory import Memory
from omicron.ssg.output.directory import Directory
from omicron.ssg.output.symlink import Symlink
from omicron.ssg.output.tags import Tags, discover_tags
from omicron.ssg.output.sitemap import Sitemap
from omicron.ssg.output.feed import Feed, discover_feeds
__all__ = [
"Output",
@@ -23,4 +25,7 @@ __all__ = [
"Symlink",
"Tags",
"discover_tags",
"Sitemap",
"Feed",
"discover_feeds",
]
+2 -1
View File
@@ -23,12 +23,13 @@ class Article(Content):
source,
meta["section"],
meta["slug"],
meta["title"],
meta["date"],
meta.get("updated", None),
set(meta["tags"]) if "tags" in meta else None,
meta.get("summary", None),
meta.get("draft", False),
)
self.title: str = meta["title"]
def handle_reference(self, path: Path) -> Content | File:
site = self.site
+5
View File
@@ -24,10 +24,12 @@ class Content(Output):
source: Path,
section: str,
slug: str,
title: str,
created: date,
updated: date | None = None,
tags: set[str] | None = None,
summary: str | None = None,
draft: bool = False,
):
super().__init__(site, Content.build_path(section, slug))
if tags is None:
@@ -38,10 +40,13 @@ class Content(Output):
raise ContentError("content source path must be normalized")
self.source = source
self.section: str = section
self.title: str = title
self.created = created
self.updated = updated
self.tags: set[str] = tags
self.summary: str | None = summary
self.draft: bool = draft
self.in_sitemap = True
@staticmethod
def build_path(section: str, slug: str) -> Path:
+83
View File
@@ -0,0 +1,83 @@
import logging
from datetime import date
from pathlib import Path
from typing import TYPE_CHECKING, Literal
from xml.etree.ElementTree import Element, ElementTree, SubElement
from omicron.ssg.output.output import Output
from omicron.ssg.output.content import Content
log = logging.getLogger(__name__)
if TYPE_CHECKING:
from omicron.ssg.site import Site # for static checking with mypy
else:
Site = "omicron.ssg.site.Site" # for runtime checking with beartype
ITEMS_PER_FEED = 20
def atom_date(d: date) -> str:
return f"{d.isoformat()}T00:00:00Z"
class Feed(Output):
NAMESPACE = "http://www.w3.org/2005/Atom"
def __init__(
self,
site: Site,
kind: Literal["root", "section"],
items: list[Content],
label: str | None,
):
super().__init__(site, Feed.build_path(kind, label))
self.kind = kind
self.label = label
self.items = sorted(items, key=lambda c: c.created, reverse=True)[
:ITEMS_PER_FEED
]
@staticmethod
def build_path(kind: Literal["root", "section"], label: str | None) -> Path:
file = Path("atom.xml")
if kind == "root":
return file
assert label, "label can't be None if kind is not root"
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
updated = max(
(c.updated or c.created for c in self.items), default=date.today()
)
feed = Element("feed", xmlns=Feed.NAMESPACE)
SubElement(feed, "title").text = title
SubElement(feed, "id").text = feed_url
SubElement(feed, "link", rel="self", href=feed_url)
SubElement(feed, "updated").text = atom_date(updated)
for item in self.items:
entry = SubElement(feed, "entry")
item_url = f"{self.site.origin}{item.url}"
SubElement(entry, "title").text = item.title
SubElement(entry, "id").text = item_url
SubElement(entry, "link", href=item_url)
SubElement(entry, "published").text = atom_date(item.created)
SubElement(entry, "updated").text = atom_date(item.updated or item.created)
if item.summary:
summary = SubElement(entry, "summary", type="html")
summary.text = str(item.summary)
dest = self.site.output_path / self.destination
dest.parent.mkdir(parents=True, exist_ok=True)
ElementTree(feed).write(dest, encoding="utf-8", xml_declaration=True)
log.debug("Wrote %s", dest)
def discover_feeds(site: Site) -> list[Feed]:
outputs = [Feed(site, "root", site.content, None)]
for section, items in site.by_section.items():
outputs.append(Feed(site, "section", items, section))
return outputs
+1
View File
@@ -30,6 +30,7 @@ class Index(Output):
self.page_num = page_num
self.total_pages = total_pages
self.label = label
self.in_sitemap = kind != "tag" and page_num == 1
def pagination_url(self, n: int) -> str:
path = Index.build_path(self.kind, n, self.label)
+2
View File
@@ -31,6 +31,8 @@ class Output(ABC):
url = url.parent
self.url = url.as_posix()
self.in_sitemap = False
@property
def site(self) -> Site:
obj = self._site_ref()
+38
View File
@@ -0,0 +1,38 @@
import logging
from pathlib import Path
from typing import TYPE_CHECKING
from xml.etree.ElementTree import Element, ElementTree, SubElement
from omicron.ssg.output.output import Output
from omicron.ssg.output.content import Content
log = logging.getLogger(__name__)
if TYPE_CHECKING:
from omicron.ssg.site import Site
else:
Site = "omicron.ssg.site.Site"
class Sitemap(Output):
PATH = Path("sitemap.xml")
NAMESPACE = "http://www.sitemaps.org/schemas/sitemap/0.9"
def __init__(self, site: Site):
super().__init__(site, Sitemap.PATH)
def write(self) -> None:
urlset = Element("urlset", xmlns=Sitemap.NAMESPACE)
for entry in self.site.by_path.values():
if not entry.in_sitemap:
continue
url = SubElement(urlset, "url")
SubElement(url, "loc").text = f"{self.site.origin}{entry.url}"
if isinstance(entry, Content):
lastmod = entry.updated or entry.created
SubElement(url, "lastmod").text = lastmod.isoformat()
dest = self.site.output_path / self.destination
dest.parent.mkdir(parents=True, exist_ok=True)
ElementTree(urlset).write(dest, encoding="utf-8", xml_declaration=True)
log.debug("Wrote %s", dest)
+1
View File
@@ -17,6 +17,7 @@ class Tags(Output):
def __init__(self, site: Site, tags: list[tuple[str, int]]):
super().__init__(site, Tags.PATH)
self.tags = tags
self.in_sitemap = True
def write(self) -> None:
template = self.site.jinja_env.get_template("tags.html")
+34 -2
View File
@@ -1,5 +1,6 @@
import logging
from typing import Any, cast
import shutil
from typing import Any, Literal, cast
from urllib.parse import urlparse
from omicron.ssg.output import (
@@ -14,8 +15,11 @@ from omicron.ssg.output import (
Index,
Tags,
Memory,
Sitemap,
Feed,
discover_index,
discover_tags,
discover_feeds,
)
from omicron.ssg.markdown import highlight_style
@@ -31,8 +35,9 @@ class ConfigError(RuntimeError):
class Site:
def __init__(self, site: Path):
def __init__(self, site: Path, drafts: bool = False):
self.site_path = site.resolve()
self.drafts = drafts
self.content: list[Content] = []
self.directories: list[Directory] = []
self.other_outputs: list[Output] = []
@@ -54,8 +59,11 @@ class Site:
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:
@@ -147,6 +155,12 @@ class Site:
def tags_url(self) -> str:
return self.by_path[Tags.PATH].url
def feed_url(
self, kind: Literal["root", "section"], label: str | None
) -> str | None:
output = self.by_path.get(Feed.build_path(kind, label))
return output.url if output else None
def update_tags_by_count(self) -> None:
tags = [(tag, len(items)) for tag, items in self.by_tag.items()]
tags.sort(key=lambda x: x[1], reverse=True)
@@ -174,6 +188,9 @@ class Site:
for file in content_path.rglob("*"):
if file.is_file() and is_content(file):
content = create_content(self, file)
if content.draft and not self.drafts:
log.debug("Skipping draft content %s", file)
continue
self.add_by_path(content)
self.add_by_section(content)
self.add_by_tag(content)
@@ -186,8 +203,23 @@ class Site:
tags_page = discover_tags(self)
self.add_by_path(tags_page)
self.other_outputs.append(tags_page)
if self.origin:
sitemap = Sitemap(self)
self.add_by_path(sitemap)
self.other_outputs.append(sitemap)
for feed in discover_feeds(self):
self.add_by_path(feed)
self.other_outputs.append(feed)
else:
log.debug("Skipping sitemap and feeds, base_url has no domain name")
log.info("Discovered %d content items", len(self.content))
def clean(self) -> None:
if not self.output_path.exists():
return
shutil.rmtree(self.output_path)
log.info("Removed %s", self.output_path)
def build(self) -> None:
if not self.origin:
log.warning(
+1
View File
@@ -7,6 +7,7 @@
<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 %}
{% block head %}{% endblock %}
</head>
<body>
+3
View File
@@ -4,6 +4,9 @@
{% block head %}
{% if page.page_num > 1 %}<meta name="robots" content="noindex, follow">{% endif %}
{% if page.kind == "section" %}
<link rel="alternate" type="application/atom+xml" title="{{ page.label }}" href="{{ site.feed_url('section', page.label) }}">
{% endif %}
{% endblock %}
{% block breadcrumb %}