Build-time Python
Build-time Python in aardvark — inline blocks that compute part of a page (fetch an API, loop over data) and generation scripts that emit whole page sets.
aardvark runs your Python at build time in two places:
- Inline
{% … %}blocks inside any page — to compute part of a page: fetch an API, loop over yourdata/, assemble a table. - Generation scripts in a
generators/directory — to produce whole files: emit pages (one Markdown file per row of data, a stub per API operation) withgenerate(), or emit any other file — a downloadable CSV, JSON, or YAML export, an image, a zip, any bytes — withemit().
Reach for an inline block when you want a fragment of an otherwise hand-written page; reach for a generation script when the output is a whole file (or a set of them). The live AI model rates page is a real generation script that writes a page; the downloadable exports at the bottom of this page are a real generation script that writes files. This page you’re reading uses an inline block (the live demo below).
Because a generation script can write any file the build serves — not just Markdown
pages, and not just the formats aardvark templates into (HTML, sitemap.xml, llms.txt) —
build-time Python is aardvark’s escape hatch for producing whatever a static host can serve:
a machine-readable data feed, a robots-style text file, a generated diagram, a binary
download. If you can compute it in Python, you can publish it.
Inline {% … %} blocks
A {% … %} block runs Python as the page builds. A single
expression is rendered in place:
This site documents {% len(data.get("products", {}).get("items", [])) %} products.
Anything beyond a single expression is statements — use the injected print() to emit
into the page:
{%
for item in data["products"]["items"]:
print(f"- **{item['name']}** — {item['blurb']}\n")
%}
print() writes its argument verbatim, with no trailing newline — so a bare expression
never injects stray whitespace. When you emit Markdown line by line (a table, a list), add
the \n yourself, as above.
Everything in the page’s template scope is available inside a block:
| Name | What it is |
|---|---|
data |
your parsed data/ files (the same object templates see) |
site, config, page |
the site config, the build config, and this page’s front matter |
component(...), snippet(...) |
emit any built-in component or project snippet |
And because it’s just Python, a block can import anything and call out to the network.
Inline blocks and generation scripts are ordinary, unsandboxed Python that runs as part
of your build. Only run code you trust. Keep build-time network calls defensive — a timeout
and a try/except fallback — and remember that a block which always hits the network makes
every build depend on that endpoint being up. data, site, and config are shared by
reference across every block and script (and the rest of the build), so read them, don’t
mutate them — a mutation in one leaks into everything that runs after it.
Live demo: this page fetches its own metadata
The viewer below isn’t checked in — it’s built when this page is. The block fetches
metadata.json (the agent-discovery artifact
aardvark publishes for this very site) and renders it with the built-in
{% jsontree %} viewer — the same
interactive JSON tree the API reference uses for OpenAPI response examples:
That whole viewer is this block — defensive fetch and all:
{%
import json, urllib.request
_url = "https://aardvarkdocs.com/metadata.json"
try:
_req = urllib.request.Request(_url, headers={"User-Agent": "aardvark-docs"})
with urllib.request.urlopen(_req, timeout=6) as _resp:
_meta = _resp.read(4 * 1024 * 1024).decode("utf-8")
json.loads(_meta) # validate it parses before handing it to the viewer
print(component("aardvark", "jsontree", data=_meta, rootName="metadata.json",
maxDepth=1, withSearch=True, withExpandAll=True,
showItemsCount=True, withKeyCountBadge=True, withBorder=True))
except Exception:
print("*(The live `metadata.json` couldn't be fetched for this build.)*")
%}
Note the shape of a careful build-time fetch: a timeout, a capped read, a
parse check, and an except that prints a fallback instead of raising. A page block that
raises fails the build (by design — a broken page should), so a network call that you don’t
want to be load-bearing must catch its own failures.
Generation scripts
When the output is a whole file — a page, a page set, or a downloadable data file — move the
Python into a generators/ directory at your project root. A generation script is just
build-time Python — the whole file runs, top to bottom; there’s no entry function to
define. Each script gets two writers: generate() for pages and emit() for any other
file. For pages, on every build aardvark:
generators/*.py (in alphabetical order) and collects every page it emits;content/: changed pages are rewritten, pages no longer
emitted are deleted, and unchanged pages are left untouched;content/ — generated and hand-authored alike.generators/*.py (in alphabetical order) and collects every page it emits;content/: changed pages are rewritten, pages no longer
emitted are deleted, and unchanged pages are left untouched;content/ — generated and hand-authored alike.So your content tree is always reproducible from generators/ plus its inputs (stale
generated pages can’t linger), and a re-run with unchanged inputs writes nothing — which is
why vark dev can re-run generators on every rebuild without looping on its own output.
Files whose name starts with _ (e.g. _helpers.py) are not run — they’re importable
helper modules your scripts can import. Skip generators for a build with
vark build --no-generators (or vark dev --no-generators).
Writing pages
A generator gets the same ambient names a page’s {% … %} blocks get —
data, site, config — plus one writer, generate(path, frontmatter, content):
import re
# `data` is injected, like in a page. One page per product:
for item in data["products"]["items"]:
slug = re.sub(r"[^a-z0-9]+", "-", item["name"].lower()).strip("-")
generate(
f"catalog/{slug}.md", # path, relative to content/
{"title": item["name"], "description": item["blurb"]}, # frontmatter
f"{item['blurb']}\n\n**Price:** ${item['price']}\n", # Markdown body
)
What’s in scope:
| Name | What it is |
|---|---|
generate(path, frontmatter, content) |
Write one .md page under content/ — the one guarded writer. |
data |
Your parsed data/ files (the same object templates see). |
site, config |
The site config and build config; config.root / config.content_dir give you project paths (e.g. for a cache dir). |
generate is deliberately strict — it’s the one supported, guarded way to write:
- the
pathmust be relative and end in.md; a path that escapescontent/is rejected; - it won’t overwrite a hand-authored page — generators only create pages;
- two scripts writing the same path in one build is an error, not a silent last-writer-wins.
The generated marker
Every page generate emits gets two injected front-matter keys:
generated: true
generated_by: generators/pricing.py
These keys are reserved. If your frontmatter sets either one yourself, the build
fails and tells you which key collided — the marker is what lets aardvark find and wipe
generated pages, so a script can’t forge or override it.
Because the marker is how aardvark tells generated pages from hand-authored ones, a file
that carries both keys is treated as generator output: on the next build it’s overwritten
if a generator re-emits it, or deleted if none does. So if you ever want to keep a
generated page permanently as hand-authored (commit it and edit it by hand), remove both
generated and generated_by from its front matter first — otherwise the next build
reclaims it.
Emitting files (CSV, JSON, anything)
generate() writes Markdown pages. When you want to publish something that isn’t a
page — a downloadable CSV, a JSON or YAML feed, an SVG, a zip, any bytes — use the other
injected writer, emit():
import csv, io, json
# One page per row is `generate`; a single downloadable export is `emit`.
# `data` values are read-only DotDicts — project the fields you want into plain
# dicts first, so csv/json/yaml can serialize them.
records = [{"name": r["name"], "price": r["price"]} for r in data["products"]["items"]]
buf = io.StringIO()
w = csv.DictWriter(buf, fieldnames=["name", "price"])
w.writeheader()
w.writerows(records)
emit("downloads/products.csv", buf.getvalue()) # -> /downloads/products.csv
emit("downloads/products.json", json.dumps(records, indent=2)) # -> /downloads/products.json
emit(path, content) writes content to path in the build output and returns the
site-absolute URL it will be served at (handy for linking to it from a page you also
generate). That’s the whole API:
| Name | What it is |
|---|---|
emit(path, content) |
Write one arbitrary file to the build output; returns its URL (e.g. /downloads/products.csv). |
The rules are few and strict:
contentis written verbatim. Astris encoded UTF-8; passbytes(or abytearray) for a binary format (an image, a PDF, a Parquet file) and the bytes land untouched — aardvark never parses, renders, or rewrites an emitted file, so what you emit is exactly what’s served.pathis a clean relative path under the site root — an absolute path or one containing..is rejected.emit("data/x.json", …)is served at/data/x.json.- It won’t clobber anything else the build writes — a page, a
static/file, or a built-in artifact (sitemap.xml, …). Emit to a path nothing else owns. - An emitted file is not a page. It’s never discovered, rendered, or listed in the nav,
search, or sitemap — it’s just a file at a URL. (Link to it with a normal
[download](…); link-check skips file links, so a.csv/.jsonhref is never flagged.) - Prefer a file extension. An extension (
/reports/data.csv) gives the file its correct content-type everywhere. A suffix-less path (/reports/data) is still served —vark serve/vark dev(and a static host) fall back to the raw file — but with a genericapplication/octet-streamtype, so reach for the natural extension when the type matters.
The key difference from generate(): a generated page is source — it’s written into
content/, then discovered and rendered like hand-authored Markdown, and reconciled there
(stale pages are deleted). An emitted file is output — it goes straight into the build
directory. Because the whole output tree is rebuilt every build, there’s no stale-file
bookkeeping: a file you stop emitting simply stops appearing.
This is why aardvark doesn’t need Hugo-style “output formats” or per-page alternate
templates. Anything a static host can serve, a generator can write with a few lines of
ordinary Python — csv, json, yaml, PIL, whatever’s on PyPI — reading from the same
data/ your pages use.
A live example
This very site’s AI model rates page is built by one generation script:
generators/pricing.py
calls OpenRouter’s API at build time and writes pricing/models.md — a real-world generator that
fetches live data (with a cached fallback under config.root / ".aardvark-cache") rather than
reading a local file.
The result is an ordinary page: it appears in the nav, in search, in the sitemap, and is link-checked like everything else — a generated page with a dead link fails the build, exactly as a hand-authored one would.
And the emit() side is live too:
generators/downloads.py
reads the same data/products.yaml this site’s shop demo uses and publishes the catalog as
three real downloads — built fresh on every build, byte-for-byte from the data:
None of those are pages — they’re files at a URL, produced by ~20 lines of ordinary Python.
Security & anti-patterns
Build-time Python is trusted-author code, not a sandbox. A generator or an inline
block runs with your build’s full privileges — filesystem,
environment variables, network — the same as a Makefile, a Sphinx conf.py, or an npm
postinstall script. There’s no in-process restriction, and there can’t be a meaningful one
(hiding os wouldn’t stop a determined script and would break legitimate uses like
authenticated fetches). So the rules below are about who runs what, not about locking the
language down.
On a private repo — or any repo where only people you trust can push and open pull requests — build-time Python only ever runs your own code: review it like any other code and you’re covered.
Avoid:
- Running generators or inline blocks you don’t trust. They can read your environment variables, read and write files, and reach the network. Treat a change that adds or edits a generator (or a build-time block) as a code change with that power, and review it as one.
- Letting untrusted code run with secrets in the environment. The defense is configuration
you click through in your repo’s GitHub settings:
- Settings → Actions → General → “Fork pull request workflows from outside collaborators” → “Require approval for all outside collaborators.” A stranger’s PR then runs no workflow until you press Approve, so their code never runs unreviewed.
- Settings → Environments → keep deploy secrets in an environment whose Deployment branches are limited to your release branch (not in loose repository secrets). Only the post-merge deploy can read them — a PR build can’t.
- Settings → Actions → Runners → don’t attach self-hosted runners to a public repo; a fork PR’s code would run on your machine.
- One gotcha no toggle covers: a workflow with
on: pull_request_targetruns with your secrets even for fork PRs — if you have one, make sure it never checks out and runs the PR’s code. Plainon: pull_requestis the safe default (fork PRs get no secrets).
- Building genuinely untrusted content in your normal build environment. If you must build content you don’t trust, isolate the whole build in an ephemeral container with no secrets in its environment — the settings above keep your secrets out of an outside PR’s reach, but only isolation contains code you can’t review at all.
- Hardcoding secrets in a generator. Read them from the environment (
os.environ) — fine for your own build — and never commit the value. - Unguarded build-time network calls. A bare
urlopenmakes every build depend on a third party being up. Wrap it in a timeout +try/exceptwith a fallback, and cache when you can — asgenerators/pricing.pydoes. - Mutating
data,site, orconfig. They’re shared by reference across every block and script in the build — read them, don’t mutate them, or you’ll poison whatever runs next.