Kiln is a documentation tool for projects that provides first class support for multiple versions and localisation. It works as a static site generator written in Swift. You describe your site in a type-safe Swift configuration and Kiln turns a directory of markdown into a fast, modern static website — no YAML, no Python toolchain.
It was built as a replacement for MkDocs-based documentation sites (such as Vapor's), so it covers the features those sites rely on: multi-language docs with fallback, a themeable UI, client-side search, admonitions, and more.
📖 See it in action: Kiln's own documentation site — built with Kiln — is live at kiln.brokenhands.io. Its source is the example site in this repo.
- Features
- Requirements
- Installation
- Quick start
- The
kilnCLI - Content & localisation
- Navigation
- Markdown
- Configuration reference
- Theming
- Search
- API reference (DocC)
- Output
- Example site
- Development
- License
- Swift-defined configuration — your whole site is a single type-safe
KilnSitevalue with a navigation result-builder DSL. - Versioned docs — define multiple documentation versions with a built-in
version switcher. The default ("latest") version is served at the site root and
others under
/<version>/, with automatic banners for pre-release and outdated versions. - Localisation — multiple languages with automatic fallback to your default
language, per-language navigation translations and site names, hreflang
alternates, and a language switcher. Right-to-left languages (Arabic, Hebrew, …)
set
dir="rtl"on the page automatically, and the default theme is authored with CSS logical properties so it mirrors correctly. - A fresh default theme — modern, responsive, with light/dark colour schemes, a sidebar nav, an on-page table of contents, and search. Fully overridable with your own Leaf templates.
- Markdown powered by swift-markdown:
GFM tables, fenced code with syntax highlighting, MkDocs-style admonitions
(
!!! tip,??? note), heading anchors + table of contents, and optional YAML front matter. - Search — a client-side search index is generated per language (no external service, no build-time JS toolchain).
- API reference from DocC — ingest pre-built DocC archives for one or more Swift packages and render them into your theme as one cohesive site, with a module switcher, a per-package version switcher, sitewide search, and a generated catalog landing page (see API reference (DocC)).
- SEO & social cards — per-page
<title>/description, canonical URLs, hreflang alternates, OpenGraph and Twitter card tags (with a site-wide default preview image and per-page front-matter overrides), plussitemap.xmlandrobots.txt. - Custom error pages (a styled 404), pretty URLs, automatic asset copying, and optional Carbon Ads.
- Build-time link checking — internal
.mdlinks (incl. localisedcontent.de.mdtargets),#anchorfragments, and relative assets are validated against the built site; warn or fail the build. - AI / agent-friendly — generates an
llms.txtindex andllms-full.txtcorpus, plus a raw-markdown copy of every page (…/index.md) with a<link rel="alternate" type="text/markdown">for discovery. - Cross-platform — builds and runs on macOS and Linux.
- Swift 6.2+ (the package enables upcoming/experimental Swift features that target 6.2).
- macOS 13+ or Linux.
Add Kiln to your Package.swift and create a small executable that builds your
site:
dependencies: [
.package(url: "https://github.com/brokenhandsio/kiln.git", from: "1.0.0"),
],
targets: [
.executableTarget(
name: "Docs",
dependencies: [.product(name: "Kiln", package: "kiln")]
),
]import Kiln
let site = KilnSite(
name: "My Docs",
url: "https://docs.example.com",
description: "Documentation for My Project.",
repository: .init(
name: "GitHub",
url: "https://github.com/me/project",
editURI: "https://github.com/me/project/edit/main/Content/"
),
theme: .default(palette: .autoLightDark(primary: .black, accent: .blue)),
social: [.init(icon: .github, link: "https://github.com/me/project")],
languages: [
.init(.english, isDefault: true),
.init(.german, navTranslations: ["Guides": "Anleitungen"]),
],
navigation: {
Page("Welcome", "index.md")
Section("Guides") {
Page("Configuration", "guides/configuration.md")
}
Link("API Reference", "https://example.com/api")
}
)
try await Kiln.build(site, contentDirectory: "Content", outputDirectory: "public")Run it, then serve the output with any static file server:
swift run Docs
python3 -m http.server --directory public…or use the kiln CLI, which builds and previews for you:
kiln serveKiln ships a command-line tool that wraps the common workflows. Because a Kiln
site is defined in Swift, kiln drives your project's own executable (via
swift run) rather than reading a config file — so the conventions are: your
build writes to ./site, and your content lives in ./Content.
With Homebrew (macOS, Apple Silicon):
brew install brokenhandsio/tap/kilnOr build the kiln product from source (any platform with a Swift toolchain,
including Intel Macs and Linux):
git clone https://github.com/brokenhandsio/kiln.git
cd kiln
swift build -c release
cp .build/release/kiln /usr/local/bin/ # or anywhere on your PATHInteractively scaffold a new documentation project — prompts for the site name, URL, default language, and any additional languages (from Kiln's built-in locale list), then writes a ready-to-build SwiftPM package:
kiln new my-docs
cd my-docs
kiln serveBuild the site and serve it locally, rebuilding automatically when you edit a file:
kiln serve # build, serve ./site at http://127.0.0.1:8080, watch for changes
kiln serve --port 3000 # change the port
kiln serve --directory public # serve a different output directory
kiln serve --no-watch # build + serve once, no rebuild-on-change
kiln serve --no-build # serve the existing output without building first
kiln serve --base-path /docs # preview a site whose basePath is "/docs" (visit http://127.0.0.1:8080/docs/)
kiln serve --pre-build "make js" # override the configured asset pre-build command for this run
kiln serve --no-pre-build # skip the asset pre-build step this runThe watcher polls for changes (skipping .build, .git, .swiftpm,
node_modules, and the output directory) and re-runs the build; reload your
browser to see updates. If a kiln.json asset pre-build is configured (see
below), each rebuild runs it before regenerating the site.
Build the site by running your project's executable:
kiln build # writes the static site (to ./site by convention)
kiln build --release # build in release configuration
kiln build --pre-build "make js" # override the configured asset pre-build command
kiln build --no-pre-build # skip the asset pre-build stepIf your site has an asset pipeline — say webpack or Sass compiled with npm —
it should run before the site is generated so the latest CSS/JS is picked up.
Drop an optional kiln.json at the project root:
{
"preBuild": {
"command": "npm run build",
"watch": ["src/scss", "src/js"]
}
}Both kiln build and kiln serve then run command before the Swift build,
streaming its output live (so webpack warnings/errors are visible). During kiln serve, the directories in watch are watched alongside your project, so
editing a stylesheet re-runs the asset build and regenerates the site. The
watch paths are resolved relative to the project root and may point outside it
(e.g. "../design/src" for a shared design repo).
This is the one piece of CLI configuration Kiln reads from a file — a tooling
concern, separate from your Swift-defined site config. With no kiln.json,
behaviour is unchanged; override or skip the step per-run with --pre-build <cmd> / --no-pre-build.
Note
kiln is optional — everything it does, you can also do with swift run plus
any static file server. It's there for convenience and a familiar
mkdocs-style workflow.
Content is plain markdown under your content directory. Translations use a locale suffix on the filename:
Content/
├── index.md # default language (e.g. English)
├── index.de.md # German translation of the home page
└── guides/
├── configuration.md
└── configuration.de.md
index.md and index.de.md share the same logical path (index.md), which
is what navigation references. When a page has no translation for a language,
Kiln falls back to the default language's content and shows a small "translation
unavailable" banner — the equivalent of mkdocs-static-i18n's
fallback_to_default: true.
The default language is built at the site root; other languages live under
/<locale>/.
Each Language has an isRTL flag that drives the dir attribute on the page
(<html lang="ar" dir="rtl">). It defaults from the LanguageCode — .arabic
and .hebrew are right-to-left, everything else is left-to-right — and is exposed
to templates as #(language.direction) ("rtl"/"ltr") and #(language.isRTL).
For a locale Kiln can't classify, set it explicitly:
Language(.custom(code: "fa", name: "فارسی"), isRTL: true)When an RTL page falls back to the (LTR) default language because a translation is
missing, the default theme wraps just that content in dir="ltr" — so the page
chrome stays RTL while the untranslated prose still reads naturally. The theme's
CSS is written with logical properties so it mirrors without a separate stylesheet;
code blocks are kept LTR regardless of page direction.
The theme's own UI strings (search box, navigation labels, error page, …) are
localised per language via LocalisationConfiguration. Any string left unset
falls back to Kiln's built-in English default:
Language(.german, localisation: .init(
searchPlaceholder: "Suchen",
searchNoResults: "Keine Ergebnisse gefunden",
tableOfContentsTitle: "Auf dieser Seite",
previousPage: "Zurück",
nextPage: "Weiter",
editPage: "Diese Seite bearbeiten",
notFoundTitle: "Seite nicht gefunden"
// … and more; see LocalisationConfiguration
))The navigation tree is built with a result builder using three helpers:
navigation: {
Page("Welcome", "index.md") // a markdown page (path is the logical path)
Section("Guides") { // a collapsible group
Page("Configuration", "guides/configuration.md")
Page("Theming", "guides/theming.md")
}
Link("API Reference", "https://example.com/api") // an external link
}Section and page titles are translated per language via each Language's
navTranslations map (keyed on the default-language title). Kiln also derives
previous/next links and the active trail automatically.
Supported out of the box:
-
Headings with GitHub-style slug
ids, permalink anchors, and an automatically generated table of contents. -
GFM tables, fenced code blocks (highlighted client-side with highlight.js), inline formatting, links, images, blockquotes, ordered/ unordered/task lists.
-
Admonitions — MkDocs/Python-Markdown style:
!!! tip "Optional title" Body content, rendered as markdown. ??? note "Collapsible" Hidden until expanded (use `???+` to start expanded). -
Front matter (the
metaextension) — an optional YAML block at the top of a file:--- title: Custom Page Title description: Used for meta and social tags. image: assets/custom-card.png # per-page social preview image template: landing # override the Leaf template for this page ---
KilnSite is the single source of truth:
| Field | Type | Notes |
|---|---|---|
name |
String |
Site title. |
url |
String |
Canonical site URL. With basePath set, this is the scheme + host (e.g. "https://example.com"). |
basePath |
String |
Subdirectory the site is served under (e.g. "/docs"). Empty by default (served from the domain root). Prefixed onto every generated root-relative link so they resolve in the subpath. |
author |
String? |
Used for meta tags. |
description |
String? |
Default meta/OpenGraph description. |
image |
String? |
Default social/OpenGraph preview image (content-relative path). |
twitterSite |
String? |
Twitter/X handle for the twitter:site tag (e.g. "@codevapor"). |
repository |
Repository? |
name, url, optional editURI for "edit this page" links. |
copyright |
String? |
Footer notice. |
theme |
Theme |
.default(…) or .custom(directory:…). |
social |
[SocialLink] |
icon (.github, .mastodon, .twitter, .discord, .linkedin, .youtube, .rss, .custom) + link. |
carbonAds |
CarbonAds? |
Carbon Ads serve/placement; shown in the TOC sidebar on desktop. |
extraCSS |
[String] |
Extra stylesheets (relative to the content dir). |
extraJavaScript |
[String] |
Extra scripts. |
languages |
[Language] |
Each Language(_ code: LanguageCode, …) — a built-in case like .english/.german or .custom(code:name:) — with isDefault, build, siteName, description, navTranslations, localisation. |
markdown |
MarkdownExtensions |
Feature toggles + TableOfContentsOptions. |
navigation |
@NavBuilder |
The nav tree (see above). |
docc |
DocCSite? |
An API-reference site built from DocC archives (see API reference (DocC)). |
By default Kiln assumes the site is served from the domain root, so every
generated link is root-absolute (/_kiln/css/theme.css, /guides/routing/, …).
If you deploy under a subdirectory instead (e.g. https://example.com/docs/),
set basePath so those links resolve in the subpath:
let site = KilnSite(
name: "My Docs",
url: "https://example.com", // scheme + host only — NOT "https://example.com/docs"
basePath: "/docs", // the subdirectory; "docs", "/docs", "/docs/" all work
// …
)Important
When basePath is set, url must be the scheme + host only
("https://example.com"), not including the subpath. Kiln builds absolute
URLs (canonical, og:url, sitemap, llms.txt) by joining url with the
base-path-prefixed page path — so putting the subpath in both url and
basePath double-prefixes them (https://example.com/docs/docs/page/).
A few more things to know:
- Output layout is unchanged.
basePathis a serving prefix, not an output directory — the build still writesindex.html,_kiln/…, etc. at the root of the output folder. Deploy that folder into your server's subdirectory. - Hand-written absolute links are left alone. A Markdown link to
/foois emitted verbatim (matching MkDocs). Use relative links (../foo.md) — which Kiln rewrites with the base path — if you need them to follow the subpath. - Local preview:
kiln serve --base-path /docsstrips the prefix so the preview athttp://127.0.0.1:8080/docs/mirrors production.
Theme options: palette (Palette with primary/accent Colors and a
.auto/.light/.dark default mode), logo, favicon, fonts
(Fonts(text:code:)), and features (.searchSuggest, .searchHighlight,
.navigationTabs, .backToTop). Color has presets (.black, .blue,
.indigo, …) or accepts any CSS string via Color("#2f6feb").
Kiln ships a default theme as a package resource. To customise it, point Kiln at a directory of your own Leaf templates and assets:
theme: .custom(directory: "Theme")Templates resolve from your directory first and fall back to the bundled theme, so you only override what you need. The theme is split into small partials:
Theme/
├── templates/
│ ├── base.leaf # overall page shell (<head>, header, layout, scripts)
│ ├── page.leaf # a standard documentation page
│ ├── home.leaf # the home page
│ ├── 404.leaf # the error page
│ └── partials/
│ ├── header.leaf
│ ├── footer.leaf
│ ├── nav-tree.leaf
│ ├── toc.leaf
│ ├── search.leaf
│ ├── language-switcher.leaf
│ └── social-icons.leaf
├── css/
└── js/
Templates receive a context with site, page, nav, language, languages,
searchIndexURL, and basePath (prefix every hard-coded root-relative link —
e.g. #(basePath)/_kiln/css/theme.css — so custom themes work under a subpath).
The rendered page body is injected with #unsafeHTML(page.content).
A search index (search/search_index.json) is generated per language at build
time. The bundled theme includes a small, dependency-free client that fetches
the index and ranks results in the browser — no external search service and no
build-time JavaScript toolchain required. Tokenisation is Unicode-aware: accented
Latin scripts are matched whole, and Han/kana (which aren't space-separated) are
segmented into bigrams so CJK queries work.
Kiln can host API reference documentation generated by DocC — the docs Xcode and the Swift toolchain produce from your source. Point Kiln at a set of pre-built DocC archives and it renders them into your theme as one cohesive site: a catalog landing page, a module switcher, a per-package version switcher, and sitewide search — the setup behind api.vapor.codes, which hosts many Vapor packages side by side.
Kiln does not compile your packages or shell out to the Swift toolchain. The pipeline is deliberately split in two:
-
Stage A — build the archives (outside Kiln). For each package/version, run DocC to emit a
.doccarchive:swift package --allow-writing-to-directory ./out \ generate-documentation --target MyLib --output-path ./out/MyLib.doccarchive
This is the only step that needs the package's source and toolchain, so it's typically done in CI (once per release) and cached.
-
Stage B — render (Kiln). Kiln reads the machine-readable render JSON inside each archive and renders themed HTML. The only coupling is DocC's render-JSON schema, which is versioned and additive, and Kiln decodes it leniently — an unrecognised construct degrades gracefully and surfaces a build warning rather than failing the build.
Drop the archives under your content directory using the layout
<archivesDirectory>/<versionID>/<Module>.doccarchive (default
archivesDirectory is archives, and a single-version package uses the version
id default):
Content/
└── archives/
├── default/
│ ├── MyLib.doccarchive
│ └── MyLibTesting.doccarchive
└── 5-alpha/ # a non-default version of the same package
└── MyLib.doccarchive
The raw archives are read at build time and are not copied into the output (only the assets they reference — images, videos, downloads — are).
Set KilnSite.docc to a DocCSite. Each APIPackage is one git repository (the
checkout unit) that owns an independent set of PackageVersions, each declaring
the Modules it ships. A package's target set can change across major versions,
so modules live on the version — extract them into variables and reuse them
across the versions that share them:
let myLib = Module("MyLib", description: "The core library.")
let myLibTesting = Module("MyLibTesting", title: "Testing", group: "Testing")
let site = KilnSite(
name: "MyLib API Docs",
url: "https://api.mylib.dev",
description: "API reference for MyLib.",
theme: .default(palette: .autoLightDark(primary: .black, accent: .blue)),
docc: DocCSite(
packages: [
// A package with two version lines → a version switcher. v6 drops the
// testing target, so it lists a different module set.
APIPackage(
"me/mylib",
group: "Core",
versions: [
PackageVersion("5", name: "5.x", ref: "main", isDefault: true, modules: [myLib, myLibTesting]),
PackageVersion("4", name: "4.x", ref: "release/4.x", modules: [myLib, myLibTesting]),
PackageVersion("6-alpha", name: "6.0 (alpha)", ref: "future", isPrerelease: true, modules: [myLib]),
]
),
// Single-version shorthand: `PackageVersion.single(ref:modules:)`
// builds one default version served at the module root.
APIPackage("me/mylib-extras", group: "Core", versions: [
.single(ref: "main", modules: [Module("MyLibExtras", description: "Optional add-ons.")]),
]),
],
// Order of the section groups on the catalog page and in the switcher.
groupOrder: ["Core", "Testing"]
)
)| Type / field | Notes |
|---|---|
DocCSite(packages:groupOrder:archivesDirectory:) |
The API-reference site. groupOrder orders the catalog/switcher sections (unlisted groups sort after; ungrouped modules fall into Other). archivesDirectory defaults to archives. |
APIPackage(_ repo, group:, versions:) |
One git repo. repo is "owner/name". group is a default group for its modules. Modules are declared per PackageVersion, not on the package. |
PackageVersion(_ id, name:, ref:, isDefault:, isPrerelease:, deprecated:, prereleaseLabel:, modules:) |
A version line and the Modules it ships. id is the URL segment (URL-safe, unique per package); name is the switcher label; ref is the git branch/tag Stage A builds. Exactly one version per package is the default and it must not be a pre-release. A pre-release shows a badge (in the switchers and on catalog cards) taken from prereleaseLabel, else inferred from the id/name as alpha/beta/rc (default beta). PackageVersion.single(ref:modules:) is shorthand for a lone default version. |
Module(_ name, title:, group:, description:) |
A DocC target. name keys the archive and the URL and must be unique across the whole site; title overrides the display name; group overrides the package group; description is the catalog-card blurb. |
The configuration is validated at build time (unique repos, unique module names
sitewide, exactly one non-pre-release default version per package, URL-safe
version ids, …); a DocCConfigurationError describes any problem.
URLs are module-first, and DocC's own /documentation/<module> prefix is
stripped. The default version is served at the module root; other versions live
under their id:
/mylib/ # MyLib landing (default version)
/mylib/somestruct/ # a symbol
/mylib/somestruct/dosomething(_:)/ # a member
/mylib/4/ # the 4.x version's landing
/mylib/6-alpha/somestruct/ # the 6.0-alpha version's symbol
/ # the generated catalog landing page
The catalog landing page at the site root lists every module, grouped by
group in groupOrder. Every page carries a module switcher (jump to any
module) and, for multi-version packages, a per-package version switcher, both
in the sidebar.
- Sitewide search works from any page and covers every module — but only the default version of each package is indexed (non-default versions are excluded to keep the index focused).
- Non-default version pages are marked
noindexfor crawlers. - With
llmsText: true,llms.txtis a compact module index (grouped like the catalog) — no full corpus, since an API surface is large and better crawled on demand.
A DocC site builds like any Kiln site once the archives are in place:
swift run # or: kiln build
kiln serve # preview at http://127.0.0.1:8080Rendering a large multi-package API can be slow, so pass incremental: true to
reuse output for packages whose archives are unchanged since the last build:
try await Kiln.build(site, contentDirectory: "Content",
outputDirectory: "site", incremental: true)A fresh checkout has no previous build to reuse, so CI still does a full build; incremental only helps repeated local builds.
A build produces a static site with pretty ("directory") URLs:
public/
├── index.html # default language at the root
├── guides/configuration/index.html
├── de/ # other languages under /<locale>/
│ ├── index.html
│ └── guides/configuration/index.html
├── search/search_index.json # per-language search index
├── de/search/search_index.json
├── 404.html # per-language error pages
├── de/404.html
├── index.md # raw-markdown copy of each page (for AI tools)
├── guides/configuration/index.md
├── _kiln/ # bundled theme assets (css/js)
├── sitemap.xml
├── robots.txt
├── llms.txt # AI/agent index (llmstxt.org)
├── llms-full.txt # full markdown corpus
└── … # your content assets, copied as-is
Examples/ExampleSite is Kiln's own documentation
site — a complete, runnable project that consumes Kiln as a dependency, and
the canonical reference for how a real site is wired together (multi-page nav,
localisation with fallback, SEO, search, and a 0.9 version that demonstrates
the version switcher). Build and preview it with the CLI:
cd Examples/ExampleSite
kiln serve --directory public(or swift run && python3 -m http.server --directory public without the CLI).
swift build
swift testCI builds and tests on both macOS and Linux and verifies the example site builds
on every push to main and every pull request.
MIT. See LICENSE.