Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0fa7d8fe2 | ||
|
|
f18392c899 | ||
|
|
bfb0d371c8 | ||
|
|
a5de93576a | ||
|
|
728b1ee1bf | ||
|
|
48af9665f0 |
+89
-5
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
|
||||
+36
-4
@@ -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)
|
||||
with open(config_path, "r") as f:
|
||||
config = yaml.safe_load(f)
|
||||
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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user