Static / edge export of fired artifacts
Export Kiln's immutable, pre-rendered artifacts to a static directory tree
you can rsync to a CDN, bake into an edge cache, or ship to an air-gapped host
(#353; the salvaged,
useful kernel of the "hybrid rendering" idea — see
docs/differentiator-opportunities.md).
Why this is different from a bolt-on SSG
Kiln's firing engine already produces immutable per-surface artifacts
(:web/:json/:json_ld) with a dependency graph that re-fires dependents
precisely on change — this is static generation, with better invalidation
than a generic SSG. The only missing piece was an output surface. Static
export is exactly that: it copies the already-fired artifacts out. It does
not re-render, and it does not fork the CMS — the live CMS stays
authoritative; the export is a snapshot. Overlaps with #341 (DB-outage-resilient
delivery): the same immutable artifacts underpin both.
Using it
Mix task
mix kiln.export.static <out_dir> [--surface web,json,json_ld,llm] [--base-url URL]
[--org-id UUID | --all-orgs]mix kiln.export.static ./_static
mix kiln.export.static /var/www/edge --surface web
mix kiln.export.static ./_static --base-url https://cdn.example.com
mix kiln.export.static ./_static --org-id 0f0e…
mix kiln.export.static ./_static --all-orgsExport is per-site (#419). With neither org flag you get the default org;
--all-orgs writes each site into its own <out_dir>/<org_id>/ subtree.
Both flags are hyphenated. An underscored --all_orgs / --org_id is not a
synonym — OptionParser treats it as an unknown switch, and the task now raises
on it rather than quietly exporting the default org (#931).
Background / admin / cron trigger
KilnCMS.Firing.StaticExportWorker runs the same export off-request. Configure a
destination and enqueue it from an admin action, a release task, or a cron entry:
config :kiln_cms, KilnCMS.Firing.StaticExport,
output_dir: "/var/www/edge",
surfaces: [:web, :json, :json_ld, :llm]Oban.insert(KilnCMS.Firing.StaticExportWorker.new(%{}))
# …or override the destination per job:
Oban.insert(KilnCMS.Firing.StaticExportWorker.new(%{"out_dir" => "/tmp/snapshot"}))With no output_dir configured (and no out_dir arg) the worker is a logged
no-op, so it's safe to schedule a cron entry before picking a destination.
Output layout
<out>/
index.json # export manifest
content/<type>/<locale>/<slug>/
web.html # :web surface — the fired HTML body
json.json # :json surface — structured intent
json_ld.json # :json_ld surface — schema.org graph
llm.md # :llm surface — Markdown for LLM consumers (#357)<type>is the public content type (page,post, or a dynamic type's name);<locale>is always present.- The bodies are byte-for-byte what the headless API serves at
GET /api/content/<type>/<slug>?surface=…— no re-render. index.jsonlists every exported document (type,slug,locale,path,surfaces, plus the run'sgenerated_at/base_url), so an edge deploy can diff and prune deterministically.
Scope & follow-ons
Phase-1 slice:
- Read-only. Only already-fired artifacts are exported; a published document that has never been fired is skipped and counted (publish/backfill first).
- No pruning. Existing files are overwritten; content deleted since a prior
export is not removed — diff
index.jsonto reconcile. A--prunemode is a natural follow-on. - Whole-site export. Incremental export (only artifacts fired since a watermark, driven by the firing broadcast) is a Phase-2 optimization for large sites; the dependency graph already makes it tractable.
- Admin UI button. The worker is the programmatic trigger today; a one-click "Export static site" admin affordance is a small follow-on.
- The
:websurface is the fired HTML body (the same bytes the?surface=webAPI returns), suited to an edge cache or a frontend that composes its own layout — not a fully-laid-out standalone page (that would require re-rendering the site layout, which this deliberately avoids).