#!/usr/bin/env python3 """ blog/build.py — static blog generator for zebra-report markdown + frontmatter → html, no dependencies beyond stdlib """ import re, html from pathlib import Path POSTS_DIR = Path("blog/posts") OUT_DIR = Path("web/blog") # ------------------------------------------------------------------ # # minimal markdown → html # # ------------------------------------------------------------------ # def md_to_html(text): lines = text.split("\n") out = [] in_code = False in_list = False buf = [] def flush_para(): if buf: content = inline(" ".join(buf).strip()) if content: out.append(f"

{content}

") buf.clear() def flush_list(): nonlocal in_list if in_list: out.append("") in_list = False def inline(s): s = html.escape(s, quote=False) s = re.sub(r"`(.+?)`", r"\1", s) s = re.sub(r"\*\*(.+?)\*\*", r"\1", s) s = re.sub(r"\*(.+?)\*", r"\1", s) s = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r'\1', s) return s for line in lines: # fenced code blocks if line.startswith("```"): if in_code: out.append("") in_code = False else: flush_para(); flush_list() lang = line[3:].strip() out.append(f'
' if lang else "
")
                in_code = True
            continue
        if in_code:
            out.append(html.escape(line))
            continue

        # headings
        m = re.match(r"^(#{1,3})\s+(.*)", line)
        if m:
            flush_para(); flush_list()
            n = len(m.group(1))
            out.append(f"{inline(m.group(2))}")
            continue

        # unordered list
        m = re.match(r"^[-*]\s+(.*)", line)
        if m:
            flush_para()
            if not in_list:
                out.append("
    ") in_list = True out.append(f"
  • {inline(m.group(1))}
  • ") continue # blank line if not line.strip(): flush_para(); flush_list() continue buf.append(line) flush_para(); flush_list() return "\n".join(out) # ------------------------------------------------------------------ # # frontmatter parser # # ------------------------------------------------------------------ # def parse_post(path): text = path.read_text() meta = {} body = text if text.startswith("---"): parts = text.split("---", 2) if len(parts) >= 3: for line in parts[1].strip().splitlines(): if ":" in line: k, v = line.split(":", 1) meta[k.strip().lower()] = v.strip() body = parts[2] meta["content"] = md_to_html(body.strip()) meta.setdefault("slug", path.stem) meta.setdefault("date", "") meta.setdefault("title", path.stem) meta.setdefault("summary", "") return meta # ------------------------------------------------------------------ # # templates # # ------------------------------------------------------------------ # CSS_LINK = '' BASE = """\ {title} — zebra report {css}
    {body}
    """ def render_post(meta, newer=None, older=None): nav = [] if older: nav.append(f'← {older["title"]}') if newer: nav.append(f'{newer["title"]} →') nav_html = f'' if nav else "" body = f"""

    {html.escape(meta["title"])}

    {html.escape(meta["date"])} — all posts

    {meta["content"]}
    {nav_html}
    """ return BASE.format(title=html.escape(meta["title"]), css=CSS_LINK, body=body) def render_index(posts): items = "" for p in posts: items += f"""
  • {html.escape(p["title"])}

    {html.escape(p["summary"])}

  • \n""" body = f'

    posts

    \n
      \n{items}
    ' return BASE.format(title="zebra report", css=CSS_LINK, body=body) # ------------------------------------------------------------------ # # build # # ------------------------------------------------------------------ # def build(): OUT_DIR.mkdir(parents=True, exist_ok=True) posts = [parse_post(p) for p in sorted(POSTS_DIR.glob("*.md"))] posts.sort(key=lambda p: p["date"], reverse=True) for i, post in enumerate(posts): newer = posts[i - 1] if i > 0 else None older = posts[i + 1] if i + 1 < len(posts) else None d = OUT_DIR / post["slug"] d.mkdir(exist_ok=True) (d / "index.html").write_text(render_post(post, newer=newer, older=older)) print(f" {post['slug']}/") (OUT_DIR / "index.html").write_text(render_index(posts)) print(" index.html") print("done.") if __name__ == "__main__": build()