from collections.abc import Iterator, Callable from pathlib import Path from dataclasses import dataclass import click import os from urllib.parse import quote from string import capwords import shutil import tempfile import re DRY = False LOG_REGEX = re.compile("(20[0-9]{2})-([0-9]{2})-([0-9]{2})-([A-z\\-]+)\\.md") @dataclass class Page: title: str url: str entries: list[Path] output: Path @staticmethod def create(source: Path, entries: list[Path], site_url: str, output_dir: Path): title = capwords(source.stem.replace("-", " ")) url = f"{site_url}/{quote(source.stem)}" output = output_dir / source.stem / "index.html" return Page(title, url, entries, output) @staticmethod def order(entries: list[Path]) -> list[Path]: log_entries = [] normal_entries = [] for e in entries: if LOG_REGEX.fullmatch(e.name): log_entries.append(e) else: normal_entries.append(e) return sorted(normal_entries, key=lambda v: v.name) + sorted(log_entries, reverse=True, key=lambda v: v.name) def renderPageCallback(template_file: Path, stylesheet_url: str) -> Callable[[Page, str], None]: """ Callback to process the provided page metadata and output the final page to the filesystem """ from jinja2 import Environment, FileSystemLoader, select_autoescape from markdown2 import markdown env = Environment( loader=FileSystemLoader(template_file.parent), autoescape=select_autoescape() ) template = env.get_template(template_file.name) def entries(page: Page) -> Iterator[str]: for entry in page.entries: with open(entry, "r") as f: yield markdown(f.read(), extras=["fenced-code-blocks", "strike", "tables"]) def render(page: Page, site_title: str, navigation: list[dict[str, str]]) -> None: print(f"Rendering {page.output}") args = {"site_title": site_title, "stylesheet_url": stylesheet_url, "navigation": navigation, "entries": entries(page)} content = template.render(args) if DRY: print(content) else: os.makedirs(page.output.parent, exist_ok=True) with open(page.output, "w") as f: f.write(content) return render def collectPages(site_root: Path, site_url: str, output_dir: Path) -> Iterator[Page]: """ Walk the site root collecting all the entries and organizing them into pages """ for f in os.listdir(site_root): page_path = site_root / f # Directories are rendered as one page with multiple entries if os.path.isdir(page_path): yield Page.create(page_path, Page.order(list(page_path.glob("**/*.md"))), site_url, output_dir) # Single .md files are rendered as single-entry pages elif page_path.suffix == ".md": yield Page.create(page_path, [page_path], site_url, output_dir) def generateSite(site_root: Path, base_url: str, output_dir: Path, render_page: Callable[[Page, str], None]) -> None: """ Generate the site located at the specified root """ print(f"Generating site rooted at {site_root}") site_url = f"{base_url}/{quote(site_root.stem)}" site_dir = output_dir / site_root.stem site_title = capwords(site_root.stem.replace("-", " ")) pages = sorted([p for p in collectPages(site_root, site_url, site_dir)], key=lambda v: v.title) navigation = [{"text": p.title, "url": p.url} for p in pages] for page in pages: render_page(page, site_title, navigation) home = Page("Home", site_url, [], site_dir / "index.html") render_page(home, site_title, navigation) def generateSites(config: dict[str, str]) -> None: """ Generate all sites according to the specified config """ docs_root = Path(config["docs_root"]) output_dir = Path(config["output_dir"]) template_file = Path(config["template_file"]) css_file = Path(config["css_file"]) base_url = config["base_url"] stylesheet_url = f"{base_url}/{css_file.name}" render_page = renderPageCallback(template_file, stylesheet_url) with tempfile.TemporaryDirectory() as tmpdir: for f in os.listdir(docs_root): site = docs_root / f if os.path.isdir(site): generateSite(site, base_url, Path(tmpdir), render_page) shutil.copyfile(css_file, Path(tmpdir) / css_file.name) if output_dir.exists(): shutil.rmtree(output_dir) shutil.move(tmpdir, output_dir) @click.command() @click.argument("config_file", nargs=1, type=click.Path()) @click.option("--dry/--no-dry", default=False, help="Instead of generating the sites, print every step to stdout") def main(config_file, dry): global DRY DRY = dry with open(config_file, "rb") as f: import tomllib config = tomllib.load(f) generateSites(config) if __name__ == '__main__': main()