krz/orgo

Lightning fast org-mode static site generator.

clone: git clone https://gitbay.org/krz/orgo.git

903ac6c8d657e2cedd2b6edcebfc4649864ee954

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-08-11T06:25:58Z

Add a documentation site under docs/, built by org-ssg itself

Thirteen pages: install, quick start, and a ten-chapter guide covering the CLI,
configuration, collections, templates, org support, authoring, incremental builds,
watching and serving, auditing, and deploying.

The docs are an org-ssg site — org sources, a config, templates and a stylesheet in
docs/, built with `org-ssg build docs -o docs/_site`. Writing them that way was the point
as much as the result: every feature they describe is being used to render the page
describing it, so the documentation cannot drift from a tool that still builds it.

Dogfooding found four things immediately:

- Eleven broken cross-references, because the guide files are numbered for reading order
  (01-cli.org) and the prose linked to them unnumbered. The build reported every one with
  its source file, which is exactly what that reporting is for.
- The nav repeated the site title, since index.org is a top-level page and the header
  already links home. Fixed with nav.mode = "explicit", which also gives the docs a
  worked example of it.
- Highlighted code was unreadable in dark mode: syntax.css is generated from one syntect
  theme and cannot follow prefers-color-scheme. The page CSS now commits to the theme's
  palette rather than leaving one of the two schemes broken.
- syntect bundles no TOML, INI, Org or Emacs Lisp syntax, which a config-heavy docs site
  hits on its first page. Now documented, with the list of what *is* bundled — the docs'
  own uncoloured toml blocks are the example.

The site also exercises features end to end that only had unit tests: template
inheritance via {% extends %} with a block override, a collection sorted by path for
reading order rather than by date, a custom #+LEDE: keyword reaching the layout through
page.keywords, page.toc rendered as a nested tree, and an asset copied through the build.
 .gitignore                      |   1 +
 README.md                       |   7 ++
 docs/guide/01-cli.org           | 122 +++++++++++++++++++++++
 docs/guide/02-configuration.org | 185 ++++++++++++++++++++++++++++++++++
 docs/guide/03-collections.org   | 216 ++++++++++++++++++++++++++++++++++++++++
 docs/guide/04-templates.org     | 175 ++++++++++++++++++++++++++++++++
 docs/guide/05-org-support.org   | 172 ++++++++++++++++++++++++++++++++
 docs/guide/06-authoring.org     | 146 +++++++++++++++++++++++++++
 docs/guide/07-incremental.org   | 115 +++++++++++++++++++++
 docs/guide/08-workflow.org      | 115 +++++++++++++++++++++
 docs/guide/09-auditing.org      |  94 +++++++++++++++++
 docs/guide/10-deploying.org     | 114 +++++++++++++++++++++
 docs/index.org                  |  80 +++++++++++++++
 docs/install.org                |  96 ++++++++++++++++++
 docs/org-ssg.toml               |  44 ++++++++
 docs/quickstart.org             | 165 ++++++++++++++++++++++++++++++
 docs/style.css                  | 130 ++++++++++++++++++++++++
 docs/templates/base.html        |  51 ++++++++++
 docs/templates/list.html        |  14 +++
 19 files changed, 2042 insertions(+)

diff --git a/.gitignore b/.gitignore
index ea8c4bf..66ad400 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,2 @@
 /target
+_site/
diff --git a/README.md b/README.md
index 44db78b..ce26b45 100644
--- a/README.md
+++ b/README.md
@@ -13,6 +13,13 @@ hashing**, treated as a first-class architectural concern from day one. The disc
 it imposes on the data model — pure, hashable, dependency-tracked units — is the real
 deliverable, even while the corpus is small enough that a full rebuild is instant.
 
