+
+
+def path_len_parts(p):
+ return len(p.parts)
+
+
+@contextmanager
+def cleanup_existing_output(output_path):
+ existing_output = []
+ if not output_path.exists():
+ output_path.mkdir(0o755, True, True)
+ else:
+ for current, *dirs_and_files in os.walk(output_path):
+ current_path = Path(current)
+ existing_output.extend(
+ current_path / f for f in chain.from_iterable(dirs_and_files)
+ )
+ yield existing_output
+ for path in sorted(existing_output, key=path_len_parts, reverse=True):
+ is_dir = path.is_dir()
+ print(f"deleting {str(path)}{'/' if is_dir else ''}")
+ if is_dir:
+ path.rmdir()
+ else:
+ path.unlink()
+
+
+class WebsiteGenerator:
+ STATIC_FILES = [
+ "style.css",
+ ]
+ SITEMAP_NAMESPACE = "http://www.sitemaps.org/schemas/sitemap/0.9"
+ SITEMAP_SCHEMA_URL = "http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd"
+
+ def __init__(self, base_url, build_path, output_path):
+ self.base_url = base_url
+ if build_path.exists():
+ shutil.rmtree(build_path)
+ build_path.mkdir(0o755, True)
+ self.build_path = build_path
+ self.output_path = output_path
+ self.source_path = Path(__file__).parent
+ with (self.source_path / "template.html").open("rt") as fh:
+ self.template = fh.read()
+ self.files_and_urls = []
+
+ @cached_property
+ def content_path(self):
+ return self.source_path / "content"
+
+ AutoContent = type("AutoContentType", (), {})()
+
+ def add_path(self, in_path, out_path, content=None, url=None):
+ if out_path in (x[0] for x in self.files_and_urls):
+ raise ValueError("Cannot add the same file multiple times")
+ self.files_and_urls.append((out_path, url))
+ if in_path.is_dir():
+ print(f"creating {str(out_path.relative_to(self.build_path))}/")
+ out_path.mkdir(0o755)
+ elif content is not None:
+ if content is self.AutoContent:
+ with in_path.open("rb") as fh:
+ content = fh.read()
+ print(f"writing {str(out_path.relative_to(self.build_path))}")
+ with out_path.open("wb" if isinstance(content, bytes) else "wt") as fh:
+ fh.write(content)
+ else:
+ raise ValueError("No content provided.")
+
+ def render_page(self, nav, in_path, url):
+ with in_path.open("rt") as fh:
+ renderer = MDRenderer(fh.read(), url)
+ return self.template.format(
+ nav=nav,
+ page=renderer.render_html(),
+ pagemeta=renderer.render_html_pagemeta(),
+ ), url
+
+ def get_url(self, out_path):
+ url = f"{self.base_url}{out_path.relative_to(self.build_path)}"
+ if url.endswith("/index.html"):
+ url = url[:-len("index.html")]
+ return url
+
+ def build(self):
+ print("==> building", self.base_url)
+ with (self.content_path / "nav.md").open("rt") as fh:
+ nav = MDRenderer(fh.read()).render_html()
+ index_md = self.content_path / "index.md"
+ index_html = self.build_path / "index.html"
+ self.add_path(
+ index_md,
+ index_html,
+ *self.render_page(nav, index_md, self.get_url(index_html)),
+ )
+ blog_md = self.content_path / "blog" / "index.md"
+ blog_html = self.build_path / "blog" / "index.html"
+ self.add_path(blog_md.parent, blog_html.parent)
+ self.add_path(
+ blog_md,
+ blog_html,
+ *self.render_page(nav, blog_md, self.get_url(blog_html)),
+ )
+ for static_file in self.STATIC_FILES:
+ self.add_path(
+ self.source_path / static_file,
+ self.build_path / static_file,
+ self.AutoContent,
+ )
+
+ def sync(self):
+ print("==> syncing", self.base_url)
+ with cleanup_existing_output(self.output_path) as existing_output:
+ sitemap = []
+ for src_path, url in self.files_and_urls:
+ rel_path = src_path.relative_to(self.build_path)
+ dest_path = self.output_path / rel_path
+ if dest_path in existing_output:
+ existing_output.remove(dest_path)
+ if src_path.is_dir():
+ if not dest_path.exists():
+ print("creating", str(rel_path))
+ dest_path.mkdir(0o755)
+ continue
+ update = None if dest_path.exists() else "creating"
+ with src_path.open("rb") as fh:
+ src_content = fh.read()
+ if update is None:
+ with dest_path.open("rb") as fh:
+ if sha256(src_content).digest() != sha256(fh.read()).digest():
+ update = "updating"
+ if update is not None:
+ print(update, rel_path)
+ with dest_path.open("wb") as out_fh:
+ out_fh.write(src_content)
+ if url is None:
+ continue
+ sitemap.append(
+ {
+ "loc": url,
+ "lastmod": datetime.fromtimestamp(
+ dest_path.stat().st_mtime,
+ UTC,
+ ).isoformat(timespec="seconds"),
+ }
+ )
+ self.generate_sitemap(
+ self.output_path / "sitemap.xml", sitemap, existing_output
+ )
+
+ @classmethod
+ def generate_sitemap(cls, sitemap_xml, urls, existing_output):
+ if sitemap_xml in existing_output:
+ existing_output.remove(sitemap_xml)
+ schema = xmlschema.XMLSchema(cls.SITEMAP_SCHEMA_URL)
+ with open(sitemap_xml, "wb") as fh:
+ ElementTree.register_namespace("", cls.SITEMAP_NAMESPACE)
+ fh.write(b"<?xml version='1.0' encoding='UTF-8'?>\n")
+ fh.write(
+ ElementTree.tostring(
+ schema.encode(
+ {
+ "@xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance",
+ "@xmlns": cls.SITEMAP_NAMESPACE,
+ "@xsi:schemaLocation": (
+ f"{cls.SITEMAP_NAMESPACE} {cls.SITEMAP_SCHEMA_URL}"
+ ),
+ "url": urls,
+ }
+ )
+ )
+ )
+ print(f"Validating XML {repr(sitemap_xml.name)}...", end=" ")
+ sys.stdout.flush()
+ schema.validate(sitemap_xml)
+ print("done")
+
+ def cleanup(self):
+ rmtree(self.build_path)