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 DRY = False @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) 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 env = Environment( loader=FileSystemLoader(template_file.parent), autoescape=select_autoescape(), extensions=["jinja_markdown.MarkdownExtension"] ) 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 f.read() 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, sorted(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()