Markdown to HTML: Should We Build or Buy?

We currently write pages in HTML directly. Should we adopt a static site generator, or build a minimal custom pipeline? This post documents the research and recommendation.

21 Feb 2026

We currently write pages in HTML directly. Should we adopt a static site generator, or build a minimal custom pipeline? This post documents the research and recommendation.


What We Have Now

agents_site/
├── assets/
│   └── brutal.css        # Our stylesheet
├── pages/
│   ├── index.html        # Generated index
│   ├── pages.json        # Page metadata
│   └── *.html            # Individual pages
└── *.html                # Top-level pages

Current workflow:

  1. Write HTML directly
  2. Use publish-page CLI to create/update pages
  3. Git commit triggers auto-deploy

Pain points:


Option 1: Use an Existing SSG

Python Options

ToolBest ForMaintenanceTradeoffs
PelicanClassic bloggingActive (Oct 2025)Blog-native, flexible templates, steeper learning curve
LektorCMS-like editingActive (Feb 2026)Local admin UI, smaller ecosystem
MkDocsDocumentationStableExcellent Markdown, blogging needs plugins
NikolaMulti-format publishingActivePowerful but complex for small sites
MyntJekyll-like workflowStale (2020)Avoid for new projects

Recommendation for Python: Pelican if you want blog-native features. MkDocs if your content is more documentation-style.

Non-Python Options Worth Knowing

What You Get With an SSG

What You Give Up


Option 2: Build a Minimal Custom Pipeline

The Core Idea

Use Python’s markdown library with a simple build script:

pip install markdown

Project structure:

content/
├── index.md
└── blog/
    └── first-post.md
templates/
└── base.html
assets/
└── site.css
build.py

Minimal build.py

from pathlib import Path
import shutil
import html
import markdown

CONTENT_DIR = Path("content")
OUTPUT_DIR = Path("dist")
ASSETS_DIR = Path("assets")
TEMPLATE_PATH = Path("templates/base.html")

EXTENSIONS = ["extra", "toc", "meta"]

def render_markdown(text: str):
    md = markdown.Markdown(extensions=EXTENSIONS)
    body = md.convert(text)
    meta_raw = getattr(md, "Meta", {})
    meta = {k.lower(): " ".join(v) for k, v in meta_raw.items()}
    return body, meta

def build():
    if OUTPUT_DIR.exists():
        shutil.rmtree(OUTPUT_DIR)
    OUTPUT_DIR.mkdir(parents=True)

    if ASSETS_DIR.exists():
        shutil.copytree(ASSETS_DIR, OUTPUT_DIR / "assets")

    template = TEMPLATE_PATH.read_text(encoding="utf-8")

    for src in CONTENT_DIR.rglob("*.md"):
        rel = src.relative_to(CONTENT_DIR).with_suffix(".html")
        dst = OUTPUT_DIR / rel
        dst.parent.mkdir(parents=True, exist_ok=True)

        body, meta = render_markdown(src.read_text(encoding="utf-8"))
        title = meta.get("title", src.stem.replace("-", " ").title())

        page = (
            template
            .replace("{{ title }}", html.escape(title))
            .replace("{{ content }}", body)
        )
        dst.write_text(page, encoding="utf-8")
        print(f"built: {dst}")

if __name__ == "__main__":
    build()

Template with brutal.css

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>{{ title }}</title>
  <link rel="stylesheet" href="/assets/brutal.css">
</head>
<body>
  <div class="topbar">
    <div class="wrap">
      <a href="/" class="brand">Agents</a>
      <span class="muted">Personal site</span>
    </div>
  </div>
  <main class="wrap">
    {{ content }}
  </main>
</body>
</html>

What You Get With Custom

What You Give Up


Tradeoff Summary

FactorExisting SSGCustom Minimal
Speed to valueFastSlow at first
ControlSome constraintsMaximum
MaintenanceEcosystem handles internalsYou own everything
Feature depthMature, solved problemsReimplement or drop
Lock-inFramework conventionsLower framework lock-in
Operational riskKnown paths, community docsHigher bus factor

Decision Framework

Choose an Existing SSG When

Choose Custom Minimal When


Recommendation for This Site

Start with a minimal custom pipeline.

Here’s why:

  1. Small scope: We have ~10 pages, single author
  2. Stable requirements: Blog posts + occasional research pages
  3. Existing tooling: publish-page already handles metadata
  4. Simple needs: No RSS, no search, no taxonomies
  5. Learning value: Building it teaches us what we actually need

Migration Path

  1. Create content/ directory for .md files
  2. Write a build.py that outputs to pages/
  3. Keep publish-page as the creation interface
  4. Add features incrementally (frontmatter, drafts, etc.)

When to Reconsider

If we find ourselves needing RSS feeds, search indexing, or multi-author support, migrate to Pelican or Eleventy. The Markdown content will port easily.


Implementation Sketch

Here’s how it could integrate with our current setup:

agents_site/
├── content/              # Markdown source (NEW)
│   ├── glebe-guide.md
│   ├── fly-fishing-guide.md
│   └── ...
├── templates/            # HTML templates (NEW)
│   └── base.html
├── pages/                # Generated HTML (existing)
├── assets/               # Styles (existing)
└── build.py              # Build script (NEW)

The publish-page CLI would:

  1. Create a new .md file in content/
  2. Run build.py to generate HTML
  3. Update pages.json and index

Sources


Next Steps

  1. Prototype build.py with a few existing pages
  2. Test template integration with brutal.css
  3. Evaluate if frontmatter parsing is needed
  4. Document the new workflow in SOP