Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0fa7d8fe2 | ||
|
|
f18392c899 | ||
|
|
bfb0d371c8 | ||
|
|
a5de93576a | ||
|
|
728b1ee1bf | ||
|
|
48af9665f0 |
+89
-5
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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.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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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]]):
|
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
@@ -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(
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
Reference in New Issue
Block a user