#!/usr/bin/env python3 # /// script # requires-python = ">=3.11" # dependencies = ["jinja2>=3.1", "markdown>=3.5"] # /// """A small static site generator.""" from __future__ import annotations import argparse import functools import hashlib import http.server import runpy import shutil import threading import time from datetime import date, datetime from pathlib import Path import jinja2 import markdown VERSION = "0.3.1" ROOT = Path(__file__).resolve().parent SRC = ROOT / "src" DIST = ROOT / "dist" TEMPLATE_SUFFIXES = {".html", ".xml"} def split_front_matter(text: str) -> tuple[dict[str, str], str]: """Split a small, scalar-only `key: value` metadata block from a file.""" lines = text.splitlines(keepends=True) if not lines or lines[0].strip() != "---": return {}, text try: end = next(i for i, line in enumerate(lines[1:], 1) if line.strip() == "---") except StopIteration: return {}, text metadata: dict[str, str] = {} for line in lines[1:end]: stripped = line.strip() if not stripped or stripped.startswith("#"): continue if ":" not in stripped: raise ValueError(f"invalid metadata line: {stripped!r}") key, value = stripped.split(":", 1) key = key.strip() value = value.strip() if not key: raise ValueError("metadata keys cannot be empty") if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'": value = value[1:-1] metadata[key] = value return metadata, "".join(lines[end + 1 :]) def output_path(source: Path) -> Path: relative = source.relative_to(SRC) return relative.with_suffix(".html") if source.suffix.lower() == ".md" else relative def page_url(path: Path) -> str: name = path.as_posix() if name == "index.html": return "/" if name.endswith("/index.html"): return f"/{name[:-10]}" return f"/{name}" RESERVED_OUTPUT_NAMES = {"_headers", "_redirects"} def is_output_source(path: Path) -> bool: relative = path.relative_to(SRC) if relative.parts == (path.name,) and path.name in RESERVED_OUTPUT_NAMES: return path.is_file() return path.is_file() and not any(part.startswith("_") for part in relative.parts) def source_files() -> list[Path]: return sorted(path for path in SRC.rglob("*") if is_output_source(path)) def read_source(path: Path) -> tuple[dict[str, str], str]: try: return split_front_matter(path.read_text(encoding="utf-8")) except UnicodeDecodeError as error: raise ValueError(f"{path.relative_to(ROOT)} is not valid UTF-8") from error def collect_pages(files: list[Path]) -> list[dict[str, str]]: pages = [] for path in files: if path.suffix.lower() not in {".html", ".md"}: continue metadata, _ = read_source(path) metadata["url"] = page_url(output_path(path)) pages.append(metadata) return pages def load_config(pages: list[dict[str, str]]) -> dict: path = ROOT / "config.py" if not path.exists(): return {} namespace = runpy.run_path(str(path)) configure = namespace.get("configure") if not callable(configure): raise ValueError("config.py must define configure(pages)") context = configure(pages) if not isinstance(context, dict): raise TypeError("configure(pages) must return a dictionary") return context def format_date(value: str | date | datetime, pattern: str = "%B %d, %Y") -> str: try: parsed = value if isinstance(value, (date, datetime)) else date.fromisoformat(value) return parsed.strftime(pattern).replace(" 0", " ") except (TypeError, ValueError): return str(value) def nearest_markdown_template(path: Path) -> str | None: directory = path.parent while True: candidate = directory / "_md.html" if candidate.exists(): return candidate.relative_to(SRC).as_posix() if directory == SRC: return None directory = directory.parent def hashed_name(path: Path) -> str: digest = hashlib.sha256(path.read_bytes()).hexdigest()[:8] return f"{path.stem}-{digest}{path.suffix}" def render_string(environment: jinja2.Environment, text: str, name: str, **context) -> str: template = environment.from_string(text) template.name = name return template.render(**context) def build() -> None: SRC.mkdir(exist_ok=True) if DIST.is_symlink() or (DIST.exists() and not DIST.is_dir()): raise ValueError("dist must be a directory") if DIST.exists(): shutil.rmtree(DIST) DIST.mkdir() files = source_files() pages = collect_pages(files) pages_by_url = {page["url"]: page for page in pages} environment = jinja2.Environment( loader=jinja2.FileSystemLoader(SRC), autoescape=False, keep_trailing_newline=True, ) environment.filters["date"] = format_date config = load_config(pages) hash_assets = bool(config.pop("hash_assets", False)) environment.globals.update(config) environment.globals["pages"] = pages page_count = 0 asset_count = 0 assets: dict[str, str] = {} for source in files: if source.suffix.lower() in TEMPLATE_SUFFIXES | {".md"}: continue relative = source.relative_to(SRC) name = hashed_name(source) if hash_assets and source.suffix else source.name destination = DIST / relative.parent / name destination.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(source, destination, follow_symlinks=True) assets[relative.as_posix()] = f"/{(relative.parent / name).as_posix()}" asset_count += 1 print(f" asset {destination.relative_to(DIST)}") environment.globals["assets"] = assets for source in files: suffix = source.suffix.lower() if suffix not in TEMPLATE_SUFFIXES | {".md"}: continue relative = source.relative_to(SRC) destination = DIST / output_path(source) destination.parent.mkdir(parents=True, exist_ok=True) if suffix in TEMPLATE_SUFFIXES: _, body = read_source(source) page = pages_by_url.get(page_url(output_path(source)), {"url": page_url(output_path(source))}) rendered = render_string(environment, body, relative.as_posix(), page=page) destination.write_text(rendered, encoding="utf-8") else: _, body = read_source(source) page = pages_by_url[page_url(output_path(source))] content = markdown.markdown(body, extensions=["fenced_code", "tables"]) wrapper = nearest_markdown_template(source) if wrapper: rendered = environment.get_template(wrapper).render(page=page, content=content) else: rendered = content destination.write_text(rendered, encoding="utf-8") page_count += 1 print(f" page {destination.relative_to(DIST)}") print(f"\nbanhus: {page_count} page{'s' if page_count != 1 else ''} + " f"{asset_count} asset{'s' if asset_count != 1 else ''} → dist/") def project_snapshot() -> tuple: paths = [path for path in SRC.rglob("*") if path.is_file()] config = ROOT / "config.py" if config.exists(): paths.append(config) snapshot = [] for path in sorted(paths): try: stat = path.stat() snapshot.append((str(path), stat.st_mtime_ns, stat.st_size)) except FileNotFoundError: pass return tuple(snapshot) def serve(port: int) -> None: handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=str(DIST)) server = http.server.ThreadingHTTPServer(("127.0.0.1", port), handler) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() print(f"banhus dev: http://localhost:{port} (watching src/ and config.py)\n") previous = project_snapshot() try: while True: time.sleep(0.25) current = project_snapshot() if current == previous: continue previous = current print("\nchange detected, rebuilding...") try: build() except Exception as error: print(f"banhus: {error}") except KeyboardInterrupt: pass finally: server.shutdown() server.server_close() def main() -> None: parser = argparse.ArgumentParser( prog="banhus.py", description="Build src/ into dist/.", ) parser.add_argument("command", nargs="?", choices=("build", "dev"), default="build") parser.add_argument("--port", type=int, default=8000, help="development server port") parser.add_argument("--version", action="version", version=f"banhus {VERSION}") arguments = parser.parse_args() if not SRC.exists(): SRC.mkdir() print("created src/") build() if arguments.command == "dev": serve(arguments.port) if __name__ == "__main__": try: main() except KeyboardInterrupt: pass except Exception as error: raise SystemExit(f"banhus: {error}") from error