diff --git a/build.py b/build.py index e7b7e95..d3bb7b3 100644 --- a/build.py +++ b/build.py @@ -1,7 +1,11 @@ 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 @@ -18,21 +22,25 @@ yaml = ruamel.yaml.YAML(typ="safe") @dataclass class MediaContainer: title: str - asset: str + source: str + preview: Optional[str] = None anchor: Optional[Union[str, int]] = None - source: Optional[str] = None content: Optional[str] = None hide_source: bool = False class MediaSerializer(msh.Schema): - title = msh.fields.String() - asset = msh.fields.URL() + title = msh.fields.String(required=True) + source = msh.fields.String(required=True) + preview = msh.fields.String(required=False) anchor = msh.fields.String(required=False) - source = msh.fields.URL(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: @@ -42,10 +50,14 @@ class LinkContainer: class LinkSerializer(msh.Schema): - link = msh.fields.URL() + 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 @@ -53,8 +65,12 @@ class Location(NamedTuple): class LocationSeralizer(msh.Schema): - title = msh.fields.String() - link = msh.fields.URL() + 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 @@ -70,47 +86,93 @@ class PostContainer: class PostSerializer(msh.Schema): - title = msh.fields.String() - location = msh.fields.Nested(LocationSeralizer) - date = msh.fields.Date() - banner = msh.fields.URL() + 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() + 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(): - cwd = Path.cwd().resolve() - output = cwd / "explore" + args = get_args() - with (cwd / "config.yaml").open() as infile: - config = yaml.load(infile) + 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"]), ) - if not output.exists(): - output.mkdir() + output.mkdir(exist_ok=True) + explore.mkdir(exist_ok=True) index = env.get_template("index.html.j2").render(config=config) - - with (output / "index.html").open("w") as outfile: + 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 (output / f"{post_data['slug']}.html").open("w") as outfile: + 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) @@ -119,4 +181,4 @@ def main(): if __name__ == "__main__": - main() + sys.exit(main())