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 logging
import sys 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 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 = logging.getLogger("omicron.ssg")
logger.setLevel(logging.DEBUG) 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")) console.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))
logger.addHandler(console) 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: def main() -> None:
setup_logging(verbose=True) try:
site = Site(Path(".")) cli()
site.build() 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.directory import Directory
from omicron.ssg.output.symlink import Symlink from omicron.ssg.output.symlink import Symlink
from omicron.ssg.output.tags import Tags, discover_tags 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__ = [ __all__ = [
"Output", "Output",
@@ -23,4 +25,7 @@ __all__ = [
"Symlink", "Symlink",
"Tags", "Tags",
"discover_tags", "discover_tags",
"Sitemap",
"Feed",
"discover_feeds",
] ]
+2 -1
View File
@@ -23,12 +23,13 @@ class Article(Content):
source, source,
meta["section"], meta["section"],
meta["slug"], meta["slug"],
meta["title"],
meta["date"], meta["date"],
meta.get("updated", None), meta.get("updated", None),
set(meta["tags"]) if "tags" in meta else None, set(meta["tags"]) if "tags" in meta else None,
meta.get("summary", None), meta.get("summary", None),
meta.get("draft", False),
) )
self.title: str = meta["title"]
def handle_reference(self, path: Path) -> Content | File: def handle_reference(self, path: Path) -> Content | File:
site = self.site site = self.site
+5
View File
@@ -24,10 +24,12 @@ class Content(Output):
source: Path, source: Path,
section: str, section: str,
slug: str, slug: str,
title: str,
created: date, created: date,
updated: date | None = None, updated: date | None = None,
tags: set[str] | None = None, tags: set[str] | None = None,
summary: str | None = None, summary: str | None = None,
draft: bool = False,
): ):
super().__init__(site, Content.build_path(section, slug)) super().__init__(site, Content.build_path(section, slug))
if tags is None: if tags is None:
@@ -38,10 +40,13 @@ class Content(Output):
raise ContentError("content source path must be normalized") raise ContentError("content source path must be normalized")
self.source = source self.source = source
self.section: str = section self.section: str = section
self.title: str = title
self.created = created self.created = created
self.updated = updated self.updated = updated
self.tags: set[str] = tags self.tags: set[str] = tags
self.summary: str | None = summary self.summary: str | None = summary
self.draft: bool = draft
self.in_sitemap = True
@staticmethod @staticmethod
def build_path(section: str, slug: str) -> Path: 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.page_num = page_num
self.total_pages = total_pages self.total_pages = total_pages
self.label = label self.label = label
self.in_sitemap = kind != "tag" and page_num == 1
def pagination_url(self, n: int) -> str: def pagination_url(self, n: int) -> str:
path = Index.build_path(self.kind, n, self.label) path = Index.build_path(self.kind, n, self.label)
+2
View File
@@ -31,6 +31,8 @@ class Output(ABC):
url = url.parent url = url.parent
self.url = url.as_posix() self.url = url.as_posix()
self.in_sitemap = False
@property @property
def site(self) -> Site: def site(self) -> Site:
obj = self._site_ref() 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]]): def __init__(self, site: Site, tags: list[tuple[str, int]]):
super().__init__(site, Tags.PATH) super().__init__(site, Tags.PATH)
self.tags = tags self.tags = tags
self.in_sitemap = True
def write(self) -> None: def write(self) -> None:
template = self.site.jinja_env.get_template("tags.html") template = self.site.jinja_env.get_template("tags.html")
+36 -4
View File
@@ -1,5 +1,6 @@
import logging import logging
from typing import Any, cast import shutil
from typing import Any, Literal, cast
from urllib.parse import urlparse from urllib.parse import urlparse
from omicron.ssg.output import ( from omicron.ssg.output import (
@@ -14,8 +15,11 @@ from omicron.ssg.output import (
Index, Index,
Tags, Tags,
Memory, Memory,
Sitemap,
Feed,
discover_index, discover_index,
discover_tags, discover_tags,
discover_feeds,
) )
from omicron.ssg.markdown import highlight_style from omicron.ssg.markdown import highlight_style
@@ -31,8 +35,9 @@ class ConfigError(RuntimeError):
class Site: class Site:
def __init__(self, site: Path): def __init__(self, site: Path, drafts: bool = False):
self.site_path = site.resolve() self.site_path = site.resolve()
self.drafts = drafts
self.content: list[Content] = [] self.content: list[Content] = []
self.directories: list[Directory] = [] self.directories: list[Directory] = []
self.other_outputs: list[Output] = [] self.other_outputs: list[Output] = []
@@ -54,8 +59,11 @@ class Site:
def read_config(self) -> dict[str, Any]: def read_config(self) -> dict[str, Any]:
config_path = self.site_path / "config.yml" config_path = self.site_path / "config.yml"
log.debug("Reading config file %s", config_path) log.debug("Reading config file %s", config_path)
with open(config_path, "r") as f: try:
config = yaml.safe_load(f) 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): 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") raise ConfigError("config file must be a YAML mapping with string keys")
if "name" not in config: if "name" not in config:
@@ -147,6 +155,12 @@ class Site:
def tags_url(self) -> str: def tags_url(self) -> str:
return self.by_path[Tags.PATH].url 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: def update_tags_by_count(self) -> None:
tags = [(tag, len(items)) for tag, items in self.by_tag.items()] tags = [(tag, len(items)) for tag, items in self.by_tag.items()]
tags.sort(key=lambda x: x[1], reverse=True) tags.sort(key=lambda x: x[1], reverse=True)
@@ -174,6 +188,9 @@ class Site:
for file in content_path.rglob("*"): for file in content_path.rglob("*"):
if file.is_file() and is_content(file): if file.is_file() and is_content(file):
content = create_content(self, 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_path(content)
self.add_by_section(content) self.add_by_section(content)
self.add_by_tag(content) self.add_by_tag(content)
@@ -186,8 +203,23 @@ 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:
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)) 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: def build(self) -> None:
if not self.origin: if not self.origin:
log.warning( 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/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.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 %} {% block head %}{% endblock %}
</head> </head>
<body> <body>
+3
View File
@@ -4,6 +4,9 @@
{% 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 %}
{% if page.kind == "section" %}
<link rel="alternate" type="application/atom+xml" title="{{ page.label }}" href="{{ site.feed_url('section', page.label) }}">
{% endif %}
{% endblock %} {% endblock %}
{% block breadcrumb %} {% block breadcrumb %}