+**Full documentation is in [`docs/`](docs/)** — a site written in org and built by
+org-ssg itself. Build and read it with:
+
+```bash
+cargo run -- serve docs -o docs/_site
+```
+
 ## Quick start
 
 ```bash
diff --git a/docs/guide/01-cli.org b/docs/guide/01-cli.org
new file mode 100644
index 0000000..e127de0
--- /dev/null
+++ b/docs/guide/01-cli.org
@@ -0,0 +1,122 @@
+#+TITLE: Command reference
+#+DESCRIPTION: Every command and flag, and what each is actually for.
+#+LEDE: Six commands: build, serve, watch, audit, init, clean.
+
+* build
+
+#+BEGIN_SRC sh
+org-ssg build <INPUT> -o <OUTPUT> [--no-cache] [--strict] [--drafts] [--config FILE]
+#+END_SRC
+
+If =INPUT= is a directory, it is walked and built into a linked site at =OUTPUT=. If it
+is a single =.org= file, one HTML file is written — useful for one-off conversions,
+though with no other documents to resolve against, internal links keep a best-effort URL.
+
+| Flag | Effect |
+|------+--------|
+| =-o, --output= | Output directory (or =.html= file for single-file input). Required for a site. |
+| =--no-cache= | Ignore the incremental cache and re-render every page. |
+| =--strict= | Broken internal links and parse diagnostics become a non-zero exit. |
+| =--drafts= | Include pages marked =#+DRAFT:=. |
+| =--config FILE= | Use this config instead of =org-ssg.toml= in the source directory. |
+
+The summary line reports what happened:
+
+#+BEGIN_EXAMPLE
+built 182 page(s) (4 rendered, 178 cached), copied 3 asset(s) from src -> _site (0 unresolved link(s), 0 diagnostic(s))
+#+END_EXAMPLE
+
+=rendered= is the invalidation set — the pages that actually needed rewriting. On a
+second build with nothing changed it is zero.
+
+** --strict is for CI
+
+Without it, a broken link is a warning and the build succeeds. With it, the build fails
+and names every problem. Use it wherever a bad build should not ship:
+
+#+BEGIN_SRC sh
+org-ssg build content -o _site --strict
+#+END_SRC
+
+* serve
+
+#+BEGIN_SRC sh
+org-ssg serve <INPUT> -o <OUTPUT> [-p PORT] [--host HOST] [--drafts] [--config FILE]
+#+END_SRC
+
+Builds, watches, serves, and reloads the browser when a rebuild lands. This is the
+command to use while writing.
+
+| Flag | Default | Effect |
+|------+---------+--------|
+| =-p, --port= | =3000= | Port to listen on. |
+| =--host= | =127.0.0.1= | Address to bind. |
+| =--drafts= | off | Include =#+DRAFT:= pages, so you can see what you are writing. |
+
+*It binds loopback on purpose.* A development server serves unreviewed drafts off your
+laptop, so reaching the local network is something you ask for:
+
+#+BEGIN_SRC sh
+org-ssg serve content -o _site --host 0.0.0.0
+#+END_SRC
+
+The live-reload script is injected into responses and never written to disk, so what you
+deploy stays clean. Details in [[file:../guide/08-workflow.org][Watching and serving]].
+
+* watch
+
+#+BEGIN_SRC sh
+org-ssg watch <INPUT> -o <OUTPUT> [--no-cache] [--strict] [--drafts] [--config FILE]
+#+END_SRC
+
+Rebuilds on filesystem events with no server — for when something else is already serving
+the output, or you just want the build to keep up as you write.
+
+* audit
+
+#+BEGIN_SRC sh
+org-ssg audit <INPUT>
+#+END_SRC
+
+Reports which org constructs a corpus uses and how they land against what org-ssg
+supports, plus a census of every keyword, block type, drawer and link scheme seen. Point
+it at your notes before trusting a tool with them. See [[file:../guide/09-auditing.org][Auditing a corpus]].
+
+It prints names, counts and =file:line= locations — never document text — so an audit of
+private notes is safe to share.
+
+* init
+
+#+BEGIN_SRC sh
+org-ssg init [DIRECTORY]
+#+END_SRC
+
+Scaffolds a working site: a fully commented config, an editable copy of the built-in
+layout, listing and tag templates, an RSS template, a home page and a first post.
+Defaults to the current directory.
+
+Only files that do not already exist are written, so it is safe to run inside a directory
+that already has content — it fills in what is missing and leaves the rest alone.
+
+* clean
+
+#+BEGIN_SRC sh
+org-ssg clean <OUTPUT>
+#+END_SRC
+
+Removes the output directory, including the incremental cache manifest inside it. You
+rarely need this: the cache is versioned and discards itself when it stops being valid.
+
+* Exit codes
+
+| Code | Meaning |
+|------+---------|
+| =0= | Success. Warnings may still have been printed. |
+| =1= | The build failed, or =--strict= found problems. |
+
+Diagnostics are printed as =file:line: message=, the form an editor can jump to:
+
+#+BEGIN_EXAMPLE
+warning: blog/post.org:42: unterminated `#+BEGIN_SRC` block (no `#+END_SRC`); everything to the end of the file was read as block content
+warning: index.org: unresolved link [[#setup]]
+#+END_EXAMPLE
diff --git a/docs/guide/02-configuration.org b/docs/guide/02-configuration.org
new file mode 100644
index 0000000..a6fbcf0
--- /dev/null
+++ b/docs/guide/02-configuration.org
@@ -0,0 +1,185 @@
+#+TITLE: Configuration
+#+DESCRIPTION: Every setting in org-ssg.toml, what it changes, and what it costs.
+#+LEDE: All of it optional. A missing config is a valid config.
+
+org-ssg looks for =org-ssg.toml= in the source directory. Pass a different path with
+=--config=. Every field has a default, so a directory of org files with no config still
+builds a complete site.
+
+A *missing* config is normal and silent. A *malformed* one is an error, and an unknown
+key is rejected by name — a misspelled setting that silently does nothing is how people
+lose an afternoon.
+
+* The whole file
+
+#+BEGIN_SRC toml
+[site]
+title = "org-ssg site"
+base_url = ""
+description = ""
+language = "en"
+
+[nav]
+mode = "top-level"
+# pages = ["index.org", "about.org"]
+
+[templates]
+dir = "templates"
+expose_page_list = false
+
+[highlight]
+theme = "InspiredGitHub"
+
+[build]
+drafts = false
+
+[html]
+heading_offset = 1
+toc = true
+section_numbers = false
+#+END_SRC
+
+Plus any number of =[[collections]]= blocks, documented in [[file:03-collections.org][Collections]].
+
+* [site]
+
+| Key | Default | Meaning |
+|-----+---------+---------|
+| =title= | ="org-ssg site"= | Site name. Available as ={{ site.title }}=. |
+| =base_url= | ="" | Absolute origin, *no trailing slash*. |
+| =description= | ="" | Available as ={{ site.description }}=. |
+| =language= | ="en"= | Goes in =<html lang>= in the built-in layout. |
+
+** base_url
+
+Leave it empty and the site is built entirely with relative URLs, which means it works
+from a subdirectory, from a filesystem path, and from any origin. That portability is why
+it is the default.
+
+Set it when you need absolute URLs, which two things require: *feeds*, because a feed is
+read away from the site that served it, and *canonical links*. The =absolute= template
+filter turns a site-root-relative path into a full URL, and errors if there is no base
+URL to build one from — rather than quietly emitting a relative URL that would make the
+feed invalid everywhere while looking fine.
+
+A trailing slash is rejected, because =https://example.com/= plus =blog/x.html= is
+=https://example.com//blog/x.html=.
+
+* [nav]
+
+The navigation shared by every page.
+
+| =mode= | Includes |
+|--------+----------|
+| ="top-level"= (default) | Pages at the site root. |
+| ="all"= | Every page. |
+| ="explicit"= | Only =nav.pages=, in the order listed. |
+| ="none"= | Nothing. |
+
+*"all" makes output quadratic.* Each of /n/ pages carries /n/ links, so total output
+grows with the square of the site. On a 1,790-page site that was 284 MB of mostly
+navigation. It is fine for a handful of pages and a trap beyond that.
+
+="top-level"= keeps the nav a map of the site's top level rather than an index of its
+contents, so nav size does not depend on how much you write.
+
+** explicit
+
+#+BEGIN_SRC toml
+[nav]
+mode = "explicit"
+pages = ["index.org", "about.org", "uses.org"]
+#+END_SRC
+
+Paths are *source* paths relative to the source root, and the order given is the order
+rendered — a hand-written nav is a designed sequence, not an alphabetical one. Naming a
+page that does not exist is an error, because a silently shorter nav is a poor way to
+learn about a typo.
+
+** Section landing pages in the nav
+
+If your sections live in subdirectories, none of them are top-level pages. Put the
+section's *generated* index in the nav instead, with =nav = true= on its collection —
+that is the page a nav entry should point at anyway.
+
+* [templates]
+
+| Key | Default | Meaning |
+|-----+---------+---------|
+| =dir= | ="templates"= | Directory of templates, relative to the source root. |
+| =expose_page_list= | =false= | Give every template a =pages= list of all page metadata. |
+
+** expose_page_list costs incremental precision
+
+With it on, any page can read every page's metadata — so adding one page can change any
+page's output, and the whole site must re-render on every add, rename or retitle. That is
+the trade for building an index by hand in a template. Most people want a
+[[file:03-collections.org][collection]] instead, which gets the same result while keeping
+adding a post a one-page rebuild.
+
+* [highlight]
+
+| Key | Default |
+|-----+---------|
+| =theme= | ="InspiredGitHub"= |
+
+Any theme syntect ships: =InspiredGitHub=, =Solarized (dark)=, =Solarized (light)=,
+=base16-ocean.dark=, =base16-ocean.light=, =base16-eighties.dark=, =base16-mocha.dark=.
+An unknown name is an error listing the valid ones.
+
+Highlighting emits *CSS classes*, never inline styles, so themes live in a stylesheet.
+Each build writes =syntax.css= into the output and every page links it.
+
+* [build]
+
+| Key | Default | Meaning |
+|-----+---------+---------|
+| =drafts= | =false= | Include pages marked =#+DRAFT:=. |
+
+=--drafts= on the command line turns this on for one run. The flag can only turn drafts
+on; it never turns off a config that asked for them.
+
+* [html]
+
+| Key | Default | Meaning |
+|-----+---------+---------|
+| =heading_offset= | =1= | Added to every org heading level. |
+| =toc= | =true= | Make =page.toc= available to templates. |
+| =section_numbers= | =false= | Number headings =1.=, =1.1.=, … |
+
+** heading_offset
+
+A level-1 org heading renders as =<h2>= by default, because the layout supplies the page
+title as the =<h1>=. This matches Emacs, whose =org-html-toplevel-hlevel= is 2 for the
+same reason.
+
+Set it to =0= if your template renders no title of its own — otherwise the document
+starts at =<h2>= with nothing above it.
+
+** section_numbers differs from Emacs on purpose
+
+=org-export-with-section-numbers= is on in Emacs, so an org-published site inherits
+numbered headings whether or not anyone chose them. Most sites do not want them, so the
+default here is the taste rather than the inheritance. Turning it on emits Emacs' own
+=section-number-N= classes.
+
+* Per-file overrides
+
+Org's own =#+OPTIONS:= switches override the site setting for one document:
+
+#+BEGIN_SRC org
+,#+OPTIONS: toc:nil num:t
+#+END_SRC
+
+| Switch | Overrides |
+|--------+-----------|
+| =toc:nil= / =toc:t= | =[html] toc= |
+| =num:t= / =num:nil= | =[html] section_numbers= |
+
+Off is spelled =nil=, =false=, =no=, =0= or =off=; anything else is on.
+
+* Configuration is a cache input
+
+The resolved config is hashed into every page's render key, so editing =org-ssg.toml=
+re-renders exactly the pages it affects — which for most settings is all of them. You
+never need =--no-cache= after a config change.
diff --git a/docs/guide/03-collections.org b/docs/guide/03-collections.org
new file mode 100644
index 0000000..714bb67
--- /dev/null
+++ b/docs/guide/03-collections.org
@@ -0,0 +1,216 @@
+#+TITLE: Collections
+#+DESCRIPTION: Generated pages — blog indexes, tag pages, pagination and RSS feeds.
+#+LEDE: The one kind of output that is not a translation of some input.
+
+A blog index exists because a set of posts exists, not because someone wrote
+=index.org=. A =[[collections]]= block declares one: a source directory in, an output
+file out, through a template.
+
+Keeping it declarative means an RSS feed is the same mechanism with an XML template
+rather than a second feature.
+
+* A blog index
+
+#+BEGIN_SRC toml
+[[collections]]
+source = "blog"             # directory to list; empty means every page
+output = "blog/index.html"  # where to write it
+template = "list.html"      # template file name
+title = "Blog"
+sort = "date"               # date | title | path
+order = "desc"              # desc | asc
+nav = true                  # put this page in the site nav
+#+END_SRC
+
+The template receives the collection's entries as =pages=, already sorted, plus the usual
+=site=, =nav= and =root=:
+
+#+BEGIN_SRC html
+{% extends "base.html" %}
+{% block content %}
+<ul>
+  {% for post in pages %}
+  <li>
+    <time datetime="{{ post.date_iso }}">{{ post.date_iso }}</time>
+    <a href="{{ root }}{{ post.url }}">{{ post.title }}</a>
+    <p>{{ post.excerpt | truncate(180) }}</p>
+  </li>
+  {% endfor %}
+</ul>
+{% endblock %}
+#+END_SRC
+
+** Sorting
+
+=sort= is =date= (default), =title= or =path=; =order= is =desc= (default) or =asc=.
+
+Date sorting uses =page.date_iso=, the =YYYY-MM-DD= extracted from =#+DATE:= whatever org
+syntax it was written in — =[2025-09-05 Fri 10:21:00]=, =<2024-05-01 Wed>= or a bare
+=2024-05-01= all work.
+
+*Pages with no parseable date sort last in either direction*, so an undated draft never
+leads a dated archive.
+
+** nav = true
+
+The listing page joins the site navigation. This is how a section landing page — =/blog/=,
+=/notes/= — gets into a nav built from top-level pages, and it points at the right thing:
+the section, not any one post in it.
+
+* Tag pages
+
+Add =group_by= and the collection emits one page /per group/ instead of one page total,
+plus an optional index of the groups:
+
+#+BEGIN_SRC toml
+[[collections]]
+source = "blog"
+group_by = "tags"                  # "tags", or any #+KEYWORD: name
+output = "tags/{tag}.html"         # {tag} becomes each group's slug
+template = "tag.html"
+title = "Tagged: {tag}"
+index_output = "tags/index.html"
+index_template = "tags.html"
+index_title = "Tags"
+nav = true                         # adds the *index*, not every tag
+#+END_SRC
+
+A group page receives its own posts as =pages= and itself as =group=:
+
+#+BEGIN_SRC html
+<h1>{{ group.name }} ({{ group.count }})</h1>
+{% for post in pages %}<a href="{{ root }}{{ post.url }}">{{ post.title }}</a>{% endfor %}
+#+END_SRC
+
+The index receives =groups=, sorted by name:
+
+#+BEGIN_SRC html
+<ul>{% for tag in groups %}
+  <li><a href="{{ root }}{{ tag.url }}">{{ tag.name }}</a> ({{ tag.count }})</li>
+{% endfor %}</ul>
+#+END_SRC
+
+** Grouping by anything
+
+=group_by = "tags"= is multi-valued: a post appears under every tag it carries. Any other
+value names a single-valued =#+KEYWORD:=, so =group_by = "category"= buckets pages by
+=#+CATEGORY:= with no extra machinery.
+
+** Two tags that would collide are an error
+
+=web_dev= and =web@dev= both slugify to =web-dev=, so one page would silently overwrite
+the other. That is a build error naming both values.
+
+* Pagination
+
+#+BEGIN_SRC toml
+[[collections]]
+source = "blog"
+output = "blog/index.html"
+paginate = 10
+paginate_output = "blog/page/{n}.html"   # {n} is the 1-based page number
+#+END_SRC
+
+*Page 1 stays at =output=*, so a section's canonical URL never moves as its page count
+changes. Only pages 2..N are named by =paginate_output=.
+
+The template gets a =paginator=:
+
+#+BEGIN_SRC html
+{% if paginator and paginator.total > 1 %}
+<nav>
+  {% if paginator.prev_url %}<a href="{{ paginator.prev_url }}">Newer</a>{% endif %}
+  {% for pg in paginator.pages %}
+    <a href="{{ pg.url }}"{% if pg.current %} aria-current="page"{% endif %}>{{ pg.number }}</a>
+  {% endfor %}
+  {% if paginator.next_url %}<a href="{{ paginator.next_url }}">Older</a>{% endif %}
+</nav>
+{% endif %}
+#+END_SRC
+
+| Field | Meaning |
+|-------+---------|
+| =current=, =total= | This page's number, and how many there are. |
+| =per_page=, =total_entries= | As configured, and across the whole listing. |
+| =prev_url=, =next_url= | =none= at the ends. |
+| =first_url=, =last_url= | Always present. |
+| =pages= | =[{number, url, current}]= for a numbered strip. |
+
+Every URL is relative to the page carrying it, so links work from page 1
+(=page/2.html=) and from page 5 (=../index.html=, =6.html=) without the template knowing
+where it sits. An unpaginated collection has no =paginator= at all, so
+={% if paginator %}= is a reliable test in a shared template.
+
+Grouping and pagination compose: each group paginates independently, which is why
+=paginate_output= needs ={tag}= as well as ={n}= on a grouped collection.
+
+* An RSS feed
+
+A feed is a listing page with an XML template. Templates load by full filename and any
+extension, so:
+
+#+BEGIN_SRC toml
+[[collections]]
+source = "blog"
+output = "feed.xml"
+template = "feed.xml"
+title = "Feed"
+#+END_SRC
+
+#+BEGIN_SRC html
+<?xml version="1.0" encoding="utf-8"?>
+<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
+<channel>
+  <title>{{ site.title }}</title>
+  <link>{{ "index.html" | absolute }}</link>
+  <atom:link href="{{ page.url | absolute }}" rel="self" type="application/rss+xml"/>
+  {% for post in pages %}
+  <item>
+    <title>{{ post.title }}</title>
+    <link>{{ post.url | absolute }}</link>
+    <guid isPermaLink="true">{{ post.url | absolute }}</guid>
+    <pubDate>{{ post.date_iso | rfc822 }}</pubDate>
+  </item>
+  {% endfor %}
+</channel>
+</rss>
+#+END_SRC
+
+This needs =site.base_url=, because a feed with relative links is invalid everywhere it
+is read. =org-ssg init= writes this template and leaves the collection commented out
+until there is a base URL to make absolute links from.
+
+* Every setting
+
+| Key | Default | Meaning |
+|-----+---------+---------|
+| =source= | ="" | Directory to list. Empty means every page. |
+| =output= | ="index.html"= | Where to write. Needs ={tag}= when grouped. |
+| =template= | ="list.html"= | Template file name. |
+| =title= | ="Index"= | ={{ page.title }}=. ={tag}= is substituted when grouped. |
+| =group_by= | ="" | ="tags"=, or a =#+KEYWORD:= name. Empty means one page. |
+| =index_output= | ="" | Where to write the group index. Empty means none. |
+| =index_template= | ="tags.html"= | Template for the group index. |
+| =index_title= | ="Tags"= | Title for the group index. |
+| =sort= | ="date"= | =date=, =title= or =path=. |
+| =order= | ="desc"= | =desc= or =asc=. |
+| =paginate= | =0= | Entries per page. =0= means no pagination. |
+| =paginate_output= | ="" | Where pages 2..N go. Needs ={n}=. |
+| =nav= | =false= | Add this page — or its index, when grouped — to the nav. |
+
+* Incremental behaviour
+
+A listing page is cached on the entries it lists, so:
+
+- Adding a post re-renders that post, its section index, its tag pages and the tag index
+  whose counts changed. Nothing else.
+- Editing a post's *body* changes no listing metadata, so the index is not touched at
+  all.
+- Retitling a post does re-render the listings that display the title.
+
+A tag page depends on its own posts and not on the other groups, which is why =groups= is
+given to the index and not to every group page: a page that can see every group would
+depend on every group, and one new post would re-render every tag page.
+
+When a collection shrinks below a page boundary, the pages that no longer exist are
+deleted rather than left serving stale content.
diff --git a/docs/guide/04-templates.org b/docs/guide/04-templates.org
new file mode 100644
index 0000000..ecc4286
--- /dev/null
+++ b/docs/guide/04-templates.org
@@ -0,0 +1,175 @@
+#+TITLE: Templates
+#+DESCRIPTION: Layouts, inheritance, every variable and filter available to a template.
+#+LEDE: minijinja layouts loaded from disk, hashed into the cache, replaceable entirely.
+
+Templates live in the directory named by =[templates] dir=, default =templates/=. They
+are [[https://docs.rs/minijinja][minijinja]] templates — Jinja2 syntax — loaded at build
+time, so editing one and rebuilding is the whole edit cycle.
+
+* base.html replaces the layout
+
+A file called =base.html= becomes the page layout. Without one, a built-in layout is used
+— which is what makes a bare directory of org files build into a real site.
+
+#+BEGIN_SRC html
+<!DOCTYPE html>
+<html lang="{{ site.language }}">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <title>{{ page.title }} — {{ site.title }}</title>
+  <link rel="stylesheet" href="{{ root }}style.css">
+  {% if stylesheet %}<link rel="stylesheet" href="{{ stylesheet }}">{% endif %}
+</head>
+<body>
+  <nav>{% for item in nav %}<a href="{{ item.url }}">{{ item.title }}</a>{% endfor %}</nav>
+  <main>
+    <h1>{{ page.title }}</h1>
+    {{ body | safe }}
+  </main>
+</body>
+</html>
+#+END_SRC
+
+A template that does not compile is a *build error*, not a fallback to the default —
+someone editing a layout should see the mistake, not output that looks like their edit
+did nothing.
+
+* Names are full filenames
+
+Templates are registered under their full relative filename: =base.html=,
+=partials/head.html=, =feed.xml=. That is what ={% extends "base.html" %}= names, and it
+is why a template can have any extension — which is how an RSS feed is just a listing
+page.
+
+#+BEGIN_SRC html
+{% extends "base.html" %}
+{% block content %}<p>Only this part differs.</p>{% endblock %}
+#+END_SRC
+
+Subdirectories work, so ={% include "partials/header.html" %}= does what you expect.
+
+* Variables
+
+** body
+
+The rendered page HTML. Always use ={{ body | safe }}= — it is already HTML, and
+escaping it would print tags at the reader.
+
+Empty on generated pages, which build their content from =pages= or =groups= instead.
+
+** page
+
+| Field | Meaning |
+|-------+---------|
+| =title= | =#+TITLE:=, or the filename stem. |
+| =url= | Output path relative to the site root, e.g. =blog/post.html=. |
+| =source= | Source path relative to the source root, e.g. =blog/post.org=. |
+| =date= | =#+DATE:= verbatim, in whatever org syntax was written. |
+| =date_iso= | The =YYYY-MM-DD= inside it, or =none=. |
+| =tags= | =#+FILETAGS:=, split. |
+| =excerpt= | =#+DESCRIPTION:=, or the first paragraph. |
+| =word_count= | Words of prose, excluding code blocks. |
+| =reading_time= | Minutes at 200 wpm, rounded up. |
+| =toc= | The heading tree. See below. |
+| =keywords= | *Every* =#+KEYWORD:=, by lowercased name. |
+
+=page.keywords= is the escape hatch: =#+CUSTOM_THING: x= is
+={{ page.keywords.custom_thing }}=, so your own metadata works without org-ssg knowing it
+exists.
+
+** site
+
+=site.title=, =site.base_url=, =site.description=, =site.language= — straight from
+=[site]= in the config.
+
+** nav
+
+A list of ={title, url}=, with URLs relative to the current page.
+
+** root
+
+The =../= prefix back to the site root from this page: empty at the top level, =../= one
+level down. Prefix it to any site-root-relative path so the same template works at any
+depth:
+
+#+BEGIN_SRC html
+<link rel="stylesheet" href="{{ root }}style.css">
+<a href="{{ root }}{{ post.url }}">{{ post.title }}</a>
+#+END_SRC
+
+** stylesheet
+
+URL of the generated =syntax.css=, relative to this page. Link it or code blocks are
+unstyled.
+
+** pages, group, groups, paginator
+
+Present on generated pages; see [[file:03-collections.org][Collections]]. =pages= is also
+available on every page when =[templates] expose_page_list = true=.
+
+** page.toc
+
+The page's headings as a *tree* — a table of contents is one, and rebuilding a tree from
+a flat list of levels inside a template is what Jinja is worst at.
+
+Each entry has =title=, =anchor=, =level= and =children=:
+
+#+BEGIN_SRC html
+{% macro toc_list(entries) %}
+<ul>{% for e in entries %}
+  <li><a href="#{{ e.anchor }}">{{ e.title }}</a>
+  {%- if e.children %}{{ toc_list(e.children) }}{% endif %}</li>
+{% endfor %}</ul>
+{% endmacro %}
+
+{% if page.toc | length > 1 %}{{ toc_list(page.toc) }}{% endif %}
+#+END_SRC
+
+Anchors come from the same function that emits heading =id= attributes, so a TOC link
+cannot drift from the heading it points at. The tree is empty when the page has no
+headings, when =[html] toc = false=, or when the document says =#+OPTIONS: toc:nil=.
+
+* Filters
+
+Beyond minijinja's built-ins:
+
+| Filter | Does |
+|--------+------|
+| =absolute= | Site-root-relative path → absolute URL, using =site.base_url=. |
+| =rfc822= | Any org or ISO date → the format RSS =pubDate= requires. |
+| =truncate(n)= | Shorten to at most /n/ characters on a word boundary, with an ellipsis. |
+
+** absolute
+
+#+BEGIN_SRC html
+<link>{{ post.url | absolute }}</link>
+#+END_SRC
+
+Apply it to the *site-root-relative* values — =page.url=, =pages[].url=, =group.url= —
+and not to =nav[].url=, =paginator.*_url=, =stylesheet= or =root=, which are relative to
+the page carrying them and already correct there.
+
+An already-absolute URL passes through, so a template can apply it uniformly to internal
+paths and external links. With no =base_url= it is an *error* naming the setting, rather
+than a relative URL that would make a feed invalid.
+
+** truncate
+
+minijinja ships no truncate, and an excerpt is usually a whole paragraph — so without one
+a listing's only options are the full paragraph or nothing.
+
+* Escaping
+
+Output is HTML-escaped by default, because titles and text are author content.
+={{ body | safe }}= is the deliberate exception.
+
+Unlike stock minijinja, =/= is *not* escaped. Escaping it is a defence for values
+interpolated into JavaScript, and since =<= is escaped anyway it buys nothing in an HTML
+document — while making every URL read =..&#x2f;index.html=. Templates emit a lot of
+URLs.
+
+* Templates are a cache input
+
+Every template's source is hashed, so editing a layout re-renders the pages that use it.
+A design change never leaves a site half-updated.
diff --git a/docs/guide/05-org-support.org b/docs/guide/05-org-support.org
new file mode 100644
index 0000000..be94681
--- /dev/null
+++ b/docs/guide/05-org-support.org
@@ -0,0 +1,172 @@
+#+TITLE: Org support
+#+DESCRIPTION: Exactly which org syntax is handled, which is not, and how the rest degrades.
+#+LEDE: A deliberate subset, with the boundary enforced by tests rather than by hope.
+
+org-ssg parses a defined slice of org. The boundary is not aspirational: every supported
+construct has a golden-file test, and every excluded one has a test asserting how it
+degrades. That is what stops the parser drifting toward all-of-org.
+
+* Supported
+
+** Headings
+
+Nesting by star count, with TODO keywords, priority cookies and tags:
+
+#+BEGIN_SRC org
+,* TODO [#A] Write the parser                                        :work:rust:
+,:PROPERTIES:
+,:CUSTOM_ID: write-parser
+,:END:
+#+END_SRC
+
+The keyword set is Emacs' default — =TODO= and =DONE= — matched on a word boundary, so a
+heading beginning "TODOs are great" is a plain title. Keyword and priority markup uses
+Emacs' own export classes.
+
+Every heading gets an =id=: its =:CUSTOM_ID:= if it has one, else its =:ID:=, else a slug
+of its text.
+
+** Text and inline markup
+
+=*bold*=, =/italic/=, =_underline_=, =+strike+=, ~=verbatim=~ and =~code~=. Links in
+every org form — external, =[[*Heading]]=, =[[#custom-id]]=, =[[id:...]]=,
+=[[file:other.org]]= — plus bare URLs in running text.
+
+Timestamps, active and inactive, with times and ranges, render as =<time>= with a
+machine-readable =datetime=.
+
+** Lists
+
+Unordered, ordered and description lists, nested by indentation, with checkboxes and
+multi-paragraph items:
+
+#+BEGIN_SRC org
+- outer item
+  - inner item
+- [X] a checked item
+- term :: definition
+#+END_SRC
+
+** Blocks
+
+=SRC= (syntax highlighted), =EXAMPLE=, =QUOTE=, =CENTER= and =EXPORT=. A source block
+inside a quote block works, because block ends match their own kind.
+
+An =html= export block passes through verbatim; every other backend is dropped, because
+emitting LaTeX into an HTML page is worse than emitting nothing.
+
+An unknown block type keeps its content as an example block rather than vanishing.
+
+*** Which languages highlight
+
+Highlighting uses the syntax definitions [[https://docs.rs/syntect][syntect]] bundles.
+A language it does not know is not an error — the block renders as escaped
+=<pre><code class="language-…">= with its content intact, just uncoloured.
+
+Recognised, among others: =bash= / =sh=, =c=, =c++=, =css=, =clojure=, =diff=, =erlang=,
+=go=, =haskell=, =html=, =java=, =javascript=, =json=, =latex=, =lisp=, =lua=,
+=makefile=, =markdown=, =matlab=, =objective-c=, =ocaml=, =perl=, =php=, =python=, =r=,
+=ruby=, =rust=, =scala=, =sql=, =tcl=, =xml=, =yaml=.
+
+*Not* bundled, and worth knowing before you write a page full of them: *TOML*, *INI*,
+*Org* and *Emacs Lisp*. The pages of this documentation are a live example — its
+=#+BEGIN_SRC toml= blocks are readable but uncoloured.
+
+** Tables and footnotes
+
+Pipe tables, with the rule row establishing a header band. Footnotes in all three forms —
+=[fn:1]= references, =[fn:1]= definitions and =[fn:1:inline text]= — rendered as a
+numbered, back-linked notes section.
+
+** Images
+
+A description-less link to an image file renders as =<img>=. With an affiliated
+=#+CAPTION:= or =#+ATTR_HTML:= it becomes a =<figure>= with the caption as both
+=<figcaption>= and alt text:
+
+#+BEGIN_SRC org
+,#+CAPTION: The pipeline, end to end
+,#+ATTR_HTML: :width 640 :class diagram
+[[file:pipeline.svg]]
+#+END_SRC
+
+Links to non-=.org= files are understood as asset links: neither resolved nor reported as
+broken.
+
+* Keywords with meaning
+
+| Keyword | Effect |
+|---------+--------|
+| =#+TITLE:= | Page title. Falls back to the filename stem. |
+| =#+DATE:= | Sorts listings. Any org date syntax. |
+| =#+DESCRIPTION:= | The excerpt shown in listings. |
+| =#+FILETAGS:= | Tags, for grouping and =page.tags=. |
+| =#+SLUG:= | Sets the output filename. |
+| =#+DRAFT:= | Keeps the page out of the build. |
+| =#+OPTIONS:= | Per-file export switches. |
+| =#+CAPTION:=, =#+ATTR_HTML:= | Attach to the image below them. |
+
+Every other =#+KEYWORD:= is available to templates as
+={{ page.keywords.that_keyword }}=, so metadata org-ssg has never heard of still reaches
+your layout.
+
+* Not supported, and what happens instead
+
+The contract is not that these work — it is that they degrade predictably and never crash
+a build.
+
+| Construct | What happens |
+|-----------+--------------|
+| Babel execution, =:results= | The source block renders as code. A checked-in =#+RESULTS:= block is *dropped*. |
+| =#+TBLFM:= | Inert. The table renders with the values as written. |
+| =#+INCLUDE:= | Never expanded. Captured as an inert keyword. |
+| LaTeX, MathJax | Survives as the literal text you typed. |
+| Macros ={{{name}}}=, radio targets | Literal text. |
+| Drawers other than =PROPERTIES= | Captured and dropped, including =LOGBOOK=. |
+| Non-HTML export blocks | Dropped entirely. |
+| Entities =\alpha= | Literal text. |
+| =#+TODO:= sequences | Not read; the default keyword set is used. |
+| Planning lines, =: = fixed-width | Render as ordinary paragraphs. |
+
+** Why #+RESULTS: is dropped rather than rendered
+
+Babel is never executed, so a checked-in results block is output from someone else's
+Emacs session at some other time. Emitting it would put unverifiable content on the page
+dressed as real content. The source block renders; its stale output does not.
+
+* Diagnostics
+
+Malformed input degrades rather than failing — but not *silently*, because the worst
+cases are severe. An unterminated =#+BEGIN_SRC= reads the rest of the file as block
+content, and an unterminated drawer does the same but renders to nothing, so one missing
+line can delete most of a page.
+
+#+BEGIN_EXAMPLE
+warning: post.org:42: unterminated `#+BEGIN_SRC` block (no `#+END_SRC`); everything to
+the end of the file was read as block content
+#+END_EXAMPLE
+
+Diagnostics carry exact line numbers through arbitrarily nested constructs, and
+=--strict= turns them into a non-zero exit.
+
+* Measured against Emacs
+
+=cargo test --test oracle= exports each fixture with org's own exporter via
+=emacs --batch= and snapshots the disagreement. Heading structure, list nesting and
+source-block text are asserted to match exactly.
+
+The rest differs deliberately:
+
+| | org-ssg | Emacs |
+|-+---------+-------|
+| emphasis | =<em>= / =<strong>= | =<i>= / =<b>= |
+| captioned image | =<figure>= / =<figcaption>= | =<p>= + "Figure 1: …" |
+| timestamp | =<time datetime="…">= | literal =<2024-01-15 Mon>= |
+| footnotes | =<section><ol>= | =<h2>Footnotes:</h2>= |
+| heading anchor | slug of the text | =org1a2b3c4= |
+| code | =<pre><code>= | =<pre>= |
+
+One genuine semantic difference: org treats a single blank line between a =1.= list and a
+following =-= list as *one* list, keeping the first item's bullet type. org-ssg starts a
+second list. That was kept on measurement — the pattern occurred zero times across a
+179-file reference corpus — rather than on taste.
diff --git a/docs/guide/06-authoring.org b/docs/guide/06-authoring.org
new file mode 100644
index 0000000..b446096
--- /dev/null
+++ b/docs/guide/06-authoring.org
@@ -0,0 +1,146 @@
+#+TITLE: Authoring
+#+DESCRIPTION: URLs, drafts, excerpts, tables of contents — the metadata that shapes a page.
+#+LEDE: What to put at the top of a file, and what each keyword buys you.
+
+* URLs
+
+By default a source path becomes the matching output path: =blog/post.org= →
+=blog/post.html=.
+
+=#+SLUG:= overrides the *filename*, never the directory:
+
+#+BEGIN_SRC org
+,#+TITLE: AES Encryption
+,#+SLUG: aes-encryption
+#+END_SRC
+
+=blog/2018-11-28-aes-encryption.org= now publishes at =blog/aes-encryption.html=. This is
+how a date-prefixed filename — useful for sorting in a file manager — becomes a clean
+address.
+
+Slugs are reduced to a single safe path component, so a slug cannot escape the output
+directory however it was written. Two pages claiming one URL is a build error rather than
+one silently overwriting the other.
+
+Links follow slugs automatically: =[[file:blog/2018-11-28-aes-encryption.org]]= resolves
+to =blog/aes-encryption.html=.
+
+* Drafts
+
+#+BEGIN_SRC org
+,#+DRAFT: t
+#+END_SRC
+
+The page is not written at all, and is absent from listings, tag pages and navigation —
+not merely unlinked.
+
+It is also out of the symbol table, so a link *to* a draft is reported as a broken link.
+That is deliberate: it is what that link would be on the published site, and better found
+now than by a reader.
+
+#+BEGIN_SRC sh
+org-ssg serve content -o _site --drafts
+#+END_SRC
+
+The keyword is read forgivingly. =t=, =yes=, =1= and a bare =#+DRAFT:= all mean draft,
+because writing the keyword at all is the signal. Only an explicit =nil=, =false=, =no=,
+=0= or =off= means published — publishing someone's unfinished post because they typed
+=yes= instead of =t= is the wrong way to be strict.
+
+* Dates
+
+#+BEGIN_SRC org
+,#+DATE: <2026-02-02 Mon>
+,#+DATE: [2025-09-05 Fri 10:21:00]
+,#+DATE: 2024-05-01
+#+END_SRC
+
+All three work. =page.date= keeps what you wrote, and =page.date_iso= is the
+=YYYY-MM-DD= inside it — the value listings sort on and templates usually print.
+
+A page with no parseable date sorts *last* in a dated listing, in either direction, so a
+draft with no date never leads an archive.
+
+* Excerpts
+
+=page.excerpt= is =#+DESCRIPTION:= when the page sets one, and its first paragraph
+otherwise:
+
+#+BEGIN_SRC org
+,#+DESCRIPTION: How the borrow checker thinks about lifetimes.
+#+END_SRC
+
+The fallback matters more than the keyword: it means a listing has something to show
+whether or not the author ever thought about summaries. Use =truncate= in the template to
+cut a long paragraph to size.
+
+* Reading time
+
+=page.word_count= and =page.reading_time= (minutes at 200 wpm, rounded up) count *prose
+only*. Source and example blocks are excluded, because a post that is mostly a shell
+transcript should not read as an hour's work. =#+TITLE:= is metadata rendered as chrome,
+so it is not counted either.
+
+* Tags
+
+#+BEGIN_SRC org
+,#+FILETAGS: :rust:web:
+#+END_SRC
+
+Available as =page.tags=, and the input to tag pages — see
+[[file:03-collections.org][Collections]].
+
+* Table of contents
+
+Every page's heading tree is available as =page.toc= without any markup in the file. Turn
+it off for one document the way org already does:
+
+#+BEGIN_SRC org
+,#+OPTIONS: toc:nil
+#+END_SRC
+
+Or site-wide with =[html] toc = false=. Rendering it is the template's business; see
+[[file:04-templates.org][Templates]].
+
+* Section numbers
+
+Off by default, unlike Emacs. Turn them on for one document:
+
+#+BEGIN_SRC org
+,#+OPTIONS: num:t
+#+END_SRC
+
+Or site-wide with =[html] section_numbers = true=.
+
+* Your own metadata
+
+Every =#+KEYWORD:= reaches templates under its lowercased name:
+
+#+BEGIN_SRC org
+,#+SUBTITLE: A closer look
+,#+REVIEWED_BY: someone
+#+END_SRC
+
+#+BEGIN_SRC html
+{% if page.keywords.subtitle %}<p class="subtitle">{{ page.keywords.subtitle }}</p>{% endif %}
+#+END_SRC
+
+Nothing needs to be registered, and org-ssg needs no release to support a keyword you
+invented.
+
+* Assets
+
+Any non-=.org= file in the source directory is copied to the output, preserving layout:
+=content/img/diagram.png= → =_site/img/diagram.png=. Reference it from a page with an
+ordinary relative link, and from a template with ={{ root }}img/diagram.png=.
+
+Four things are *never* published:
+
+- Dot-entries such as =.git= and =.env=. A source directory is often a repository, and
+  publishing its history next to the homepage is a real way to leak a project.
+- =org-ssg.toml=, which is a build input.
+- The templates directory, likewise.
+- The output directory, when it lives inside the source — so =org-ssg build . -o _site=
+  does the obvious thing rather than copying its own output back into itself.
+
+Note that excluding dot-entries also means =.well-known/= cannot be published.
diff --git a/docs/guide/07-incremental.org b/docs/guide/07-incremental.org
new file mode 100644
index 0000000..3b66b3f
--- /dev/null
+++ b/docs/guide/07-incremental.org
@@ -0,0 +1,115 @@
+#+TITLE: Incremental builds
+#+DESCRIPTION: How the cache decides what to re-render, and why that shape is the architecture.
+#+LEDE: Editing one post rebuilds four pages, whatever the size of the site.
+
+Incremental rebuilding is not an optimisation bolted on to org-ssg; it is the constraint
+the data model was built around. Parsing is a pure function of one file's bytes, link
+resolution reports the edges it used, and rendering is a pure function of a resolved
+document. Those properties are what make caching sound — and they are also what make the
+build parallel.
+
+You do not have to configure any of this. It is described here because knowing what
+invalidates what explains the behaviour you will see.
+
+* What you observe
+
+#+BEGIN_EXAMPLE
+$ org-ssg build content -o _site
+built 182 page(s) (182 rendered, 0 cached) ...
+
+$ org-ssg build content -o _site
+built 182 page(s) (0 rendered, 182 cached) ...
+
+$ vim content/blog/post.org && org-ssg build content -o _site
+built 182 page(s) (4 rendered, 178 cached) ...
+#+END_EXAMPLE
+
+The four are the post itself, its section index, its tag page, and the tag index whose
+counts changed.
+
+* The render key
+
+Every page has a key composed from four hashes:
+
+| Component | Changes when |
+|-----------+--------------|
+| content | The source file's bytes change. |
+| resolved links | A link's target moves, is renamed, or disappears. |
+| config | =org-ssg.toml= changes, or the shared chrome does. |
+| templates | Any template's source changes. |
+
+If a page's key matches the cached one and its output file still exists, the file on disk
+is already correct and is left untouched.
+
+The cache lives in =<output>/.org-ssg-cache.json= and is tagged with a format version. A
+version mismatch, a missing file or a corrupt file all fall back to a full rebuild — the
+cache is an optimisation, never a correctness dependency. There is a test for each of
+those three fallbacks.
+
+* Link dependencies
+
+Resolution records which targets each page consumed, which gives the build a dependency
+graph. That is what makes renaming a heading work:
+
+#+BEGIN_EXAMPLE
+a.org:  * Target Heading
+b.org:  Jump to [[*Target Heading][there]].
+#+END_EXAMPLE
+
+Rename the heading in =a.org= and *both* pages re-render — =b.org= because the URL it
+emits has changed. Without the graph, =b.html= would keep a link to an anchor that no
+longer exists. On a rebuild the graph is merged with the previous build's, so a target
+that was *removed* still pulls in the pages that linked to it.
+
+* Global chrome
+
+The navigation appears on every page, so a change to it must re-render every page. The
+site-structure hash covers exactly the pages that can appear in the nav — which is why
+the default =nav.mode = "top-level"= matters for more than aesthetics:
+
+- Retitling a *top-level* page changes the nav everywhere, and re-renders the site.
+- Adding a *nested* page cannot change anyone's nav, and re-renders one page.
+
+Turning on =[templates] expose_page_list= widens that hash to every page, because then
+any template can read any page's metadata. That is the documented cost of building an
+index by hand instead of with a collection.
+
+* Generated pages
+
+A listing page has no source file, so it is cached on the thing it actually depends on:
+the entries it lists — their URLs, titles, dates and tags.
+
+- Adding a post re-renders the indexes that list it.
+- Editing a post's *body* changes no listing metadata, so no index is touched.
+- Retitling a post re-renders the listings that display the title.
+
+A tag page depends on its own posts and not on the other groups. That is why the group
+list is given to the tag *index* and not to every tag page: a page that could see every
+group would depend on every group, and one new post would re-render every tag page.
+
+* Byte equivalence
+
+A full build (=--no-cache=) and an incremental rebuild produce *byte-identical* output.
+This is the property everything else rests on, and it is a test rather than an intention:
+the suite builds a site both ways and compares every emitted file.
+
+* Parallelism
+
+Parsing, resolution and rendering run across cores. Measured on a 1,790-page corpus, a
+full build went from 3.98s to 0.82s on 12 cores; the 179-page reference corpus builds in
+0.07s.
+
+Parallelism is not observable in the result. Emitted bytes are unaffected, and the build
+*report* — the order of =rendered= and =skipped= — is assembled sequentially afterwards,
+so a build is reproducible run to run. There is a test for that ordering, because a
+non-deterministic report over a deterministic site would be a confusing thing to debug.
+
+* When to reach for --no-cache
+
+Almost never. Config changes, template edits and cache-format upgrades all invalidate
+correctly on their own. It exists to answer "is the cache lying to me?" — and if it ever
+is, that is a bug worth reporting, with the two builds' output to compare.
+
+#+BEGIN_SRC sh
+org-ssg build content -o _site --no-cache
+#+END_SRC
diff --git a/docs/guide/08-workflow.org b/docs/guide/08-workflow.org
new file mode 100644
index 0000000..7c9e717
--- /dev/null
+++ b/docs/guide/08-workflow.org
@@ -0,0 +1,115 @@
+#+TITLE: Watching and serving
+#+DESCRIPTION: The write-save-see loop, and what the development server does and does not do.
+#+LEDE: Filesystem events, debounced rebuilds, and a browser that reloads itself.
+
+* serve
+
+#+BEGIN_SRC sh
+org-ssg serve content -o _site
+#+END_SRC
+
+Builds, watches, serves at [[http://127.0.0.1:3000][127.0.0.1:3000]], and reloads the
+browser when a rebuild lands. This is the command to leave running while you write.
+
+Add =--drafts= to see work in progress, =--port= to move it, and =--host 0.0.0.0= to
+reach it from another device.
+
+** It binds loopback deliberately
+
+A development server serves unreviewed drafts off your laptop. Exposing that to whatever
+network you are on — a café, a conference, an office — should be something you ask for,
+so the default is =127.0.0.1= and =--host= is the way out.
+
+** The reload script never reaches disk
+
+The script is injected into HTML *responses*, not into the built files. What you deploy
+is the site as built, with no development machinery in it. If you are curious, compare a
+served page with the file in your output directory.
+
+** How reload works
+
+The page carries the build generation it was rendered from, and asks the server "anything
+newer than N?". The server holds that request open until there is, then answers — so a
+reload is immediate rather than polled, but the mechanism is ordinary HTTP with no
+WebSocket.
+
+Baking the generation into the page closes a race: if a rebuild lands between a page
+being served and its first request going out, the server answers at once instead of the
+tab sitting on stale content until your *next* edit.
+
+A reload only follows a *successful* rebuild. Reloading onto an unchanged page because
+the build just failed tells you nothing — the error is already on your terminal.
+
+* watch
+
+#+BEGIN_SRC sh
+org-ssg watch content -o _site
+#+END_SRC
+
+The same rebuilding without the server, for when something else already serves the output.
+
+* What counts as a change
+
+Rebuilds are driven by OS filesystem events, so nothing happens while nothing happens.
+The rule for what triggers one is deliberately *not* the rule the build uses to find
+content — the question is "would this change the site?", not "is this a page?".
+
+*Triggers a rebuild:* any =.org= file, any asset, =org-ssg.toml=, and anything in the
+templates directory. The last two are skipped by the build when looking for content, but
+both change the output.
+
+*Does not:*
+
+- The output directory. Without this the build's own writes would raise events that
+  trigger a rebuild, forever.
+- Dot-directories. =.git= churns on every command, and rebuilding a site because git
+  wrote an index lock would make watching useless in a repository.
+- Editor scratch files: =file.org~=, =#file.org#=, =.#file.org=, =*.swp=, =*.tmp=. Emacs'
+  backup files matter here — they do not start with a dot, so they would otherwise look
+  exactly like content.
+
+* Debouncing
+
+Saving a file is rarely one event: an editor writes a temp file, renames it over the
+original, and touches the directory. Events are collected for 120ms of quiet before a
+rebuild starts, so one save is one rebuild.
+
+* Rebuild failures do not stop the session
+
+A build that fails prints the error and keeps watching. The usual cause is a half-saved
+file, and the next keystroke fixes it. Nothing needs restarting.
+
+#+BEGIN_EXAMPLE
+blog/post.org changed: build failed: parsing blog/post.org: ...
+blog/post.org changed: 2 rendered, 180 cached
+#+END_EXAMPLE
+
+* Where native watching is unavailable
+
+Some network and container filesystems have no event API. org-ssg falls back to polling
+every two seconds and says so, rather than failing:
+
+#+BEGIN_EXAMPLE
+note: native file watching unavailable (...); polling every 2s
+#+END_EXAMPLE
+
+* Serving details
+
+- =/= and any directory URL serve =index.html=.
+- Content types are set by extension; unknown extensions are served as binary.
+- Everything is sent =Cache-Control: no-store=, because a cached dev response makes an
+  edit look like it did not land.
+- URL resolution refuses to leave the output directory. =..=, percent-encoded =..=,
+  backslashes, absolute paths and embedded NULs all resolve to nothing.
+
+* A typical session
+
+#+BEGIN_SRC sh
+# One terminal, left running.
+org-ssg serve content -o _site --drafts
+
+# Write. The browser keeps up.
+
+# Before publishing, check what a real build says.
+org-ssg build content -o _site --strict
+#+END_SRC
diff --git a/docs/guide/09-auditing.org b/docs/guide/09-auditing.org
new file mode 100644
index 0000000..c8c7e05
--- /dev/null
+++ b/docs/guide/09-auditing.org
@@ -0,0 +1,94 @@
+#+TITLE: Auditing a corpus
+#+DESCRIPTION: Find out what a tool will make of your writing before you trust it with it.
+#+LEDE: Construct frequencies, unknown-name census, and no document text in the output.
+
+#+BEGIN_SRC sh
+org-ssg audit ~/notes
+#+END_SRC
+
+The audit answers two questions about a body of org files:
+
+1. *Coverage.* Of the constructs this corpus uses, which are supported? A construct that
+   is common here and unsupported is a problem with the tool's scope, not with your
+   writing.
+2. *Blind spots.* Which names appear that org-ssg has no opinion about at all? These are
+   the dangerous ones — not "known unsupported" but unknown.
+
+* Reading the output
+
+#+BEGIN_EXAMPLE
+corpus: 179 file(s), 29258 line(s)
+
+CONSTRUCTS (by frequency)
+     construct                            uses   files  first seen
+IN   list item                            1282     109  blog/2018-11-28-aes-encryption.org:53
+IN   heading                              1128     174  blog/2018-11-28-aes-encryption.org:7
+IN   source block                          932     121  blog/2018-11-28-cpp-compiler.org:17
+...
+OUT  table formula (#+TBLFM:)                4       1  blog/2024-08-11-org-mode-features.org:191
+OUT  entity (\name)                          3       3  blog/2024-04-06-convert-onenote.org:37
+
+coverage: 8854 in-scope use(s) (99.9%), 8 out-of-scope (0.1%)
+
+KEYWORDS
+   TITLE                                 179     179  blog/2018-11-28-aes-encryption.org:2
+??? SLUG                                  178     178  blog/2018-11-28-aes-encryption.org:4
+   DESCRIPTION                           176     176  blog/2018-11-28-aes-encryption.org:3
+...
+#+END_EXAMPLE
+
+- =IN= is supported; =OUT= is excluded by design and degrades as described in
+  [[file:05-org-support.org][Org support]].
+- The *coverage* line is the number to look at first.
+- =???= marks a name org-ssg does not recognise at all — in this example =#+SLUG:=, from
+  a run made before it was supported.
+
+Four censuses follow the construct table: every distinct =#+KEYWORD:=, block type,
+drawer name and link scheme in the corpus. A =???= in any of them is worth a look.
+
+* It never prints your writing
+
+Names, counts and =file:line= locations only. That is a deliberate constraint so that an
+audit of private notes — work notes, a journal — is safe to paste into an issue or share
+with someone helping you.
+
+* Why it is a separate scanner
+
+The audit deliberately does *not* reuse the parser. Auditing with the parser could only
+ever find constructs the parser already knows about, which is exactly the wrong
+instrument for the second question: it would report a blind spot as clean.
+
+* Comparing against Emacs
+
+The second half of the same idea is a differential test suite. =cargo test --test oracle=
+exports each fixture with org's own HTML exporter through =emacs --batch=, reduces both
+outputs to a semantic skeleton, and *snapshots the disagreement*.
+
+#+BEGIN_SRC sh
+cargo test --test oracle
+#+END_SRC
+
+Snapshotting rather than asserting agreement is deliberate: a checked-in divergence
+report gets reviewed and shows up in code review, where a permanently red test gets
+ignored. Three invariants /are/ asserted outright — heading structure, list nesting and
+source-block text — and all three hold.
+
+The suite skips cleanly with no Emacs installed, so a machine without it still gets a
+green run; it simply measures one thing less.
+
+* Using the audit before a migration
+
+#+BEGIN_SRC sh
+# What is in there?
+org-ssg audit ~/notes
+
+# Build it and see what the builder itself complains about.
+org-ssg build ~/notes -o /tmp/preview --strict
+
+# Look at the result.
+org-ssg serve ~/notes -o /tmp/preview
+#+END_SRC
+
+=--strict= surfaces broken internal links and malformed constructs as failures rather
+than warnings, which is the fastest way to find the handful of files that need attention
+before you commit to anything.
diff --git a/docs/guide/10-deploying.org b/docs/guide/10-deploying.org
new file mode 100644
index 0000000..1e01bdc
--- /dev/null
+++ b/docs/guide/10-deploying.org
@@ -0,0 +1,114 @@
+#+TITLE: Deploying
+#+DESCRIPTION: Producing a production build, and putting it somewhere.
+#+LEDE: The output is a directory of files. Everything after that is your host's problem.
+
+* The production build
+
+#+BEGIN_SRC sh
+org-ssg build content -o _site --strict
+#+END_SRC
+
+Two differences from the build you run while writing:
+
+- =--strict= turns broken internal links and parse diagnostics into a non-zero exit, so a
+  bad build fails rather than shipping.
+- No =--drafts=, so pages marked =#+DRAFT:= stay out.
+
+Everything in =_site= is the site: HTML, the generated =syntax.css=, and every asset
+copied from the source. There is no runtime, no server requirement and no build step
+downstream.
+
+* Set base_url for production
+
+#+BEGIN_SRC toml
+[site]
+base_url = "https://example.com"
+#+END_SRC
+
+Relative URLs work anywhere, which is why the default is empty — but two things need
+absolute ones: *feeds*, because a feed is read away from the site that served it, and
+*canonical links*. Without a base URL the =absolute= filter is an error rather than a
+quietly relative link, so a feed template will tell you.
+
+No trailing slash.
+
+* One thing to exclude
+
+The build writes =.org-ssg-cache.json= into the output directory. It is a dot-file, so
+most static hosts ignore it, but it is not part of the site — exclude it if your host
+uploads everything:
+
+#+BEGIN_SRC sh
+rsync -a --delete --exclude '.org-ssg-cache.json' _site/ user@host:/var/www/site/
+#+END_SRC
+
+Keeping the cache *between* deploys, where the CI runner can see it, is what makes CI
+builds incremental. Keeping it on the *server* achieves nothing.
+
+* Continuous integration
+
+#+BEGIN_SRC yaml
+name: build
+on: [push]
+jobs:
+  build:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+      - uses: dtolnay/rust-toolchain@stable
+      - run: cargo install --path .
+      - run: org-ssg build content -o _site --strict
+      - uses: actions/upload-artifact@v4
+        with:
+          name: site
+          path: _site
+#+END_SRC
+
+=--strict= is the point of running this in CI at all: it turns a broken link into a
+failed build.
+
+** Caching between runs
+
+Cache =_site/.org-ssg-cache.json= *and* =_site= together, or not at all. The manifest
+describes files it expects to find; a cache without its outputs simply triggers a full
+rebuild, which is correct but pointless.
+
+Given how fast a full build is — a 179-page site in well under a second — caching CI
+builds is rarely worth the configuration.
+
+* Static hosts
+
+Nothing here is org-ssg-specific; a built site is ordinary static files.
+
+- *Netlify, Vercel, Cloudflare Pages*: publish directory =_site=, build command
+  =cargo install --path . && org-ssg build content -o _site --strict=.
+- *GitHub Pages*: upload =_site= as the Pages artifact.
+- *Any web server*: copy =_site= to the document root.
+
+** URLs end in .html
+
+org-ssg writes =blog/post.html= and links to it that way, so the site works with no
+server configuration at all — including opening it from a filesystem path.
+
+If you prefer extensionless URLs, that is a server-side rewrite, and you should also set
+=base_url= and check that your rewrite rules do not break the relative links in the pages.
+
+* Checking a build before shipping
+
+#+BEGIN_SRC sh
+org-ssg build content -o _site --strict
+org-ssg serve content -o _site
+#+END_SRC
+
+Serving the production build locally is the last check worth doing: it catches a missing
+asset or a broken relative link in the browser, where you would notice.
+
+* What a clean build looks like
+
+#+BEGIN_EXAMPLE
+built 182 page(s) (182 rendered, 0 cached), copied 3 asset(s) from content -> _site (0 unresolved link(s), 0 diagnostic(s))
+#+END_EXAMPLE
+
+Both zeros matter. Unresolved links are internal links pointing at nothing; diagnostics
+are malformed org that degraded rather than failing. With =--strict= neither can reach
+this line, because either would have failed the build.
diff --git a/docs/index.org b/docs/index.org
new file mode 100644
index 0000000..4807762
--- /dev/null
+++ b/docs/index.org
@@ -0,0 +1,80 @@
+#+TITLE: org-ssg
+#+DESCRIPTION: An org-mode static site generator in Rust, where the org element tree is the document model.
+#+LEDE: Org is the source language, not an inconvenient input to be normalised into markdown.
+#+OPTIONS: toc:nil
+
+org-ssg turns a directory of =.org= files into a static website. It treats org as the
+*source language*: the org element tree — headings, drawers, blocks, links with their
+org-specific semantics — /is/ the document model, and that tree is rendered straight to
+HTML. There is no markdown-shaped intermediate representation, because the point is to
+preserve what markdown cannot express.
+
+#+BEGIN_SRC sh
+cargo run -- init my-site
+cargo run -- serve my-site -o _site
+#+END_SRC
+
+Open [[http://127.0.0.1:3000][127.0.0.1:3000]], edit any =.org= file, and the browser reloads itself.
+
+* Start here
+
+- [[file:install.org][Install]] — get the binary built and on your PATH.
+- [[file:quickstart.org][Quick start]] — a working site in two commands, then your own content.
+- [[file:guide/01-cli.org][The guide]] — every command, setting, template variable and org construct.
+
+* What you get with no configuration at all
+
+Point it at a directory of org files and you get a complete site: pages, navigation,
+syntax-highlighted code, and the stylesheet that colours it. Nothing about your files has
+to change, and no =org-ssg.toml= is required.
+
+#+BEGIN_SRC sh
+org-ssg build ~/notes -o _site
+#+END_SRC
+
+Configuration changes what you get. It is never what makes it work.
+
+* What it does that is unusual
+
+** Incremental builds are the architecture
+
+Every page has a render key composed from its content, its resolved links, the site
+config and the templates. Editing one post re-renders that post, its section index, its
+tag pages, and the tag index whose counts changed — four pages, whatever the size of the
+site. A full build and an incremental build produce byte-identical output, and a test
+proves it.
+
+** It is measured against Emacs
+
+=cargo test --test oracle= exports each test fixture with org's own HTML exporter through
+=emacs --batch= and records the disagreement. Heading structure, list nesting and
+source-block text match exactly. Everything that still differs is a deliberate choice,
+listed in [[file:guide/05-org-support.org][Org support]].
+
+** It tells you what your corpus actually uses
+
+#+BEGIN_SRC sh
+org-ssg audit ~/notes
+#+END_SRC
+
+The audit reports which org constructs appear in a corpus, how often, and whether each is
+supported — so you can find out before you trust a tool with your writing. It reports
+names, counts and =file:line= locations only, never document text, so auditing private
+notes stays safe to paste into an issue.
+
+* Feature summary
+
+| Area | What is there |
+|------+---------------|
+| Org syntax | headings with TODO/priority/tags, lists (nested, description, checkboxes), tables, source blocks, quote/center/example/export blocks, footnotes, timestamps, links, images with captions |
+| Output | syntax highlighting via syntect, table of contents, section numbers, heading anchors |
+| Structure | =#+SLUG:= URLs, drafts, generated listing pages, tag pages and tag indexes, pagination, RSS feeds |
+| Templates | minijinja layouts with inheritance, rich page metadata, custom filters |
+| Workflow | incremental rebuilds, =watch= on filesystem events, =serve= with live reload |
+| Confidence | 152 tests, an =emacs --batch= differential oracle, a corpus audit tool |
+
+* Status
+
+This documentation site is itself an org-ssg site — the sources are in =docs/= and it is
+built with the command in [[file:quickstart.org][Quick start]]. If a feature is described here, it is being used
+to render the page describing it.
diff --git a/docs/install.org b/docs/install.org
new file mode 100644
index 0000000..fc7f0ce
--- /dev/null
+++ b/docs/install.org
@@ -0,0 +1,96 @@
+#+TITLE: Install
+#+DESCRIPTION: Build org-ssg from source, put it on your PATH, and check that it works.
+#+LEDE: One Rust toolchain, one command, no runtime dependencies.
+
+* Requirements
+
+- *Rust 1.82 or newer.* Install from [[https://rustup.rs][rustup.rs]] if you do not have it. There is no other
+  runtime requirement: the binary is self-contained, with syntax definitions and
+  highlighting themes compiled in.
+- *Emacs (optional).* Only the differential test suite uses it, to compare output against
+  org's own exporter. Nothing about building a site needs Emacs.
+
+* From source
+
+#+BEGIN_SRC sh
+git clone <repository-url> org-ssg
+cd org-ssg
+cargo build --release
+#+END_SRC
+
+The binary lands at =target/release/org-ssg=. Copy it somewhere on your =PATH=:
+
+#+BEGIN_SRC sh
+cp target/release/org-ssg ~/.local/bin/
+#+END_SRC
+
+Or let cargo do it, which puts it in =~/.cargo/bin=:
+
+#+BEGIN_SRC sh
+cargo install --path .
+#+END_SRC
+
+* Running without installing
+
+Every command in this documentation works through cargo if you would rather not install
+anything. Replace =org-ssg= with =cargo run --= and add =--release= for a fast build:
+
+#+BEGIN_SRC sh
+cargo run --release -- build my-site -o _site
+#+END_SRC
+
+The debug build is fine for small sites and noticeably slower on large ones, because
+syntax highlighting dominates and is not optimised in a debug profile.
+
+* Check that it works
+
+#+BEGIN_SRC sh
+org-ssg --version
+org-ssg init /tmp/org-ssg-check
+org-ssg build /tmp/org-ssg-check -o /tmp/org-ssg-check/_site
+#+END_SRC
+
+You should see a line reporting the pages built:
+
+#+BEGIN_EXAMPLE
+built 5 page(s) (5 rendered, 0 cached), copied 0 asset(s) ... (0 unresolved link(s), 0 diagnostic(s))
+#+END_EXAMPLE
+
+Open =/tmp/org-ssg-check/_site/index.html= in a browser, or serve it properly:
+
+#+BEGIN_SRC sh
+org-ssg serve /tmp/org-ssg-check -o /tmp/org-ssg-check/_site
+#+END_SRC
+
+* Running the test suite
+
+#+BEGIN_SRC sh
+cargo test
+#+END_SRC
+
+152 tests, covering the parser, the renderer, configuration, generated pages, the
+incremental cache, the watcher and the development server.
+
+The oracle suite is part of that run and compares output against Emacs:
+
+#+BEGIN_SRC sh
+cargo test --test oracle
+#+END_SRC
+
+It *skips cleanly* when there is no =emacs= on your =PATH=, so a machine without Emacs
+still gets a green test run — it simply measures one thing less.
+
+* Upgrading
+
+org-ssg stores an incremental cache in =<output>/.org-ssg-cache.json=, tagged with a
+format version. A newer binary that changes how output is produced bumps that version,
+and a version it does not recognise is discarded in favour of a full rebuild. You never
+need to clear the cache by hand after an upgrade — but if you want to:
+
+#+BEGIN_SRC sh
+org-ssg clean _site
+#+END_SRC
+
+* Next
+
+[[file:quickstart.org][Quick start]] builds a real site and puts your own writing into it.
diff --git a/docs/org-ssg.toml b/docs/org-ssg.toml
new file mode 100644
index 0000000..b3db419
--- /dev/null
+++ b/docs/org-ssg.toml
@@ -0,0 +1,44 @@
+# Configuration for the org-ssg documentation site.
+#
+# This site is built by org-ssg itself, so this file doubles as a worked example: every
+# setting here is one the docs describe, used the way the docs recommend.
+
+[site]
+title = "org-ssg"
+description = "An org-mode static site generator, in Rust."
+language = "en"
+# Left empty so the docs build with relative URLs and open from the filesystem. Set it to
+# your real origin to enable canonical links and feeds.
+base_url = ""
+
+[nav]
+# Explicit, because the header already links home: listing index.org here as well would
+# repeat the site title in the nav directly beside itself. The guide reaches the nav as a
+# collection below, since it lives in a subdirectory rather than at the top level.
+mode = "explicit"
+pages = ["install.org", "quickstart.org"]
+
+[templates]
+dir = "templates"
+
+[highlight]
+# A dark theme, with code blocks styled dark in both colour schemes. syntax.css is
+# generated from a single syntect theme and cannot respond to prefers-color-scheme, so
+# the page CSS matches the theme rather than leaving code unreadable in one of them.
+theme = "base16-ocean.dark"
+
+[html]
+# The layout renders the page title as <h1>, so document headings start at <h2>.
+heading_offset = 1
+toc = true
+section_numbers = false
+
+# The guide's contents page, listing every chapter in reading order rather than by date.
+[[collections]]
+source = "guide"
+output = "guide/index.html"
+template = "list.html"
+title = "Guide"
+sort = "path"
+order = "asc"
+nav = true
diff --git a/docs/quickstart.org b/docs/quickstart.org
new file mode 100644
index 0000000..5830fe0
--- /dev/null
+++ b/docs/quickstart.org
@@ -0,0 +1,165 @@
+#+TITLE: Quick start
+#+DESCRIPTION: A working site in two commands, then your own writing, then your own design.
+#+LEDE: Five minutes from nothing to a site that reloads as you type.
+
+* Two commands
+
+#+BEGIN_SRC sh
+org-ssg init my-site
+org-ssg serve my-site -o _site
+#+END_SRC
+
+Open [[http://127.0.0.1:3000][127.0.0.1:3000]]. Edit =my-site/index.org= in your editor, save, and the page reloads
+on its own.
+
+=init= writes only files that do not already exist, so running it inside a directory that
+already has content is safe and additive.
+
+** What init created
+
+#+BEGIN_EXAMPLE
+my-site/
+  org-ssg.toml            every setting, at its default, commented
+  index.org               the home page
+  blog/first-post.org     a post, to show the collection working
+  templates/
+    base.html             the page layout — edit this
+    list.html             the blog index
+    tags.html             the tag index
+    feed.xml              an RSS feed
+#+END_EXAMPLE
+
+* Or skip all of that
+
+You do not need =init=, a config file, or templates. Point the builder at org files you
+already have:
+
+#+BEGIN_SRC sh
+org-ssg build ~/notes -o _site
+#+END_SRC
+
+You get a complete site with a built-in layout, navigation across your top-level pages,
+and syntax highlighting. Nothing in your files has to change.
+
+Before trusting it with a large collection, ask what it makes of your writing:
+
+#+BEGIN_SRC sh
+org-ssg audit ~/notes
+#+END_SRC
+
+That reports which org constructs appear, how often, and whether each is supported. See
+[[file:guide/09-auditing.org][Auditing a corpus]].
+
+* Write a page
+
+Any =.org= file under the source directory becomes a page at the matching path.
+=notes/rust/borrowing.org= becomes =notes/rust/borrowing.html=.
+
+#+BEGIN_SRC org
+,#+TITLE: Borrowing
+,#+DATE: <2026-02-02 Mon>
+,#+FILETAGS: :rust:notes:
+,#+DESCRIPTION: How the borrow checker thinks about lifetimes.
+
+An opening paragraph, which becomes the excerpt in listings when there is no
+description.
+
+,* A heading
+
+Ordinary org: *bold*, /italic/, ~code~, [[https://orgmode.org][links]], and lists.
+
+,#+BEGIN_SRC rust
+fn main() {}
+,#+END_SRC
+#+END_SRC
+
+The keywords are all optional. =#+TITLE:= names the page, =#+DATE:= orders it in
+listings, =#+FILETAGS:= groups it on tag pages, and =#+DESCRIPTION:= is its summary.
+
+** Control the URL
+
+By default the filename decides the URL. =#+SLUG:= overrides it, which is how a
+date-prefixed filename becomes a clean address:
+
+#+BEGIN_SRC org
+,#+TITLE: Borrowing
+,#+SLUG: borrowing-explained
+#+END_SRC
+
+=2026-02-02-borrowing.org= now publishes as =borrowing-explained.html=.
+
+** Keep something unfinished out of the build
+
+#+BEGIN_SRC org
+,#+DRAFT: t
+#+END_SRC
+
+The page is not written, and does not appear in listings or navigation. Preview it while
+you work with =--drafts=:
+
+#+BEGIN_SRC sh
+org-ssg serve my-site -o _site --drafts
+#+END_SRC
+
+* Change the design
+
+Everything visual lives in =templates/base.html=. It is an ordinary
+[[https://docs.rs/minijinja][minijinja]] (Jinja2) template, and replacing it replaces the
+whole layout:
+
+#+BEGIN_SRC html
+<!DOCTYPE html>
+<html lang="{{ site.language }}">
+<head>
+  <meta charset="utf-8">
+  <title>{{ page.title }} — {{ site.title }}</title>
+  <link rel="stylesheet" href="{{ root }}style.css">
+</head>
+<body>
+  <nav>{% for item in nav %}<a href="{{ item.url }}">{{ item.title }}</a>{% endfor %}</nav>
+  <h1>{{ page.title }}</h1>
+  {{ body | safe }}
+</body>
+</html>
+#+END_SRC
+
+=root= is the =../= prefix back to the site root, so the same template works at any
+depth. Any other file in the source directory — =style.css=, images, fonts — is copied to
+the output untouched.
+
+Editing a template rebuilds every page that uses it, so the browser reloads while you are
+still looking at it. The full list of variables is in [[file:guide/04-templates.org][Templates]].
+
+* Add a blog index
+
+Listing pages have no source file; they are declared in =org-ssg.toml=:
+
+#+BEGIN_SRC toml
+[[collections]]
+source = "blog"
+output = "blog/index.html"
+template = "list.html"
+title = "Blog"
+sort = "date"
+order = "desc"
+nav = true
+#+END_SRC
+
+That is also how you get tag pages, pagination and an RSS feed — same mechanism, more
+settings. See [[file:guide/03-collections.org][Collections]].
+
+* Build for real
+
+#+BEGIN_SRC sh
+org-ssg build my-site -o _site --strict
+#+END_SRC
+
+=--strict= turns broken internal links and parse diagnostics into a non-zero exit, which
+is what you want in CI. Deployment is just copying =_site= somewhere; see
+[[file:guide/10-deploying.org][Deploying]].
+
+* Next
+
+- [[file:guide/01-cli.org][Command reference]] — every command and flag.
+- [[file:guide/02-configuration.org][Configuration]] — every setting in =org-ssg.toml=.
+- [[file:guide/05-org-support.org][Org support]] — exactly which org syntax is handled.
diff --git a/docs/style.css b/docs/style.css
new file mode 100644
index 0000000..27b9f13
--- /dev/null
+++ b/docs/style.css
@@ -0,0 +1,130 @@
+/* Documentation site styling.
+ *
+ * A plain asset, copied through the build untouched — which is also how any other CSS,
+ * image or font in a source directory reaches the output. */
+
+:root {
+  --ink: #1c1f24;
+  --muted: #5b6472;
+  --rule: #dfe3e8;
+  --accent: #0b5fa5;
+  --surface: #f6f8fa;
+  --measure: 42rem;
+}
+
+@media (prefers-color-scheme: dark) {
+  :root {
+    --ink: #dee3ea;
+    --muted: #9aa4b2;
+    --rule: #2b3138;
+    --accent: #79b8ff;
+    --surface: #171a1f;
+  }
+  body { background: #0f1216; }
+}
+
+* { box-sizing: border-box; }
+
+body {
+  margin: 0;
+  color: var(--ink);
+  font: 16px/1.65 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
+}
+
+header.site {
+  border-bottom: 1px solid var(--rule);
+  padding: 1rem 1.5rem;
+  display: flex;
+  flex-wrap: wrap;
+  gap: 1rem 1.5rem;
+  align-items: baseline;
+}
+
+header.site .site-title {
+  font-weight: 700;
+  font-size: 1.05rem;
+  color: var(--ink);
+  text-decoration: none;
+}
+
+header.site nav { display: flex; gap: 1.25rem; flex-wrap: wrap; }
+header.site nav a { color: var(--muted); text-decoration: none; }
+header.site nav a:hover { color: var(--accent); }
+
+main {
+  max-width: var(--measure);
+  margin: 0 auto;
+  padding: 2.5rem 1.5rem 5rem;
+}
+
+h1 { font-size: 2rem; line-height: 1.2; margin: 0 0 .5rem; letter-spacing: -0.02em; }
+h2 { font-size: 1.35rem; margin: 2.5rem 0 .75rem; letter-spacing: -0.01em; }
+h3 { font-size: 1.1rem; margin: 2rem 0 .5rem; }
+
+p.page-date, p.lede { color: var(--muted); }
+p.lede { font-size: 1.1rem; margin-top: 0; }
+
+a { color: var(--accent); }
+
+code {
+  font: 0.875em/1.5 ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
+  background: var(--surface);
+  padding: .1em .35em;
+  border-radius: 3px;
+}
+
+/* Code blocks are dark in both colour schemes, matching the syntect theme named in
+   org-ssg.toml. One generated stylesheet cannot follow prefers-color-scheme, so the page
+   commits to the theme's palette instead of leaving highlighted code unreadable in one
+   of the two. */
+pre {
+  background: #2b303b;
+  color: #c0c5ce;
+  border: 1px solid #1f232b;
+  border-radius: 6px;
+  padding: .9rem 1rem;
+  overflow-x: auto;
+}
+
+pre code { background: none; padding: 0; }
+
+blockquote {
+  margin: 1.5rem 0;
+  padding: .25rem 0 .25rem 1rem;
+  border-left: 3px solid var(--rule);
+  color: var(--muted);
+}
+
+table { border-collapse: collapse; width: 100%; margin: 1.25rem 0; display: block; overflow-x: auto; }
+th, td { text-align: left; padding: .5rem .75rem; border-bottom: 1px solid var(--rule); }
+th { font-size: .85rem; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); }
+
+hr { border: 0; border-top: 1px solid var(--rule); margin: 2.5rem 0; }
+
+/* Table of contents, emitted from page.toc */
+nav.toc {
+  background: var(--surface);
+  border: 1px solid var(--rule);
+  border-radius: 6px;
+  padding: .75rem 1.25rem 1rem;
+  margin: 1.5rem 0 2.5rem;
+}
+nav.toc h2 { font-size: .8rem; text-transform: uppercase; letter-spacing: .06em; margin: .25rem 0 .5rem; color: var(--muted); }
+nav.toc ul { margin: 0; padding-left: 1.1rem; }
+nav.toc li { margin: .15rem 0; }
+
+ul.post-list { list-style: none; padding: 0; }
+ul.post-list > li { padding: 1rem 0; border-bottom: 1px solid var(--rule); }
+ul.post-list a { font-weight: 600; font-size: 1.05rem; }
+p.excerpt { margin: .35rem 0 .2rem; color: var(--muted); }
+span.reading-time { font-size: .85rem; color: var(--muted); }
+
+footer.site {
+  border-top: 1px solid var(--rule);
+  padding: 1.5rem;
+  color: var(--muted);
+  font-size: .9rem;
+  text-align: center;
+}
+
+.tag { font-size: .75rem; background: var(--surface); border: 1px solid var(--rule); border-radius: 999px; padding: .1em .6em; color: var(--muted); }
diff --git a/docs/templates/base.html b/docs/templates/base.html
new file mode 100644
index 0000000..9faafc3
--- /dev/null
+++ b/docs/templates/base.html
@@ -0,0 +1,51 @@
+<!DOCTYPE html>
+<html lang="{{ site.language }}">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>{{ page.title }} &middot; {{ site.title }}</title>
+{%- if site.base_url %}
+<link rel="canonical" href="{{ page.url | absolute }}">
+{%- endif %}
+<meta name="description" content="{{ page.excerpt | truncate(150) }}">
+<link rel="stylesheet" href="{{ root }}style.css">
+{%- if stylesheet %}
+<link rel="stylesheet" href="{{ stylesheet }}">
+{%- endif %}
+</head>
+<body>
+<header class="site">
+<a class="site-title" href="{{ root }}index.html">{{ site.title }}</a>
+{%- if nav %}
+<nav>
+{%- for item in nav %}
+<a href="{{ item.url }}">{{ item.title }}</a>
+{%- endfor %}
+</nav>
+{%- endif %}
+</header>
+<main>
+<h1>{{ page.title }}</h1>
+{%- if page.keywords.lede %}
+<p class="lede">{{ page.keywords.lede }}</p>
+{%- endif %}
+{%- if page.toc | length > 1 %}
+{%- macro toc_list(entries) %}
+<ul>
+{%- for entry in entries %}
+<li><a href="#{{ entry.anchor }}">{{ entry.title }}</a>
+{%- if entry.children %}{{ toc_list(entry.children) }}{% endif %}</li>
+{%- endfor %}
+</ul>
+{%- endmacro %}
+<nav class="toc" aria-label="On this page">
+<h2>On this page</h2>
+{{- toc_list(page.toc) }}
+</nav>
+{%- endif %}
+{% block content %}{{ body | safe }}{% endblock %}</main>
+<footer class="site">
+Built with org-ssg &mdash; these docs are an org-ssg site.
+</footer>
+</body>
+</html>
diff --git a/docs/templates/list.html b/docs/templates/list.html
new file mode 100644
index 0000000..5f29927
--- /dev/null
+++ b/docs/templates/list.html
@@ -0,0 +1,14 @@
+{% extends "base.html" %}
+{% block content %}
+<ul class="post-list">
+{%- for entry in pages %}
+<li>
+<a href="{{ root }}{{ entry.url }}">{{ entry.title }}</a>
+{%- if entry.excerpt %}
+<p class="excerpt">{{ entry.excerpt | truncate(180) }}</p>
+{%- endif %}
+<span class="reading-time">{{ entry.reading_time }} min read</span>
+</li>
+{%- endfor %}
+</ul>
+{% endblock %}