import argparse import datetime import shutil import sys from dataclasses import dataclass from pathlib import Path from typing import Any from typing import Dict from typing import NamedTuple from typing import Optional from typing import Sequence from typing import Union import jinja2 import marshmallow as msh import ruamel.yaml yaml = ruamel.yaml.YAML(typ="safe") @dataclass class MediaContainer: title: str source: str preview: Optional[str] = None anchor: Optional[Union[str, int]] = None content: Optional[str] = None hide_source: bool = False class MediaSerializer(msh.Schema): title = msh.fields.String(required=True) source = msh.fields.String(required=True) preview = msh.fields.String(required=False) anchor = msh.fields.String(required=False) content = msh.fields.String(required=False) hide_source = msh.fields.Boolean(required=False) @msh.post_load def _make_dataclass(self, data: Dict[str, Any], *args, **kwargs) -> MediaContainer: return MediaContainer(**data) @dataclass class LinkContainer: link: str title: Optional[str] = None icon: Optional[str] = None class LinkSerializer(msh.Schema): link = msh.fields.URL(required=True) title = msh.fields.String(required=False) icon = msh.fields.String(required=False) @msh.post_load def _make_dataclass(self, data: Dict[str, Any], *args, **kwargs) -> LinkContainer: return LinkContainer(**data) class Location(NamedTuple): title: str link: str class LocationSeralizer(msh.Schema): title = msh.fields.String(required=True) link = msh.fields.URL(required=True) @msh.post_load def _make_dataclass(self, data: Dict[str, Any], *args, **kwargs) -> Location: return Location(**data) @dataclass class PostContainer: title: str location: Location date: datetime.date banner: str media: Sequence[MediaContainer] links: Sequence[LinkContainer] = () slug: Optional[str] = None class PostSerializer(msh.Schema): title = msh.fields.String(required=True) location = msh.fields.Nested(LocationSeralizer, required=True) date = msh.fields.Date("%Y-%m-%d", required=True) banner = msh.fields.URL(required=True) slug = msh.fields.String(required=False) links = msh.fields.List(msh.fields.Nested(LinkSerializer), required=False) media = msh.fields.List(msh.fields.Nested(MediaSerializer), required=True) @msh.validates_schema def _unique_anchors(self, data: Dict[str, Any], **kwargs): anchors = [item.anchor for item in data["media"] if item.anchor is not None] if len(anchors) != len(set(anchors)): raise msh.ValidationError( f"Media anchors used multiple times: {set([item for item in anchors if anchors.count(item) > 1])}" ) @msh.post_load def _make_dataclass(self, data: Dict[str, Any], *args, **kwargs) -> PostContainer: for index, item in enumerate(data["media"]): item.anchor = item.anchor or index data["media"][index] = item return PostContainer(**data) class ConfigSerializer(msh.Schema): static = msh.fields.List(msh.fields.String(), required=False) posts = msh.fields.List(msh.fields.Nested(PostSerializer), required=True) @msh.validates_schema def _unique_slugs(self, data: Dict[str, Any], **kwargs): slugs = [item.slug for item in data["posts"] if item.slug is not None] if len(slugs) != len(set(slugs)): raise msh.ValidationError( f"Post slugs used multiple times: {set([item for item in slugs if slugs.count(item) > 1])}" ) def get_args() -> argparse.Namespace: parser = argparse.ArgumentParser() parser.add_argument( "--config", help="Path to the config file", default=(Path.cwd() / "config.yaml") ) parser.add_argument( "-c", "--check", action="store_true", help="Check the config without building" ) parser.add_argument("-p", "--publish", action="store_true", help="Publish the site") return parser.parse_args() def main(): args = get_args() cwd = Path.cwd().resolve() output = cwd / "build" explore = output / "explore" with Path(args.config).resolve().open() as infile: config = ConfigSerializer().load(yaml.load(infile)) if args.check: return 0 env = jinja2.Environment( loader=jinja2.FileSystemLoader(str(cwd / "templates")), autoescape=jinja2.select_autoescape(["html", "xml"]), ) output.mkdir(exist_ok=True) explore.mkdir(exist_ok=True) index = env.get_template("index.html.j2").render(config=config) with (explore / "index.html").open("w") as outfile: outfile.write(index) sitemap = env.get_template("sitemap.xml.j2").render(config=config) with (output / "sitemap.xml").open("w") as outfile: outfile.write(sitemap) for static in config["static"]: dest = Path(output / static).resolve() dest.parent.mkdir(parents=True, exist_ok=True) shutil.copyfile(static, str(output / static), follow_symlinks=True) post_template = env.get_template("post.html.j2") for post_data in config["posts"]: post = post_template.render(post=post_data) with (explore / f"{post_data.slug}.html").open("w") as outfile: outfile.write(post) nginx = env.get_template("nginx.conf.d.j2").render(config=config) with (cwd / "nginx.conf").open("w") as outfile: outfile.write(nginx) if __name__ == "__main__": sys.exit(main())