Advertisement
Open Source Projects by Phil Schwartz

Batch-Converting Markdown to HTML with a Python Build Script

Building static documentation, blog archives, or knowledge bases from a folder of Markdown files has long been a familiar chore for developers who prefer plain text over heavyweight content management systems. Once the corpus grows past a handful of pages, manual exports become tedious, and copy-pasting into a web editor invites formatting drift between files. A small Python script can read an entire directory, render each .md file to HTML, and drop the results into an output folder ready for any static host.

The real power of a custom converter emerges when you bolt on a templating layer. Instead of bare HTML fragments wrapped in <pre> tags, each page can inherit a shared layout complete with a navigation bar, a footer, and site-wide metadata. For developers publishing internal documentation drawn from Australian government standards bodies such as the ACSC, this approach keeps the rendered output consistent without paying for a SaaS documentation platform.

This kind of utility also slots neatly into a broader collection of Python tooling. Projects like a related scanner project show how the same scripting mindset scales from text processing into low-level network work. The care given to argument parsing, logging, and exit codes pays off whether the script is rendering prose or probing services.

The walkthrough below covers a practical pipeline: parsing front matter, choosing a Markdown library, designing templates, walking the source tree, and adding a simple watch mode so re-runs happen automatically when a file changes.

Project layout and Python environment

A clean directory layout keeps the build script readable and the templates reusable. A typical project holds the Markdown source in content/, templates in templates/, and rendered output in dist/. The Python entry point lives at the root, alongside a requirements.txt that pins the third-party libraries the script depends on.

A virtual environment is the standard approach on any developer's machine, whether they are running Fedora on a workstation in Brisbane or Ubuntu on a CI runner in Melbourne. The setup sequence is python3 -m venv .venv, activate it, then install the two libraries the script depends on: a Markdown parser and a template engine. Pin versions in requirements.txt so rebuilds are reproducible months later.

For Australian teams that publish to government or academic audiences, reproducibility matters beyond convenience. AARNet research groups and university IT shops often archive builds for compliance, and a pinned dependency list makes those archives trustworthy. Logging the tool versions into a build manifest at the top of the output folder is a low-effort addition that future maintainers will appreciate.

Parsing Markdown and front matter

Python ships with solid Markdown parsers on PyPI, and markdown and markdown-it-py are the two that surface most often. The former uses a straightforward Python API; the latter mirrors the popular markdown-it JavaScript parser and supports CommonMark plus plugins. Either renders cleanly to HTML and exposes extension hooks for tables, footnotes, and code-block highlighting.

Front matter at the top of each Markdown file — the YAML or TOML block delimited by --- — is what lets templates know the page title, author, publication date, and tags. PyYAML handles the YAML flavour; tomllib, added in Python 3.11, handles TOML natively without extra dependencies. Stripping the front matter before handing the remaining text to the Markdown engine is a one-liner once the parser returns a metadata dictionary and a body string.

Store the parsed metadata alongside the rendered HTML, then pass both to the template renderer in the next stage. This separation means the template stays in charge of layout and the script stays in charge of content extraction, which makes both easier to test in isolation.

Designing a template system

Jinja2 remains the de facto choice for HTML templating in the Python ecosystem. It ships with a sandboxed execution mode, supports template inheritance through {% extends %}, and integrates cleanly with the rest of the standard library. A base template typically defines the doctype, links the stylesheet, and exposes slots for the page title, navigation, and main content.

Per-section templates can extend the base and override the content block. A blog post layout might add an article header with author and date; a documentation page might add a sidebar built from the directory listing. Keeping the templates in their own folder and loading them with a single FileSystemLoader means designers can edit HTML without touching Python.

When the script renders a page, it combines the parsed Markdown HTML, the metadata, and the chosen template into a final string, then writes that string to dist/.html. Wrapping that step in a function turns the rest of the pipeline into a series of file system walks and template selections.

Walking the source tree

pathlib.Path is the most pleasant way to traverse the source directory in Python 3.10 and later. The rglob('*.md') method yields every Markdown file under a root path, and a small helper turns each path into a destination path under dist/ while preserving the relative directory structure.

For developers maintaining regional documentation portals, this mirroring matters. A team covering multiple states might keep separate folders for Queensland and Victoria policies, and the build script should produce dist/qld/index.html and dist/vic/index.html without manual renaming. Encoding awareness matters too — UTF-8 is the safe default, and reading files with encoding='utf-8' avoids surprises on Windows hosts.

After rendering, log each processed file to stdout with a relative path so the build output doubles as a quick audit trail. That trail is the kind of detail Australian cyber security guidelines tend to appreciate when content drives public-facing services.

Watch mode and continuous integration

A watch loop turns the script into something that feels almost live. The watchfiles package detects filesystem changes and triggers a re-render of the affected file, which speeds up local authoring without a full static site generator. A short debounce prevents thrashing when an editor saves a file multiple times in succession.

In CI, the same script can run as a build step that fails the pipeline when rendering errors occur. Catching a broken template or a missing front matter field early saves a deploy that publishes half-rendered pages. Pipelines running on Australian cloud regions benefit from caching the virtual environment between runs, since round-trip times to overseas PyPI mirrors can stretch build windows noticeably.

For teams that need scheduled rebuilds rather than event-driven ones, a cron entry or a systemd timer fits comfortably alongside the script. The result is a publishing tool that stays out of the way until it is needed, then produces a consistent static site from a folder of plain text.

Recommendations for a reliable build pipeline