krz/orgo

Lightning fast org-mode static site generator.

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

4db41537b58c193ae6fc4ffe4f154a282b23fc24

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-08-11T02:56:06Z

org-mode SSG v0.3: native Rust org parser, multi-file site build with link resolution, incremental content-hash rebuilds
 .gitignore                                         |   1 +
 Cargo.lock                                         | 991 +++++++++++++++++++++
 Cargo.toml                                         |  35 +
 README.md                                          | 164 ++++
 fixtures/core.org                                  |  21 +
 fixtures/elements.org                              |  31 +
 fixtures/footnote.org                              |   8 +
 fixtures/minimal.org                               |  20 +
 fixtures/site/about.org                            |   3 +
 fixtures/site/guide.org                            |  17 +
 fixtures/site/index.org                            |   9 +
 fixtures/site/style.css                            |   2 +
 fixtures/table.org                                 |   6 +
 src/incremental.rs                                 | 257 ++++++
 src/index.rs                                       | 133 +++
 src/lib.rs                                         |  19 +
 src/main.rs                                        | 180 ++++
 src/model.rs                                       | 224 +++++
 src/parser.rs                                      | 886 ++++++++++++++++++
 src/render.rs                                      | 340 +++++++
 src/resolve.rs                                     | 173 ++++
 src/site.rs                                        | 420 +++++++++
 src/template.rs                                    |  85 ++
 src/util.rs                                        | 110 +++
 tests/harness.rs                                   |  34 +
 tests/incremental.rs                               | 296 ++++++
 tests/pipeline.rs                                  |  48 +
 tests/site.rs                                      | 124 +++
 .../harness__element_tree_json_snapshot.snap       |  28 +
 tests/snapshots/pipeline__core_element_tree.snap   | 200 +++++
 tests/snapshots/pipeline__core_html.snap           |  19 +
 .../snapshots/pipeline__minimal_element_tree.snap  | 180 ++++
 tests/snapshots/pipeline__minimal_html.snap        |  14 +
 tests/snapshots/site__footnote_render.snap         |  16 +
 tests/snapshots/site__site_guide_html.snap         |  39 +
 tests/snapshots/site__site_index_html.snap         |  24 +
 tests/snapshots/site__table_render.snap            |  13 +
 37 files changed, 5170 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ea8c4bf
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+/target
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..4df8076
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,991 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
+name = "android_system_properties"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "anstream"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
+
+[[package]]
+name = "anstyle-parse"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
+dependencies = [
+ "windows-sys",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
+dependencies = [
+ "anstyle",
+ "once_cell_polyfill",
+ "windows-sys",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
+
+[[package]]
+name = "arrayref"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
+
+[[package]]
+name = "arrayvec"
+version = "0.7.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "bincode"
+version = "1.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "bitflags"
+version = "2.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
+
+[[package]]
+name = "blake3"
+version = "1.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76ae7bad254120e9e4c63bafc385310756f90c484eac0e36b8317cf09cb92a77"
+dependencies = [
+ "arrayref",
+ "arrayvec",
+ "cc",
+ "cfg-if",
+ "constant_time_eq",
+ "cpufeatures",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "camino"
+version = "1.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "cc"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "chrono"
+version = "0.4.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
+dependencies = [
+ "iana-time-zone",
+ "js-sys",
+ "num-traits",
+ "serde",
+ "wasm-bindgen",
+ "windows-link",
+]
+
+[[package]]
+name = "clap"
+version = "4.6.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.6.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "clap_lex"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
+
+[[package]]
+name = "colorchoice"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
+
+[[package]]
+name = "console"
+version = "0.16.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c"
+dependencies = [
+ "encode_unicode",
+ "libc",
+ "windows-sys",
+]
+
+[[package]]
+name = "constant_time_eq"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+
+[[package]]
+name = "cpufeatures"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crc32fast"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "deranged"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
+
+[[package]]
+name = "encode_unicode"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de"
+
+[[package]]
+name = "flate2"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
+dependencies = [
+ "crc32fast",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
+[[package]]
+name = "futures-core"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
+
+[[package]]
+name = "futures-task"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
+
+[[package]]
+name = "futures-util"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "iana-time-zone"
+version = "0.1.65"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
+dependencies = [
+ "android_system_properties",
+ "core-foundation-sys",
+ "iana-time-zone-haiku",
+ "js-sys",
+ "log",
+ "wasm-bindgen",
+ "windows-core",
+]
+
+[[package]]
+name = "iana-time-zone-haiku"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
+dependencies = [
+ "equivalent",
+ "hashbrown",
+]
+
+[[package]]
+name = "insta"
+version = "1.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82"
+dependencies = [
+ "console",
+ "once_cell",
+ "serde",
+ "similar",
+ "tempfile",
+]
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "js-sys"
+version = "0.3.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.189"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
+
+[[package]]
+name = "linked-hash-map"
+version = "0.5.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "log"
+version = "0.4.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "memo-map"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b"
+
+[[package]]
+name = "minijinja"
+version = "2.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42d74234349a775546a83af0f0c0c0e3a73227dee4950a542cda81a47240b3e6"
+dependencies = [
+ "memo-map",
+ "serde",
+]
+
+[[package]]
+name = "miniz_oxide"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
+[[package]]
+name = "num-conv"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "once_cell_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+
+[[package]]
+name = "onig"
+version = "6.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2"
+dependencies = [
+ "bitflags",
+ "libc",
+ "once_cell",
+ "onig_sys",
+]
+
+[[package]]
+name = "onig_sys"
+version = "69.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e68317604e77e53b85896388e1a803c1d21b74c899ec9e5e1112db90735edd7"
+dependencies = [
+ "cc",
+ "pkg-config",
+]
+
+[[package]]
+name = "org-ssg"
+version = "0.3.0"
+dependencies = [
+ "anyhow",
+ "blake3",
+ "camino",
+ "chrono",
+ "clap",
+ "insta",
+ "minijinja",
+ "serde",
+ "serde_json",
+ "syntect",
+ "thiserror",
+ "walkdir",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "pkg-config"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
+
+[[package]]
+name = "plist"
+version = "1.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85"
+dependencies = [
+ "base64",
+ "indexmap",
+ "quick-xml",
+ "serde",
+ "time",
+]
+
+[[package]]
+name = "powerfmt"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quick-xml"
+version = "0.41.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
+
+[[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
+
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.151"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "simd-adler32"
+version = "0.3.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
+
+[[package]]
+name = "similar"
+version = "2.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "syn"
+version = "2.0.119"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "3.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syntect"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925"
+dependencies = [
+ "bincode",
+ "flate2",
+ "fnv",
+ "once_cell",
+ "onig",
+ "plist",
+ "regex-syntax",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "thiserror",
+ "walkdir",
+ "yaml-rust",
+]
+
+[[package]]
+name = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom",
+ "once_cell",
+ "rustix",
+ "windows-sys",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "time"
+version = "0.3.55"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
+dependencies = [
+ "deranged",
+ "num-conv",
+ "powerfmt",
+ "serde_core",
+ "time-core",
+ "time-macros",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
+
+[[package]]
+name = "time-macros"
+version = "0.2.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
+dependencies = [
+ "num-conv",
+ "time-core",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "walkdir"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "winapi-util"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
+dependencies = [
+ "windows-sys",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
+dependencies = [
+ "windows-implement",
+ "windows-interface",
+ "windows-link",
+ "windows-result",
+ "windows-strings",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.59.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-result"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "yaml-rust"
+version = "0.4.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85"
+dependencies = [
+ "linked-hash-map",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..68da9be
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,35 @@
+[package]
+name = "org-ssg"
+version = "0.3.0"
+edition = "2021"
+description = "Org-mode static site generator that renders the org element tree straight to HTML"
+license = "MIT"
+
+[lib]
+name = "org_ssg"
+path = "src/lib.rs"
+
+[[bin]]
+name = "org-ssg"
+path = "src/main.rs"
+
+[dependencies]
+# Parsing / model
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+chrono = { version = "0.4", features = ["serde"] }
+camino = { version = "1", features = ["serde1"] }
+
+# Pipeline
+walkdir = "2"
+syntect = "5"
+minijinja = "2"
+blake3 = "1"
+
+# CLI / errors
+clap = { version = "4", features = ["derive"] }
+anyhow = "1"
+thiserror = "2"
+
+[dev-dependencies]
+insta = { version = "1", features = ["json"] }
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..002da3e
--- /dev/null
+++ b/README.md
@@ -0,0 +1,164 @@
+# org-ssg
+
+An org-mode static site generator, in Rust. Org is treated as the *source language*,
+not an inconvenient input to be normalized into markdown. The org element tree —
+headings, drawers, blocks, links with their org-specific semantics — **is** the
+document model, and we render that tree straight to HTML. We never round-trip through
+a markdown-shaped intermediate representation, because the point is to preserve what
+markdown cannot express: property drawers, TODO/priority/tag metadata on headings,
+`#+` directives, ID links, named/captioned blocks, footnote semantics.
+
+The one non-obvious early commitment is **incremental builds keyed on content
+hashing**, treated as a first-class architectural concern from day one. The discipline
+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.
+
+## Pipeline
+
+```
+DISCOVER → PARSE → INDEX → RESOLVE → RENDER → TEMPLATE → EMIT
+```
+
+PARSE and RENDER are pure functions of their inputs (cacheable, hashable). INDEX/RESOLVE
+is the only inherently global stage — it is where the link dependency graph is born.
+
+| Stage | Module | Notes |
+|---|---|---|
+| PARSE | `src/parser.rs` | Hand-written recursive descent: line lexer → element builder → inline tokenizer. |
+| model | `src/model.rs` | The org element tree — Elements (block) vs Objects (inline). |
+| INDEX | `src/index.rs` | Collect link targets into a symbol table. |
+| RESOLVE | `src/resolve.rs` | Rewrite links to URLs; return the used-target list (dependency edges). |
+| RENDER | `src/render.rs` | Tree → HTML fragment; syntect highlighting; footnote two-pass. |
+| TEMPLATE | `src/template.rs` | minijinja: fragment + metadata → full page. |
+| incremental | `src/incremental.rs` | Content/config/template hashing, dep graph, cache manifest, invalidation. |
+
+## v1 scope (recommended; must be reconciled against a corpus audit first)
+
+**IN — v1 must handle:** headings with nesting; TODO keywords; priorities `[#A]`; tags;
+property drawers; plain lists (unordered/ordered/description, checkboxes, nesting);
+tables (with rule rows, no `#+TBLFM:`); source blocks with syntax highlighting;
+example/quote/center blocks; links (external, internal `[[*Heading]]`/`[[#custom-id]]`,
+`id:`); footnotes (inline and referenced); `#+` keywords/directives; inline markup
+(bold/italic/underline/verbatim/code/strike); timestamps (active/inactive, ranges);
+paragraphs and horizontal rules; images with `#+CAPTION`/`#+ATTR_HTML`.
+
+**OUT — explicitly not v1 (parse-and-ignore or reject loudly):** Babel execution /
+`:results`; `#+TBLFM:` formulas; LaTeX / MathJax; `#+INCLUDE:`; radio targets and
+macros; drawers other than PROPERTIES/LOGBOOK; column view / clocking / agenda
+semantics; non-HTML export blocks; the full Unicode entity set.
+
+**Scope guardrail:** every IN item gets a golden-file fixture from a real document;
+every OUT item gets a test asserting it degrades predictably (ignored, no crash). The
+IN/OUT line is enforced by tests, defending against the project's #1 risk: scope creep
+back toward all-of-org.
+
+## Phase plan
+
+| Phase | Scope | Status |
+|---|---|---|
+| **M0** | **Buildable skeleton: crate layout, module stubs, deps, test harness, fixtures** | **done** |
+| **v0.1** | **End-to-end core parse → render: `build` a single `.org` file to HTML** | **done** |
+| **v0.2** | **Multi-file SITE build: INDEX + RESOLVE internal links, minijinja templates, `build <src-dir> <out-dir>`, tables + footnotes** | **done** |
+| **v0.3** | **Incremental build layer: content/config/template hashing, dependency graph, per-page render keys, persisted cache manifest, invalidation** | **done** |
+| 0 | Corpus audit + `emacs --batch` ground-truth oracle | todo |
+| 1 | Line lexer + heading/section skeleton | done |
+| 2 | Block elements — lists, source blocks, tables, footnote defs done; generic drawers | partial |
+| 3 | Inline objects — emphasis, links, bare URLs, footnote refs done; timestamps | partial |
+| 4 | Rendering to HTML — tree walk, tables, footnote two-pass, minijinja templating done; real syntect highlighting | partial |
+| 5 | Link resolution + symbol table (INDEX + RESOLVE, used-target list, broken-link reporting) | done |
+| 6 | Incremental build layer (hashing, dep graph, invalidation) done; `watch` is a simple poll loop | done |
+| 7 | Hardening: rayon parallelism, CLI polish, error locations | todo |
+
+### v0.2 in / out
+
+**Added in v0.2:** the INDEX stage (`SymbolTable` of `:ID:`/`:CUSTOM_ID:`/heading/`file:`
+targets across a directory); the RESOLVE stage — rewrites `[[#custom-id]]`, `[[id:...]]`,
+`[[*Heading]]` and `[[file:other.org]]` links to real relative output URLs, returns the
+`used_targets` list (the `uses` edges, spec §4.3/R2) and reports unresolved links as
+warnings rather than crashing; a minijinja base layout (title, nav, body) applied to every
+page; a `build <src-dir> <out-dir>` path that walks the tree, parses + resolves + renders +
+templates every `.org` into a linked static site and copies non-`.org` assets through;
+plus two new constructs — pipe **tables** (with header band from the rule row) and
+**footnotes** (block `[fn:1]` definitions, referenced `[fn:1]`, and inline `[fn:1:text]`,
+rendered as a numbered, back-linked notes section).
+
+**Still stubbed (`todo!`):** timestamps; TODO keywords and priorities; generic
+(non-PROPERTIES) drawers. Source-block syntax highlighting remains a `<pre><code>`
+passthrough behind the `Highlighter` trait; real syntect tokenizing is deferred.
+
+### v0.3 in / out
+
+**Added in v0.3 — the incremental build layer (spec §4, the flagship, non-retrofittable
+feature):**
+
+- **Three hash classes (spec §4.1)** in `src/incremental.rs`: a **content hash** (blake3
+  of a file's bytes), a **config hash** (blake3 of the resolved `BuildConfig`), and a
+  **template hash** (blake3 of the template sources). A change in any one invalidates the
+  pages it affects.
+- **Dependency graph (spec §4.3)** built from RESOLVE's `defines`/`uses` edges: a page
+  depends on the targets it links to, so editing (or renaming a heading in) a file
+  invalidates the pages that *link into* it, not just the file itself — the load-bearing
+  R2 invariant. On rebuild the graph is merged with the previous build's `defines` so a
+  *removed* target still pulls in its linkers.
+- **Per-page `render_key`** = `H(content ⊕ resolved-links ⊕ config ⊕ template)`. If a
+  page's render key is unchanged, its on-disk output is already correct and it is skipped.
+  The config component folds in a **site-structure hash** (every page's `(path, title)`),
+  because the shared nav bar is global chrome — a title change or a page add/remove alters
+  the nav on every page and so must re-render them all (otherwise byte-equivalence breaks).
+- **Persisted cache manifest** (`<out>/.org-ssg-cache.json`, JSON), carrying per-page
+  records, the config/template hashes, and the serialized dependency graph, tagged with
+  `CACHE_FORMAT_VERSION`. A version mismatch, a missing file, or a corrupt file all fall
+  back to a clean full rebuild — the cache is an optimization, never a correctness
+  dependency.
+- **Wired into `build_site`**: only pages whose render key changed (or that link into a
+  changed file's targets) are re-rendered; unchanged outputs are left in place. `--no-cache`
+  forces a full rebuild; `clean <out-dir>` removes the output directory (and its cache).
+  `SiteReport` now reports `rendered` vs `skipped` counts.
+
+The hard gates are enforced by `tests/incremental.rs`: full-vs-incremental **byte
+equivalence** (and a second unchanged build re-rendering **zero** pages); **edit-one-file**
+re-renders exactly the changed page plus its linkers; **renamed-heading** invalidates the
+linking page and updates its emitted anchor; and cache **version-bump / missing / corrupt**
+all fall back to a full rebuild.
+
+**Out of scope in v0.3 (unchanged from v0.2):** real syntect highlighting; timestamps and
+TODO keywords. `watch` is a minimal mtime poll loop (`watch <src-dir> -o <out-dir>`), not an
+OS file-watcher — the fs-notify integration is deferred. The parse-tree cache (spec §4.5,
+"optionally") is not persisted: PARSE/INDEX/RESOLVE run for every file each build (cheap and
+pure); the incremental win is on RENDER + EMIT.
+
+**From v0.1 (core subset):** headings with nesting and anchors (every heading is now
+anchored — `:CUSTOM_ID:`/`:ID:` else a slug of its text) and trailing tags; paragraphs;
+plain lists (unordered + ordered) with checkboxes; source blocks; inline markup (`*bold*`,
+`/italic/`, `_underline_`, `+strike+`, `=verbatim=`, `~code~`); links and bare URLs.
+
+## Dependencies
+
+Parser is hand-written recursive descent (not `nom`/`chumsky`/`pest` — org is
+line-oriented and context-sensitive, not clean CFG). Key crates: `syntect` (syntax
+highlighting, behind a `Highlighter` trait so tree-sitter can be swapped in later),
+`minijinja` (runtime templates), `blake3` (content/cache hashing), `chrono`,
+`camino`, `walkdir`, `clap`, `anyhow`/`thiserror`. `insta` for snapshot tests.
+
+## Build & test
+
+```
+cargo build
+cargo test
+cargo run -- build fixtures/minimal.org -o minimal.html   # single file
+cargo run -- build fixtures/site -o _site                 # whole site (incremental)
+cargo run -- build fixtures/site -o _site --no-cache      # force a full rebuild
+cargo run -- watch fixtures/site -o _site                 # poll + rebuild on change
+cargo run -- clean _site                                  # remove output + cache
+```
+
+A second `build` of an unchanged site re-renders nothing; editing a page re-renders only
+that page and the pages that link into it (watch the `rendered`/`cached` counts).
+
+`fixtures/` holds tiny `.org` samples: single-file ones (`minimal.org`, `core.org`,
+`elements.org`, `table.org`, `footnote.org`) and a linked multi-file site under
+`fixtures/site/` (`index.org`, `guide.org`, `about.org` + a `style.css` asset). The
+real corpus (golden files derived from actual documents) lands in Phase 0. `cargo test`
+includes `insta` snapshots of the element tree and rendered HTML for the single-file
+fixtures, the two templated site pages (proving cross-file link resolution), and the
+table and footnote constructs.
diff --git a/fixtures/core.org b/fixtures/core.org
new file mode 100644
index 0000000..91754ed
--- /dev/null
+++ b/fixtures/core.org
@@ -0,0 +1,21 @@
+#+TITLE: Core Constructs
+
+Intro paragraph with a bare URL https://example.com and some ~inline code~.
+
+* Ordered and checked
+
+1. first item
+2. second item with /emphasis/
+
+- [ ] todo item
+- [X] done item
+
+* Links and code
+
+An external [[https://example.org][site]] and a bare [[https://bare.example]].
+
+#+BEGIN_SRC rust
+fn main() {
+    println!("hello");
+}
+#+END_SRC
diff --git a/fixtures/elements.org b/fixtures/elements.org
new file mode 100644
index 0000000..475760e
--- /dev/null
+++ b/fixtures/elements.org
@@ -0,0 +1,31 @@
+#+TITLE: Element Sampler
+#+FILETAGS: :sample:
+
+* Code and tables
+
+#+BEGIN_SRC rust
+fn main() {
+    println!("hello");
+}
+#+END_SRC
+
+| Name  | Score |
+|-------+-------|
+| alpha |    10 |
+| beta  |    20 |
+
+* Links and footnotes
+
+An external link: [[https://example.com][Example]] and an id link [[id:abc-123]].
+
+Text with a footnote reference.[fn:1]
+
+[fn:1] The footnote definition.
+
+* Blocks
+
+#+BEGIN_QUOTE
+A quoted paragraph.
+#+END_QUOTE
+
+-----
diff --git a/fixtures/footnote.org b/fixtures/footnote.org
new file mode 100644
index 0000000..9c76419
--- /dev/null
+++ b/fixtures/footnote.org
@@ -0,0 +1,8 @@
+#+TITLE: Footnote Sample
+
+Text with a reference.[fn:1] And a second one.[fn:2]
+
+An inline footnote.[fn:3:defined right here]
+
+[fn:1] The first definition.
+[fn:2] The second definition, with /emphasis/.
diff --git a/fixtures/minimal.org b/fixtures/minimal.org
new file mode 100644
index 0000000..b2705b4
--- /dev/null
+++ b/fixtures/minimal.org
@@ -0,0 +1,20 @@
+#+TITLE: Minimal Fixture
+#+DATE: 2026-08-08
+#+AUTHOR: Owner
+
+A single paragraph of preamble text before any heading.
+
+* First Heading
+  :PROPERTIES:
+  :CUSTOM_ID: first
+  :END:
+
+Some body text with *bold*, /italic/, and =verbatim=.
+
+** A Subheading                                                     :tag1:tag2:
+
+- an unordered item
+- another with a checkbox [ ]
+
+* Second Heading
+  See [[#first][the first heading]].
diff --git a/fixtures/site/about.org b/fixtures/site/about.org
new file mode 100644
index 0000000..a0239c8
--- /dev/null
+++ b/fixtures/site/about.org
@@ -0,0 +1,3 @@
+#+TITLE: About
+
+This site is built by org-ssg. Back to [[file:index.org][home]].
diff --git a/fixtures/site/guide.org b/fixtures/site/guide.org
new file mode 100644
index 0000000..b2653fb
--- /dev/null
+++ b/fixtures/site/guide.org
@@ -0,0 +1,17 @@
+#+TITLE: Guide
+
+* Setup
+  :PROPERTIES:
+  :CUSTOM_ID: setup
+  :END:
+
+Install the steps in order.[fn:1] Then return [[file:index.org][home]].
+
+[fn:1] Read the manual before you begin.
+
+* Data
+
+| Name  | Score |
+|-------+-------|
+| alpha |    10 |
+| beta  |    20 |
diff --git a/fixtures/site/index.org b/fixtures/site/index.org
new file mode 100644
index 0000000..3bfaaa5
--- /dev/null
+++ b/fixtures/site/index.org
@@ -0,0 +1,9 @@
+#+TITLE: Home
+
+Welcome. See the [[file:guide.org][guide]] and jump straight to its
+[[#setup][setup section]] across files.
+
+Also see [[*Overview]] further down this page.
+
+* Overview
+The overview lives on the home page.
diff --git a/fixtures/site/style.css b/fixtures/site/style.css
new file mode 100644
index 0000000..3f93e35
--- /dev/null
+++ b/fixtures/site/style.css
@@ -0,0 +1,2 @@
+body { font-family: system-ui, sans-serif; max-width: 40rem; margin: 2rem auto; }
+nav a { margin-right: 1rem; }
diff --git a/fixtures/table.org b/fixtures/table.org
new file mode 100644
index 0000000..f32f2de
--- /dev/null
+++ b/fixtures/table.org
@@ -0,0 +1,6 @@
+#+TITLE: Table Sample
+
+| Name  | Score |
+|-------+-------|
+| alpha |    10 |
+| beta  |    20 |
diff --git a/src/incremental.rs b/src/incremental.rs
new file mode 100644
index 0000000..1e4aedc
--- /dev/null
+++ b/src/incremental.rs
@@ -0,0 +1,257 @@
+//! Incremental build layer (spec §4): content/config/template hashing, the link
+//! dependency graph, the cache manifest, and the invalidation algorithm.
+//!
+//! This is the hard, non-retrofittable part (spec §4). The data model is already
+//! shaped for it — pure, hashable, dependency-tracked units — so this layer is mostly
+//! bookkeeping over the graph.
+//!
+//! The three hash classes (spec §4.1) all feed a page's composed `render_key`:
+//! 1. **content hash** — blake3 of a source file's raw bytes (drives re-parse).
+//! 2. **config hash** — blake3 of the resolved global config (a base-URL/options change
+//!    can invalidate everything).
+//! 3. **template hash** — blake3 of the templates (a base-layout edit invalidates every
+//!    page that uses it).
+//!
+//! Skip rule (spec §4.1): if a page's `render_key` is unchanged, its emitted file on
+//! disk is already correct — skip it. The dependency graph (spec §4.3) additionally
+//! invalidates pages that *link into* a changed file's targets, so a renamed/removed
+//! heading invalidates the pages that link to it, not just the file that owns it.
+
+use std::collections::{HashMap, HashSet};
+
+use camino::{Utf8Path, Utf8PathBuf};
+use serde::{Deserialize, Serialize};
+
+use crate::index::{SymbolTable, TargetId};
+use crate::model::ContentHash;
+use crate::util::output_url;
+
+/// Bump whenever the `Document` type, hashing scheme, or resolution rules change.
+/// On mismatch: discard cache, full rebuild (spec §4.5). The blake3 crate's major
+/// version is folded in as the "hash-algo version" so a hash upgrade also busts.
+pub const CACHE_FORMAT_VERSION: u32 = 3;
+
+/// blake3 hex identity for a content/config/template/render-key hash class (spec §4.1).
+pub type Hash = ContentHash;
+
+/// Resolved global build config. Its hash is a component of every page's render key
+/// (spec §4.1): a change here can invalidate the whole site. Kept minimal for v0.3 —
+/// there is no user-facing config yet — but structured so real knobs (base URL, TODO
+/// keyword set, highlighter theme id, inline features) flow into the hash when added.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct BuildConfig {
+    pub output_extension: String,
+    pub highlighter_theme: String,
+}
+
+impl Default for BuildConfig {
+    fn default() -> Self {
+        BuildConfig {
+            output_extension: "html".to_string(),
+            highlighter_theme: "passthrough".to_string(),
+        }
+    }
+}
+
+/// Compose bytes into a blake3 hash. The one place hashing happens for composite keys.
+fn hash_bytes(bytes: &[u8]) -> Hash {
+    ContentHash(*blake3::hash(bytes).as_bytes())
+}
+
+/// blake3 of the resolved global config (spec §4.1, hash class 2).
+pub fn config_hash(config: &BuildConfig) -> Hash {
+    let json = serde_json::to_vec(config).expect("BuildConfig serializes");
+    hash_bytes(&json)
+}
+
+/// Fold two hashes into one composite (order-sensitive).
+pub fn combine(a: Hash, b: Hash) -> Hash {
+    let mut hasher = blake3::Hasher::new();
+    hasher.update(&a.0);
+    hasher.update(&b.0);
+    ContentHash(*hasher.finalize().as_bytes())
+}
+
+/// Hash of the global site structure that appears in every page's chrome. The nav bar is
+/// built from every page's `(path, title)`, so any title change, path change, page
+/// addition, or removal alters the nav on ALL pages and must invalidate them. This is a
+/// genuine global dependency (like the config/template hashes, spec §4.1), so it is
+/// folded into every page's render key. Deterministic: entries are sorted.
+pub fn site_structure_hash(entries: &[(String, String)]) -> Hash {
+    let mut sorted = entries.to_vec();
+    sorted.sort();
+    let mut hasher = blake3::Hasher::new();
+    for (path, title) in &sorted {
+        hasher.update(path.as_bytes());
+        hasher.update(&[0]);
+        hasher.update(title.as_bytes());
+        hasher.update(&[0]);
+    }
+    ContentHash(*hasher.finalize().as_bytes())
+}
+
+/// blake3 over the template sources (spec §4.1, hash class 3). One combined hash over
+/// all templates; when partials land, split this per-template so a single-partial edit
+/// invalidates only its users.
+pub fn template_hash(sources: &[(&str, &str)]) -> Hash {
+    let mut hasher = blake3::Hasher::new();
+    for (name, src) in sources {
+        hasher.update(name.as_bytes());
+        hasher.update(&[0]);
+        hasher.update(src.as_bytes());
+        hasher.update(&[0]);
+    }
+    ContentHash(*hasher.finalize().as_bytes())
+}
+
+/// The link dependency graph (spec §4.3). `defines`: file → targets it owns.
+/// `uses`: page → targets it resolved a link to (the invalidation-critical edges).
+#[derive(Debug, Default, Clone, Serialize, Deserialize)]
+pub struct DepGraph {
+    pub defines: HashMap<Utf8PathBuf, HashSet<TargetId>>,
+    pub uses: HashMap<Utf8PathBuf, HashSet<TargetId>>,
+}
+
+impl DepGraph {
+    /// Merge `self` (typically the previous build's graph) with `other` (this build's),
+    /// unioning the `defines` targets per file and taking `other`'s `uses`. Used to build
+    /// the graph handed to [`invalidation_set`]: a target that a changed file *removed*
+    /// is still present in the old `defines`, so pages that linked to it are still found
+    /// (the renamed/removed-heading case, spec §4.3 step 2).
+    pub fn merged_defines_with(&self, other: &DepGraph) -> DepGraph {
+        let mut defines = self.defines.clone();
+        for (file, targets) in &other.defines {
+            defines.entry(file.clone()).or_default().extend(targets.iter().cloned());
+        }
+        DepGraph {
+            defines,
+            uses: other.uses.clone(),
+        }
+    }
+}
+
+/// blake3 over the resolved URLs of the targets a page consumed (spec §4.1: the
+/// `resolved_links_hash` component). Computed relative to the linking page, so a target
+/// whose resolved URL/anchor changed — a renamed `*Heading`, a moved file — flips this
+/// hash and therefore the page's `render_key`. Deterministic: targets are sorted.
+pub fn resolved_links_hash(
+    from: &Utf8Path,
+    used: &HashSet<TargetId>,
+    symbols: &SymbolTable,
+) -> Hash {
+    let mut pairs: Vec<(String, String)> = used
+        .iter()
+        .map(|tid| {
+            let url = symbols
+                .targets
+                .get(tid)
+                .map(|loc| output_url(from, &loc.source_path, loc.anchor.as_deref()))
+                .unwrap_or_default();
+            (tid.sort_key(), url)
+        })
+        .collect();
+    pairs.sort();
+    let mut hasher = blake3::Hasher::new();
+    for (tid, url) in &pairs {
+        hasher.update(tid.as_bytes());
+        hasher.update(&[0]);
+        hasher.update(url.as_bytes());
+        hasher.update(&[0]);
+    }
+    ContentHash(*hasher.finalize().as_bytes())
+}
+
+/// Compose a page's final-output cache key (spec §4.1):
+///
+/// ```text
+/// render_key = H( parse_result_hash ⊕ resolved_links_hash ⊕ config_hash ⊕ template_hash )
+/// ```
+///
+/// `parse_result_hash` is the source content hash (PARSE is a pure function of the file
+/// bytes, so the content hash fully identifies the parse result). If `render_key` is
+/// unchanged the on-disk output is already correct and the page is skipped.
+pub fn render_key(
+    parse_result_hash: Hash,
+    resolved_links_hash: Hash,
+    config_hash: Hash,
+    template_hash: Hash,
+) -> Hash {
+    let mut hasher = blake3::Hasher::new();
+    hasher.update(&parse_result_hash.0);
+    hasher.update(&resolved_links_hash.0);
+    hasher.update(&config_hash.0);
+    hasher.update(&template_hash.0);
+    ContentHash(*hasher.finalize().as_bytes())
+}
+
+/// Per-page cache record persisted in the manifest (spec §4.5).
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct PageRecord {
+    pub content_hash: ContentHash,
+    pub render_key: Hash,
+    pub output_path: Utf8PathBuf,
+}
+
+/// The on-disk cache manifest (spec §4.5). Serialized as JSON (human-diffable; the
+/// cache is an optimization, never a correctness dependency — a `--no-cache`/`clean`
+/// run always produces byte-identical output).
+#[derive(Debug, Default, Serialize, Deserialize)]
+pub struct Manifest {
+    pub format_version: u32,
+    pub config_hash: Option<Hash>,
+    pub template_hash: Option<Hash>,
+    pub pages: HashMap<Utf8PathBuf, PageRecord>,
+    pub graph: DepGraph,
+}
+
+/// The cache-manifest file lives inside the output directory (spec §4.5: an on-disk
+/// cache dir). `clean` removes the output directory, taking the cache with it.
+pub fn manifest_path(out: &Utf8Path) -> Utf8PathBuf {
+    out.join(".org-ssg-cache.json")
+}
+
+/// Load the manifest, returning `None` on ANY of: missing file, read/parse error, or a
+/// cache-format version mismatch (spec §4.5). `None` ⇒ the caller does a full rebuild.
+/// The cache is never a correctness dependency, so a corrupt cache is never a crash.
+pub fn load_manifest(out: &Utf8Path) -> Option<Manifest> {
+    let bytes = std::fs::read(manifest_path(out)).ok()?;
+    let manifest: Manifest = serde_json::from_slice(&bytes).ok()?;
+    if manifest.format_version != CACHE_FORMAT_VERSION {
+        return None;
+    }
+    Some(manifest)
+}
+
+/// Persist the manifest into the output directory. A write failure is surfaced to the
+/// caller (a failed cache write only costs the next build a full rebuild).
+pub fn save_manifest(out: &Utf8Path, manifest: &Manifest) -> std::io::Result<()> {
+    let json = serde_json::to_vec_pretty(manifest).expect("manifest serializes");
+    std::fs::write(manifest_path(out), json)
+}
+
+/// Given the set of changed files and the dependency graph, compute the set of pages to
+/// rebuild (spec §4.3 invalidation algorithm): the changed files themselves, plus every
+/// page with a `uses` edge into a target *defined by* a changed file. Pass a graph whose
+/// `defines` is the union of the previous and current builds (see
+/// [`DepGraph::merged_defines_with`]) so that a target a changed file *removed* still
+/// pulls in the pages that linked to it (the renamed/removed-heading case).
+pub fn invalidation_set(
+    changed: &HashSet<Utf8PathBuf>,
+    graph: &DepGraph,
+) -> HashSet<Utf8PathBuf> {
+    // Targets touched by any changed file (added, removed, or possibly-moved).
+    let mut delta_targets: HashSet<&TargetId> = HashSet::new();
+    for file in changed {
+        if let Some(targets) = graph.defines.get(file) {
+            delta_targets.extend(targets.iter());
+        }
+    }
+    // Changed files themselves, plus any page that links into a delta'd target.
+    let mut result: HashSet<Utf8PathBuf> = changed.clone();
+    for (page, used) in &graph.uses {
+        if used.iter().any(|t| delta_targets.contains(t)) {
+            result.insert(page.clone());
+        }
+    }
+    result
+}
diff --git a/src/index.rs b/src/index.rs
new file mode 100644
index 0000000..bc96db8
--- /dev/null
+++ b/src/index.rs
@@ -0,0 +1,133 @@
+//! INDEX stage (spec §2.1, §4.3): collect link targets across all documents into a
+//! global symbol table. This is the only inherently global stage before RESOLVE.
+
+use std::collections::HashMap;
+
+use camino::{Utf8Path, Utf8PathBuf};
+use serde::{Deserialize, Serialize};
+
+use crate::model::{Document, Section};
+use crate::util::{plain_text, slugify};
+
+/// Identity of a link target. A target is owned by exactly one file (spec §4.3).
+///
+/// `Serialize`/`Deserialize` so the dependency graph (defines/uses edges) round-trips
+/// through the on-disk cache manifest (spec §4.5).
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
+pub enum TargetId {
+    Id(String),
+    CustomId(String),
+    Heading(String),
+    File(Utf8PathBuf),
+}
+
+impl TargetId {
+    /// A stable string form used to order targets deterministically when hashing
+    /// (so a page's `resolved_links_hash` does not depend on `HashSet` iteration order).
+    pub fn sort_key(&self) -> String {
+        match self {
+            TargetId::Id(s) => format!("id:{s}"),
+            TargetId::CustomId(s) => format!("custom:{s}"),
+            TargetId::Heading(s) => format!("heading:{s}"),
+            TargetId::File(p) => format!("file:{p}"),
+        }
+    }
+}
+
+/// Where a resolved target lives, once INDEX has seen its defining file.
+#[derive(Debug, Clone)]
+pub struct TargetLocation {
+    pub source_path: Utf8PathBuf,
+    /// Final URL fragment/anchor for the target, filled during resolution.
+    pub anchor: Option<String>,
+}
+
+/// Maps every collected target to its owning location (spec §4.3 "defines" edges).
+#[derive(Debug, Default)]
+pub struct SymbolTable {
+    pub targets: HashMap<TargetId, TargetLocation>,
+}
+
+impl SymbolTable {
+    pub fn new() -> Self {
+        Self::default()
+    }
+
+    /// Walk one document, registering every `:ID:`/`:CUSTOM_ID:`/heading/file target.
+    /// A target is owned by exactly one file (spec §4.3); the anchor is the fragment
+    /// the renderer emits for that target's heading.
+    pub fn index_document(&mut self, doc: &Document) {
+        let path = &doc.source_path;
+        self.targets.insert(
+            TargetId::File(path.clone()),
+            TargetLocation {
+                source_path: path.clone(),
+                anchor: None,
+            },
+        );
+        index_section(&doc.root, path, &mut self.targets);
+    }
+}
+
+/// The set of link targets a single document *defines* (owns) — the `defines` edges of
+/// the dependency graph (spec §4.3). Mirrors [`SymbolTable::index_document`] but returns
+/// the targets for one file in isolation, which is what the incremental layer records
+/// per-file in the cache manifest.
+pub fn document_targets(doc: &Document) -> Vec<TargetId> {
+    let mut out = vec![TargetId::File(doc.source_path.clone())];
+    collect_targets(&doc.root, &mut out);
+    out
+}
+
+fn collect_targets(section: &Section, out: &mut Vec<TargetId>) {
+    if let Some(h) = &section.heading {
+        if let Some(cid) = &h.custom_id {
+            out.push(TargetId::CustomId(cid.clone()));
+        }
+        if let Some(id) = &h.id {
+            out.push(TargetId::Id(id.clone()));
+        }
+        out.push(TargetId::Heading(plain_text(&h.title)));
+    }
+    for child in &section.children {
+        collect_targets(child, out);
+    }
+}
+
+fn index_section(section: &Section, path: &Utf8Path, targets: &mut HashMap<TargetId, TargetLocation>) {
+    if let Some(h) = &section.heading {
+        let anchor = h
+            .custom_id
+            .clone()
+            .or_else(|| h.id.clone())
+            .unwrap_or_else(|| slugify(&plain_text(&h.title)));
+        if let Some(cid) = &h.custom_id {
+            targets.insert(
+                TargetId::CustomId(cid.clone()),
+                TargetLocation {
+                    source_path: path.to_owned(),
+                    anchor: Some(cid.clone()),
+                },
+            );
+        }
+        if let Some(id) = &h.id {
+            targets.insert(
+                TargetId::Id(id.clone()),
+                TargetLocation {
+                    source_path: path.to_owned(),
+                    anchor: Some(id.clone()),
+                },
+            );
+        }
+        targets.insert(
+            TargetId::Heading(plain_text(&h.title)),
+            TargetLocation {
+                source_path: path.to_owned(),
+                anchor: Some(anchor),
+            },
+        );
+    }
+    for child in &section.children {
+        index_section(child, path, targets);
+    }
+}
diff --git a/src/lib.rs b/src/lib.rs
new file mode 100644
index 0000000..20fe144
--- /dev/null
+++ b/src/lib.rs
@@ -0,0 +1,19 @@
+//! org-ssg — an org-mode static site generator.
+//!
+//! Org is the source language, not an input to be normalized into markdown. The org
+//! element tree ([`model`]) *is* the document model; we render it straight to HTML.
+//!
+//! Pipeline (spec §2.1), one module per stage:
+//! DISCOVER → [`parser`] (PARSE) → [`index`] (INDEX) → [`resolve`] (RESOLVE) →
+//! [`render`] (RENDER) → [`template`] (TEMPLATE) → EMIT, with [`incremental`]
+//! deciding which pages actually need rewriting.
+
+pub mod incremental;
+pub mod index;
+pub mod model;
+pub mod parser;
+pub mod render;
+pub mod resolve;
+pub mod site;
+pub mod template;
+pub mod util;
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..31f260f
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,180 @@
+//! CLI entry point (spec §3.5): `build`, `watch`, `clean`.
+
+use std::fs;
+
+use anyhow::{Context, Result};
+use camino::{Utf8Path, Utf8PathBuf};
+use clap::{Parser, Subcommand};
+
+use org_ssg::parser::parse;
+use org_ssg::render::{render, Html, SyntectHighlighter};
+use org_ssg::resolve::ResolvedDoc;
+use org_ssg::site::{build_site, BuildOptions};
+use org_ssg::template::Templater;
+
+#[derive(Parser)]
+#[command(name = "org-ssg", about = "Org-mode static site generator")]
+struct Cli {
+    #[command(subcommand)]
+    command: Command,
+}
+
+#[derive(Subcommand)]
+enum Command {
+    /// Build a site. If INPUT is a directory, walk it and emit a linked static site
+    /// to OUTPUT (a directory); if INPUT is a single `.org` file, emit one HTML file.
+    Build {
+        /// Input `.org` file, or a source directory for a whole-site build.
+        input: Utf8PathBuf,
+        /// Output path: an `.html` file for a single input, or a directory for a site.
+        #[arg(short, long)]
+        output: Option<Utf8PathBuf>,
+        /// Bypass the incremental cache and re-render every page (spec §4.5).
+        #[arg(long)]
+        no_cache: bool,
+        /// Treat broken internal links as errors (spec §4.3.4).
+        #[arg(long)]
+        strict: bool,
+    },
+    /// Watch a source directory and rebuild incrementally on change (simple poll loop).
+    Watch {
+        /// Source directory to watch.
+        input: Utf8PathBuf,
+        /// Output directory.
+        #[arg(short, long)]
+        output: Utf8PathBuf,
+    },
+    /// Remove the build output directory (which holds the cache manifest).
+    Clean {
+        /// Output directory to remove.
+        output: Utf8PathBuf,
+    },
+}
+
+fn main() -> Result<()> {
+    let cli = Cli::parse();
+    match cli.command {
+        Command::Build {
+            input,
+            output,
+            no_cache,
+            strict,
+        } => {
+            if input.is_dir() {
+                let out = output
+                    .context("site build requires an output directory: build <src-dir> -o <out-dir>")?;
+                let opts = BuildOptions { no_cache, strict };
+                let report = build_site(&input, &out, &opts)?;
+                println!(
+                    "built {} page(s) ({} rendered, {} cached), copied {} asset(s) from {} -> {} ({} unresolved link(s))",
+                    report.pages.len(),
+                    report.rendered.len(),
+                    report.skipped.len(),
+                    report.assets.len(),
+                    input,
+                    out,
+                    report.broken.len()
+                );
+            } else {
+                let output = output.unwrap_or_else(|| input.with_extension("html"));
+                build_file(&input, &output)?;
+                println!("built {} -> {}", input, output);
+            }
+            Ok(())
+        }
+        // Watch is intentionally a minimal poll loop, not an OS file-watch (spec §5 Phase
+        // 6 lists `watch`; the real fs-notify integration is deferred). It rebuilds
+        // incrementally whenever any source file's mtime advances.
+        Command::Watch { input, output } => watch(&input, &output),
+        Command::Clean { output } => {
+            if output.exists() {
+                fs::remove_dir_all(&output)
+                    .with_context(|| format!("removing output directory {output}"))?;
+                println!("removed {output}");
+            } else {
+                println!("nothing to clean: {output} does not exist");
+            }
+            Ok(())
+        }
+    }
+}
+
+/// Minimal poll-based watch loop: rebuild incrementally whenever a source file changes.
+/// Not an OS file-watcher (deferred); it snapshots source mtimes every 500ms.
+fn watch(input: &Utf8Path, output: &Utf8Path) -> Result<()> {
+    use std::time::{Duration, SystemTime};
+
+    if !input.is_dir() {
+        anyhow::bail!("watch requires a source directory: watch <src-dir> -o <out-dir>");
+    }
+    let opts = BuildOptions::default();
+
+    let snapshot = |root: &Utf8Path| -> Vec<(Utf8PathBuf, SystemTime)> {
+        let mut v = Vec::new();
+        for entry in walkdir::WalkDir::new(root).sort_by_file_name() {
+            let Ok(entry) = entry else { continue };
+            if !entry.file_type().is_file() {
+                continue;
+            }
+            if let (Ok(path), Ok(meta)) = (
+                Utf8PathBuf::from_path_buf(entry.path().to_owned()),
+                entry.metadata(),
+            ) {
+                let mtime = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
+                v.push((path, mtime));
+            }
+        }
+        v
+    };
+
+    let report = build_site(input, output, &opts)?;
+    println!(
+        "watching {input} -> {output}: built {} page(s) ({} rendered). Ctrl-C to stop.",
+        report.pages.len(),
+        report.rendered.len()
+    );
+    let mut last = snapshot(input);
+    loop {
+        std::thread::sleep(Duration::from_millis(500));
+        let now = snapshot(input);
+        if now != last {
+            match build_site(input, output, &opts) {
+                Ok(report) => println!(
+                    "rebuilt: {} rendered, {} cached",
+                    report.rendered.len(),
+                    report.skipped.len()
+                ),
+                Err(e) => eprintln!("build error: {e:#}"),
+            }
+            last = now;
+        }
+    }
+}
+
+/// Single-file build: read → PARSE → RENDER → TEMPLATE → write. No cross-file link
+/// resolution (there is no corpus to resolve against); links keep their best-effort
+/// URLs. Whole-site link resolution lives in [`build_site`].
+fn build_file(input: &Utf8Path, output: &Utf8Path) -> Result<()> {
+    let source = fs::read_to_string(input)
+        .with_context(|| format!("reading source file {input}"))?;
+    let document = parse(input, &source).with_context(|| format!("parsing {input}"))?;
+
+    let title = document
+        .keywords
+        .entries
+        .iter()
+        .find(|(k, _)| k.eq_ignore_ascii_case("TITLE"))
+        .map(|(_, v)| v.clone())
+        .unwrap_or_else(|| input.file_stem().unwrap_or("untitled").to_string());
+
+    let resolved = ResolvedDoc { document };
+    let highlighter = SyntectHighlighter;
+    let Html(fragment) = render(&resolved, &highlighter);
+
+    let templater = Templater::new();
+    let page = templater
+        .render_page(&title, &fragment, &[])
+        .with_context(|| format!("templating {input}"))?;
+    fs::write(output, page).with_context(|| format!("writing output file {output}"))?;
+    Ok(())
+}
diff --git a/src/model.rs b/src/model.rs
new file mode 100644
index 0000000..00b239f
--- /dev/null
+++ b/src/model.rs
@@ -0,0 +1,224 @@
+//! The org element tree. This *is* the document model (spec §2.2) — the parser's
+//! output type is what the renderer consumes; there is no separate document AST.
+//!
+//! Org's two-tier structure is mirrored in the type system:
+//! - [`Element`] — block-level things (headings, paragraphs, lists, tables, blocks).
+//! - [`Object`] — inline things inside an element's content (bold, links, timestamps).
+//!
+//! This split lets the renderer never accidentally nest a heading inside emphasis.
+//! The whole tree is `serde`-serializable so the parse cache and golden-file
+//! snapshots share one representation (spec §4, §5).
+
+use camino::Utf8PathBuf;
+use chrono::NaiveDateTime;
+use serde::{Deserialize, Serialize};
+
+/// Content hash of raw source bytes. blake3 (spec §4.1). Serialized as hex.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
+pub struct ContentHash(pub [u8; 32]);
+
+/// Parsed `#+KEYWORD:` directives (`#+TITLE`, `#+DATE`, `#+OPTIONS`, ...).
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+pub struct Keywords {
+    pub entries: Vec<(String, String)>,
+}
+
+/// Parsed `:PROPERTIES:` drawer as an ordered key/value map.
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+pub struct Properties {
+    pub entries: Vec<(String, String)>,
+}
+
+/// A TODO keyword resolved against the configured keyword set.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct TodoKeyword {
+    pub name: String,
+    pub done: bool,
+}
+
+/// One source file → one Document. This is the unit of parsing and caching (spec §2.3).
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Document {
+    pub source_path: Utf8PathBuf,
+    pub content_hash: ContentHash,
+    pub keywords: Keywords,
+    /// Pre-first-heading content plus child headings.
+    pub root: Section,
+}
+
+/// A section = content directly under a heading (or the file preamble), followed by
+/// nested subsections. Recursive, mirroring org's headline hierarchy — so that
+/// "renaming a heading invalidates its subtree's link targets" is a local operation.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Section {
+    /// `None` for the file preamble.
+    pub heading: Option<Heading>,
+    /// Block-level content of THIS section.
+    pub content: Vec<Element>,
+    /// Nested sub-headings.
+    pub children: Vec<Section>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Heading {
+    pub level: u8,
+    pub todo: Option<TodoKeyword>,
+    /// `'A'..` from `[#A]`.
+    pub priority: Option<char>,
+    /// Inline objects — headings can contain markup/links.
+    pub title: Vec<Object>,
+    pub tags: Vec<String>,
+    pub properties: Properties,
+    /// `:ID:`.
+    pub id: Option<String>,
+    /// `:CUSTOM_ID:`.
+    pub custom_id: Option<String>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub enum Element {
+    Paragraph(Vec<Object>),
+    List(List),
+    Table(Table),
+    SrcBlock {
+        lang: Option<String>,
+        params: BlockParams,
+        code: String,
+    },
+    ExampleBlock(String),
+    QuoteBlock(Vec<Element>),
+    CenterBlock(Vec<Element>),
+    /// html passes through; others dropped at render (spec §1 OUT).
+    ExportBlock {
+        backend: String,
+        raw: String,
+    },
+    HorizontalRule,
+    FootnoteDefinition {
+        label: String,
+        content: Vec<Element>,
+    },
+    /// Stray `#+FOO:` kept as metadata.
+    Keyword {
+        key: String,
+        value: String,
+    },
+    /// Generic drawer; LOGBOOK special-cased (spec §8 Q7).
+    Drawer {
+        name: String,
+        content: Vec<Element>,
+    },
+    Comment(String),
+}
+
+/// `#+BEGIN_SRC` switches / header args. Parsed but mostly ignored in v1.
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+pub struct BlockParams {
+    pub raw: String,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct List {
+    pub kind: ListKind,
+    pub items: Vec<ListItem>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub enum ListKind {
+    Unordered,
+    Ordered,
+    Description,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ListItem {
+    pub bullet: Bullet,
+    pub checkbox: Option<Checkbox>,
+    /// Description-list term before `::`.
+    pub term: Option<Vec<Object>>,
+    /// Items hold block content (may nest lists).
+    pub content: Vec<Element>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub enum Bullet {
+    Dash,
+    Plus,
+    /// `1.` / `1)` — carries the ordinal.
+    Ordered(u32),
+}
+
+/// `[ ]` / `[X]` / `[-]`.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub enum Checkbox {
+    Off,
+    On,
+    Trans,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Table {
+    /// Rule rows preserved to locate the header band.
+    pub rows: Vec<TableRow>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub enum TableRow {
+    Cells(Vec<Vec<Object>>),
+    Rule,
+}
+
+/// Inline objects (spec §2.3).
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub enum Object {
+    Text(String),
+    Bold(Vec<Object>),
+    Italic(Vec<Object>),
+    Underline(Vec<Object>),
+    StrikeThrough(Vec<Object>),
+    /// `=...=` : no nested markup (String, not Vec<Object>, by design).
+    Verbatim(String),
+    /// `~...~` : no nested markup.
+    Code(String),
+    Link(Link),
+    FootnoteRef {
+        label: String,
+        inline: Option<Vec<Object>>,
+    },
+    Timestamp(Timestamp),
+    LineBreak,
+    /// Resolved `\alpha`-style entity, only if enabled.
+    Entity(String),
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Link {
+    /// Unresolved at parse time — resolution is a separate global stage (spec §2.3).
+    pub target: LinkTarget,
+    pub description: Option<Vec<Object>>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub enum LinkTarget {
+    /// `https:`, `mailto:`, ...
+    External(String),
+    File {
+        path: Utf8PathBuf,
+        search: Option<String>,
+    },
+    /// `[[id:...]]`.
+    Id(String),
+    /// `[[#...]]`.
+    CustomId(String),
+    /// `[[*Heading text]]`.
+    Heading(String),
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Timestamp {
+    /// `<...>` vs `[...]`.
+    pub active: bool,
+    pub start: NaiveDateTime,
+    pub end: Option<NaiveDateTime>,
+    pub has_time: bool,
+}
diff --git a/src/parser.rs b/src/parser.rs
new file mode 100644
index 0000000..efe43b8
--- /dev/null
+++ b/src/parser.rs
@@ -0,0 +1,886 @@
+//! PARSE stage (spec §2.1, §3.1): bytes → tokens → org element tree.
+//!
+//! Hand-written recursive descent, deliberately two-tier (spec §3.1):
+//! 1. [`line_lexer`] — cheap first pass classifying each line, context-free.
+//! 2. [`build_document`] — recursive descent over the line stream into `Section`s/`Element`s.
+//! 3. [`inline`] — scans an element's text runs into `Vec<Object>`, implementing
+//!    org's emphasis pre/post-char rules explicitly.
+//!
+//! PARSE is a pure function of a single file's bytes (spec §2.1): it never depends on
+//! another file, which is what makes content-hash caching sound.
+//!
+//! v0.1 scope (the CORE subset): headings + nesting, property drawers on headings,
+//! paragraphs, plain lists (unordered + ordered) with checkboxes, source blocks, and
+//! inline markup (bold/italic/underline/strike/verbatim/code, links, bare URLs).
+//! Out of scope and left graceful (parsed-and-ignored, never crashing): tables,
+//! footnotes, timestamps, TODO keywords, non-SRC blocks (kept verbatim as example
+//! blocks), generic drawers other than PROPERTIES.
+
+use camino::Utf8Path;
+
+use crate::model::{
+    BlockParams, Bullet, Checkbox, ContentHash, Document, Element, Heading, Keywords, Link,
+    LinkTarget, List, ListItem, ListKind, Object, Properties, Section, Table, TableRow,
+};
+
+#[derive(Debug, thiserror::Error)]
+pub enum ParseError {
+    #[error("parse error at line {line}: {message}")]
+    At { line: usize, message: String },
+}
+
+/// Classified lines produced by the first pass (spec §3.1).
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum Line {
+    Heading,
+    BlockBegin { kind: String },
+    BlockEnd,
+    ListItem,
+    TableRow,
+    Keyword,
+    DrawerBegin,
+    DrawerEnd,
+    Rule,
+    Blank,
+    Text,
+}
+
+/// First pass: classify each raw line. Context-free per line.
+pub fn line_lexer(source: &str) -> Vec<Line> {
+    source.lines().map(classify_line).collect()
+}
+
+fn classify_line(line: &str) -> Line {
+    if line.trim().is_empty() {
+        return Line::Blank;
+    }
+    if heading_level(line).is_some() {
+        return Line::Heading;
+    }
+    let t = line.trim_start();
+    let upper = t.to_ascii_uppercase();
+    if let Some(rest) = upper.strip_prefix("#+BEGIN_") {
+        let kind = rest.split_whitespace().next().unwrap_or("").to_string();
+        return Line::BlockBegin { kind };
+    }
+    if upper.starts_with("#+END_") {
+        return Line::BlockEnd;
+    }
+    if keyword_kv(line).is_some() {
+        return Line::Keyword;
+    }
+    if is_rule(line) {
+        return Line::Rule;
+    }
+    if t.eq_ignore_ascii_case(":END:") {
+        return Line::DrawerEnd;
+    }
+    if is_drawer_begin(t) {
+        return Line::DrawerBegin;
+    }
+    if is_list_item(t).is_some() {
+        return Line::ListItem;
+    }
+    if t.starts_with('|') {
+        return Line::TableRow;
+    }
+    Line::Text
+}
+
+/// blake3 of raw source bytes — the content hash that drives re-parse decisions (spec §4.1).
+pub fn content_hash(bytes: &[u8]) -> ContentHash {
+    ContentHash(*blake3::hash(bytes).as_bytes())
+}
+
+/// Parse one source file into a [`Document`]. Pure over `(path, source)`.
+pub fn parse(path: &Utf8Path, source: &str) -> Result<Document, ParseError> {
+    let content_hash = content_hash(source.as_bytes());
+    let lines: Vec<&str> = source.lines().collect();
+    let classes = line_lexer(source);
+
+    let mut keywords = Keywords::default();
+    let mut root = Section {
+        heading: None,
+        content: Vec::new(),
+        children: Vec::new(),
+    };
+
+    let heading_idxs: Vec<usize> = classes
+        .iter()
+        .enumerate()
+        .filter(|(_, c)| **c == Line::Heading)
+        .map(|(i, _)| i)
+        .collect();
+    let first = heading_idxs.first().copied().unwrap_or(lines.len());
+
+    // Preamble: document-level keywords are lifted into `keywords`; the remaining
+    // lines become the root section's block content.
+    {
+        let mut body: Vec<&str> = Vec::new();
+        for (l, c) in lines[..first].iter().zip(&classes[..first]) {
+            if *c == Line::Keyword {
+                if let Some((k, v)) = keyword_kv(l) {
+                    keywords.entries.push((k, v));
+                }
+            } else {
+                body.push(l);
+            }
+        }
+        root.content = parse_elements(&body);
+    }
+
+    // Each heading segment runs from its own line up to (but excluding) the next heading.
+    let mut flat: Vec<(u8, Section)> = Vec::new();
+    for (k, &h_idx) in heading_idxs.iter().enumerate() {
+        let end = heading_idxs.get(k + 1).copied().unwrap_or(lines.len());
+        let heading = parse_heading(lines[h_idx]);
+        let level = heading.level;
+        let (heading, content) = parse_section_body(heading, &lines[h_idx + 1..end]);
+        flat.push((
+            level,
+            Section {
+                heading: Some(heading),
+                content,
+                children: Vec::new(),
+            },
+        ));
+    }
+
+    let mut pos = 0;
+    root.children = build_children(&mut flat, &mut pos, 0);
+
+    Ok(Document {
+        source_path: path.to_owned(),
+        content_hash,
+        keywords,
+        root,
+    })
+}
+
+/// Fold the flat `(level, section)` list into org's nested hierarchy by level.
+fn build_children(flat: &mut [(u8, Section)], pos: &mut usize, parent_level: u8) -> Vec<Section> {
+    let mut children = Vec::new();
+    while *pos < flat.len() {
+        let level = flat[*pos].0;
+        if level <= parent_level {
+            break;
+        }
+        let mut section = std::mem::replace(&mut flat[*pos].1, empty_section());
+        *pos += 1;
+        section.children = build_children(flat, pos, level);
+        children.push(section);
+    }
+    children
+}
+
+fn empty_section() -> Section {
+    Section {
+        heading: None,
+        content: Vec::new(),
+        children: Vec::new(),
+    }
+}
+
+/// Second-tier: scan an element's text into inline objects, applying org's
+/// pre/post-char emphasis rules (spec §3.1, R3 — the highest-divergence area).
+pub fn inline(text: &str) -> Vec<Object> {
+    let chars: Vec<char> = text.chars().collect();
+    parse_inline_run(&chars)
+}
+
+// ---------------------------------------------------------------------------
+// Headings
+// ---------------------------------------------------------------------------
+
+/// `*`-prefixed heading depth, or `None` if the line is not a heading.
+fn heading_level(line: &str) -> Option<u8> {
+    if !line.starts_with('*') {
+        return None;
+    }
+    let stars = line.chars().take_while(|c| *c == '*').count();
+    let after = &line[stars..];
+    if after.starts_with(' ') || after.is_empty() {
+        Some(stars.min(u8::MAX as usize) as u8)
+    } else {
+        None
+    }
+}
+
+fn parse_heading(line: &str) -> Heading {
+    let level = heading_level(line).unwrap_or(1);
+    let rest = line[level as usize..].trim();
+    let (title_str, tags) = split_tags(rest);
+    Heading {
+        level,
+        todo: None,     // TODO keywords: out of scope for v0.1.
+        priority: None, // priorities: out of scope for v0.1.
+        title: inline(title_str.trim()),
+        tags,
+        properties: Properties::default(),
+        id: None,
+        custom_id: None,
+    }
+}
+
+/// Split a trailing `:tag1:tag2:` cluster off the heading text.
+fn split_tags(rest: &str) -> (&str, Vec<String>) {
+    let trimmed = rest.trim_end();
+    if !trimmed.ends_with(':') {
+        return (rest, Vec::new());
+    }
+    let start = match trimmed.rfind(char::is_whitespace) {
+        Some(i) => i + 1,
+        None => 0,
+    };
+    let candidate = &trimmed[start..];
+    if is_tag_cluster(candidate) {
+        let tags = candidate
+            .split(':')
+            .filter(|s| !s.is_empty())
+            .map(|s| s.to_string())
+            .collect();
+        (&trimmed[..start], tags)
+    } else {
+        (rest, Vec::new())
+    }
+}
+
+/// A `:a:b:c:` cluster: colon-delimited, non-empty tag names, colon-bounded.
+fn is_tag_cluster(s: &str) -> bool {
+    if !s.starts_with(':') || !s.ends_with(':') || s.len() < 3 {
+        return false;
+    }
+    let inner = &s[1..s.len() - 1];
+    !inner.is_empty()
+        && inner.split(':').all(|part| {
+            !part.is_empty()
+                && part
+                    .chars()
+                    .all(|c| c.is_alphanumeric() || matches!(c, '_' | '@' | '#' | '%'))
+        })
+}
+
+// ---------------------------------------------------------------------------
+// Section body: property drawer + block content
+// ---------------------------------------------------------------------------
+
+fn parse_section_body(mut heading: Heading, body: &[&str]) -> (Heading, Vec<Element>) {
+    let mut idx = 0;
+    while idx < body.len() && body[idx].trim().is_empty() {
+        idx += 1;
+    }
+    if idx < body.len() && body[idx].trim().eq_ignore_ascii_case(":PROPERTIES:") {
+        idx += 1;
+        while idx < body.len() {
+            let t = body[idx].trim();
+            if t.eq_ignore_ascii_case(":END:") {
+                idx += 1;
+                break;
+            }
+            if let Some((k, v)) = parse_property(t) {
+                if k.eq_ignore_ascii_case("CUSTOM_ID") {
+                    heading.custom_id = Some(v.clone());
+                } else if k.eq_ignore_ascii_case("ID") {
+                    heading.id = Some(v.clone());
+                }
+                heading.properties.entries.push((k, v));
+            }
+            idx += 1;
+        }
+    }
+    let content = parse_elements(&body[idx..]);
+    (heading, content)
+}
+
+/// `:KEY: value` inside a drawer.
+fn parse_property(line: &str) -> Option<(String, String)> {
+    let line = line.trim();
+    let line = line.strip_prefix(':')?;
+    let end = line.find(':')?;
+    let key = line[..end].trim().to_string();
+    if key.is_empty() {
+        return None;
+    }
+    let value = line[end + 1..].trim().to_string();
+    Some((key, value))
+}
+
+// ---------------------------------------------------------------------------
+// Block-level element builder
+// ---------------------------------------------------------------------------
+
+fn parse_elements(lines: &[&str]) -> Vec<Element> {
+    let mut out = Vec::new();
+    let mut i = 0;
+    while i < lines.len() {
+        let line = lines[i];
+        if line.trim().is_empty() {
+            i += 1;
+            continue;
+        }
+        if let Some((kind, after)) = block_begin(line) {
+            let mut j = i + 1;
+            let mut inner = Vec::new();
+            while j < lines.len() && !is_block_end(lines[j]) {
+                inner.push(lines[j]);
+                j += 1;
+            }
+            let code = inner.join("\n");
+            if kind.eq_ignore_ascii_case("SRC") {
+                let (lang, params) = parse_src_header(&after);
+                out.push(Element::SrcBlock { lang, params, code });
+            } else {
+                // Non-SRC blocks (quote/example/center/export) are kept verbatim for
+                // v0.1 rather than richly modeled — see module scope note.
+                out.push(Element::ExampleBlock(code));
+            }
+            i = if j < lines.len() { j + 1 } else { j };
+            continue;
+        }
+        if is_rule(line) {
+            out.push(Element::HorizontalRule);
+            i += 1;
+            continue;
+        }
+        if let Some((key, value)) = keyword_kv(line) {
+            out.push(Element::Keyword { key, value });
+            i += 1;
+            continue;
+        }
+        if line.trim_start().starts_with('|') {
+            let (table, next) = parse_table(lines, i);
+            out.push(Element::Table(table));
+            i = next;
+            continue;
+        }
+        if let Some((label, first_rest)) = footnote_def_label(line) {
+            let (def, next) = parse_footnote_def(lines, i, label, first_rest);
+            out.push(def);
+            i = next;
+            continue;
+        }
+        if is_list_item(line.trim_start()).is_some() {
+            let (list, next) = parse_list(lines, i);
+            out.push(Element::List(list));
+            i = next;
+            continue;
+        }
+        // Paragraph: gather consecutive soft-wrapped text lines.
+        let mut para = Vec::new();
+        while i < lines.len() {
+            let l = lines[i];
+            if l.trim().is_empty() || is_structural(l) {
+                break;
+            }
+            para.push(l.trim());
+            i += 1;
+        }
+        if !para.is_empty() {
+            out.push(Element::Paragraph(inline(&para.join(" "))));
+        }
+    }
+    out
+}
+
+/// Is this line the start of a non-paragraph construct?
+fn is_structural(line: &str) -> bool {
+    let t = line.trim_start();
+    block_begin(line).is_some()
+        || is_block_end(line)
+        || is_rule(line)
+        || keyword_kv(line).is_some()
+        || is_list_item(t).is_some()
+        || t.starts_with('|')
+        || footnote_def_label(line).is_some()
+        || heading_level(line).is_some()
+}
+
+// ---------------------------------------------------------------------------
+// Tables (spec §1 IN; `#+TBLFM:` formulas are parse-and-ignored via keyword_kv)
+// ---------------------------------------------------------------------------
+
+/// Consume a run of consecutive `|`-prefixed lines into a [`Table`]. Rule rows
+/// (`|---+---|`) are preserved as [`TableRow::Rule`] so the renderer can locate the
+/// header band.
+fn parse_table(lines: &[&str], start: usize) -> (Table, usize) {
+    let mut rows = Vec::new();
+    let mut i = start;
+    while i < lines.len() {
+        let t = lines[i].trim_start();
+        if !t.starts_with('|') {
+            break;
+        }
+        if is_table_rule(t) {
+            rows.push(TableRow::Rule);
+        } else {
+            rows.push(TableRow::Cells(parse_table_cells(t)));
+        }
+        i += 1;
+    }
+    (Table { rows }, i)
+}
+
+/// A rule row: only `|`, `-`, `+`, whitespace, and at least one `-`.
+fn is_table_rule(t: &str) -> bool {
+    t.starts_with('|')
+        && t.contains('-')
+        && t.chars().all(|c| matches!(c, '|' | '-' | '+' | ' '))
+}
+
+fn parse_table_cells(t: &str) -> Vec<Vec<Object>> {
+    let inner = t.trim().trim_start_matches('|').trim_end_matches('|');
+    inner.split('|').map(|cell| inline(cell.trim())).collect()
+}
+
+// ---------------------------------------------------------------------------
+// Footnote definitions (spec §1 IN; inline refs handled in the inline tokenizer)
+// ---------------------------------------------------------------------------
+
+/// A footnote *definition* line: `[fn:LABEL] text...`. Returns the label and the
+/// remainder on the same line. `[fn:LABEL:inline]` (a colon inside the label span) is
+/// an inline reference, not a definition, so it is rejected here.
+fn footnote_def_label(line: &str) -> Option<(String, String)> {
+    let t = line.trim_start();
+    let r = t.strip_prefix("[fn:")?;
+    let end = r.find(']')?;
+    let label = &r[..end];
+    if label.is_empty() || label.contains(':') {
+        return None;
+    }
+    Some((label.to_string(), r[end + 1..].trim_start().to_string()))
+}
+
+/// Gather a footnote definition's content: the remainder of its opening line plus
+/// following continuation lines up to the next blank/structural/definition line.
+fn parse_footnote_def(
+    lines: &[&str],
+    start: usize,
+    label: String,
+    first_rest: String,
+) -> (Element, usize) {
+    let mut parts: Vec<String> = Vec::new();
+    if !first_rest.is_empty() {
+        parts.push(first_rest);
+    }
+    let mut i = start + 1;
+    while i < lines.len() {
+        let l = lines[i];
+        if l.trim().is_empty() || is_structural(l) {
+            break;
+        }
+        parts.push(l.trim().to_string());
+        i += 1;
+    }
+    let content = if parts.is_empty() {
+        Vec::new()
+    } else {
+        vec![Element::Paragraph(inline(&parts.join(" ")))]
+    };
+    (Element::FootnoteDefinition { label, content }, i)
+}
+
+fn parse_list(lines: &[&str], start: usize) -> (List, usize) {
+    let kind = match is_list_item(lines[start].trim_start()) {
+        Some(Bullet::Ordered(_)) => ListKind::Ordered,
+        _ => ListKind::Unordered,
+    };
+    let mut items = Vec::new();
+    let mut i = start;
+    while i < lines.len() {
+        let t = lines[i].trim_start();
+        let bullet = match is_list_item(t) {
+            Some(b) => b,
+            None => break,
+        };
+        let item_kind = match bullet {
+            Bullet::Ordered(_) => ListKind::Ordered,
+            _ => ListKind::Unordered,
+        };
+        if item_kind != kind {
+            break;
+        }
+        let rest = item_body(t, &bullet);
+        let (checkbox, text) = split_checkbox(rest);
+        items.push(ListItem {
+            bullet,
+            checkbox,
+            term: None, // description lists: out of scope for v0.1.
+            content: vec![Element::Paragraph(inline(text.trim()))],
+        });
+        i += 1;
+    }
+    (List { kind, items }, i)
+}
+
+/// Text of a list item after its bullet marker.
+fn item_body<'a>(item: &'a str, bullet: &Bullet) -> &'a str {
+    match bullet {
+        Bullet::Dash | Bullet::Plus => item[1..].trim_start(),
+        Bullet::Ordered(_) => {
+            // Skip digits then the `.`/`)` terminator.
+            let after_digits = item.trim_start_matches(|c: char| c.is_ascii_digit());
+            after_digits
+                .strip_prefix('.')
+                .or_else(|| after_digits.strip_prefix(')'))
+                .unwrap_or(after_digits)
+                .trim_start()
+        }
+    }
+}
+
+/// Detect a leading `[ ]`/`[X]`/`[-]` checkbox on a list item.
+fn split_checkbox(text: &str) -> (Option<Checkbox>, &str) {
+    let bytes = text.as_bytes();
+    if bytes.len() >= 3 && bytes[0] == b'[' && bytes[2] == b']' {
+        let cb = match bytes[1] {
+            b' ' => Some(Checkbox::Off),
+            b'X' | b'x' => Some(Checkbox::On),
+            b'-' => Some(Checkbox::Trans),
+            _ => None,
+        };
+        if let Some(cb) = cb {
+            return (Some(cb), text[3..].trim_start());
+        }
+    }
+    (None, text)
+}
+
+// ---------------------------------------------------------------------------
+// Line predicates / small parsers
+// ---------------------------------------------------------------------------
+
+fn block_begin(line: &str) -> Option<(String, String)> {
+    let t = line.trim_start();
+    let upper = t.to_ascii_uppercase();
+    let rest_upper = upper.strip_prefix("#+BEGIN_")?;
+    let kind_len = rest_upper
+        .find(char::is_whitespace)
+        .unwrap_or(rest_upper.len());
+    // Index back into the original-case string past "#+BEGIN_".
+    let base = t.len() - rest_upper.len();
+    let kind = t[base..base + kind_len].to_string();
+    let after = t[base + kind_len..].trim().to_string();
+    Some((kind, after))
+}
+
+fn is_block_end(line: &str) -> bool {
+    line.trim_start().to_ascii_uppercase().starts_with("#+END_")
+}
+
+fn parse_src_header(after: &str) -> (Option<String>, BlockParams) {
+    let mut parts = after.splitn(2, char::is_whitespace);
+    let lang = parts.next().filter(|s| !s.is_empty()).map(|s| s.to_string());
+    let params = BlockParams {
+        raw: parts.next().unwrap_or("").trim().to_string(),
+    };
+    (lang, params)
+}
+
+/// `#+KEY: value`, excluding `#+BEGIN_`/`#+END_` block delimiters.
+fn keyword_kv(line: &str) -> Option<(String, String)> {
+    let t = line.trim_start();
+    let rest = t.strip_prefix("#+")?;
+    if rest.to_ascii_uppercase().starts_with("BEGIN_")
+        || rest.to_ascii_uppercase().starts_with("END_")
+    {
+        return None;
+    }
+    let colon = rest.find(':')?;
+    let key = rest[..colon].trim().to_string();
+    if key.is_empty() {
+        return None;
+    }
+    let value = rest[colon + 1..].trim().to_string();
+    Some((key, value))
+}
+
+fn is_rule(line: &str) -> bool {
+    let t = line.trim();
+    t.len() >= 5 && t.chars().all(|c| c == '-')
+}
+
+fn is_drawer_begin(t: &str) -> bool {
+    if !t.starts_with(':') || !t.ends_with(':') || t.len() < 3 {
+        return false;
+    }
+    let inner = &t[1..t.len() - 1];
+    !inner.is_empty()
+        && inner
+            .chars()
+            .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
+}
+
+/// If `t` (already left-trimmed) begins a list item, return its bullet.
+fn is_list_item(t: &str) -> Option<Bullet> {
+    let bytes = t.as_bytes();
+    if bytes.is_empty() {
+        return None;
+    }
+    if (bytes[0] == b'-' || bytes[0] == b'+')
+        && (bytes.len() == 1 || bytes[1] == b' ')
+    {
+        return Some(if bytes[0] == b'-' {
+            Bullet::Dash
+        } else {
+            Bullet::Plus
+        });
+    }
+    let digits: String = t.chars().take_while(|c| c.is_ascii_digit()).collect();
+    if !digits.is_empty() {
+        let after = &t[digits.len()..];
+        if (after.starts_with('.') || after.starts_with(')'))
+            && (after.len() == 1 || after.as_bytes()[1] == b' ')
+        {
+            if let Ok(n) = digits.parse::<u32>() {
+                return Some(Bullet::Ordered(n));
+            }
+        }
+    }
+    None
+}
+
+// ---------------------------------------------------------------------------
+// Inline tokenizer (spec §3.1, R3)
+// ---------------------------------------------------------------------------
+
+fn parse_inline_run(chars: &[char]) -> Vec<Object> {
+    let mut out = Vec::new();
+    let mut buf = String::new();
+    let mut i = 0;
+    let n = chars.len();
+    while i < n {
+        let c = chars[i];
+        if c == '[' && starts_with_at(chars, i, "[fn:") {
+            if let Some((obj, next)) = try_footnote_ref(chars, i) {
+                flush(&mut buf, &mut out);
+                out.push(obj);
+                i = next;
+                continue;
+            }
+        }
+        if c == '[' && i + 1 < n && chars[i + 1] == '[' {
+            if let Some((obj, next)) = try_link(chars, i) {
+                flush(&mut buf, &mut out);
+                out.push(obj);
+                i = next;
+                continue;
+            }
+        }
+        if is_scheme_start(chars, i) && boundary_before(chars, i) {
+            if let Some((obj, next)) = try_bare_url(chars, i) {
+                flush(&mut buf, &mut out);
+                out.push(obj);
+                i = next;
+                continue;
+            }
+        }
+        if is_marker(c) {
+            if let Some((obj, next)) = try_emphasis(chars, i) {
+                flush(&mut buf, &mut out);
+                out.push(obj);
+                i = next;
+                continue;
+            }
+        }
+        buf.push(c);
+        i += 1;
+    }
+    flush(&mut buf, &mut out);
+    out
+}
+
+fn flush(buf: &mut String, out: &mut Vec<Object>) {
+    if !buf.is_empty() {
+        out.push(Object::Text(std::mem::take(buf)));
+    }
+}
+
+fn starts_with_at(chars: &[char], i: usize, needle: &str) -> bool {
+    let n: Vec<char> = needle.chars().collect();
+    i + n.len() <= chars.len() && chars[i..i + n.len()] == n[..]
+}
+
+/// A footnote reference: `[fn:LABEL]` (referenced) or `[fn:LABEL:text]` (inline
+/// definition). Anonymous inline footnotes `[fn::text]` carry an empty label.
+fn try_footnote_ref(chars: &[char], i: usize) -> Option<(Object, usize)> {
+    let n = chars.len();
+    let close = (i + 1..n).find(|&k| chars[k] == ']')?;
+    let inner: String = chars[i + 1..close].iter().collect();
+    let rest = inner.strip_prefix("fn:")?;
+    let (label, inline_objs) = match rest.split_once(':') {
+        Some((l, txt)) => {
+            let txt_chars: Vec<char> = txt.chars().collect();
+            (l.to_string(), Some(parse_inline_run(&txt_chars)))
+        }
+        None => (rest.to_string(), None),
+    };
+    if label.is_empty() && inline_objs.is_none() {
+        return None;
+    }
+    Some((
+        Object::FootnoteRef {
+            label,
+            inline: inline_objs,
+        },
+        close + 1,
+    ))
+}
+
+/// `[[target]]` or `[[target][description]]`.
+fn try_link(chars: &[char], i: usize) -> Option<(Object, usize)> {
+    let n = chars.len();
+    let mut j = i + 2;
+    while j + 1 < n {
+        if chars[j] == ']' && chars[j + 1] == ']' {
+            let inner = &chars[i + 2..j];
+            let (target_str, desc) = split_link_inner(inner);
+            let target = parse_target(&target_str);
+            let description = desc.map(|d| parse_inline_run(&d));
+            return Some((Object::Link(Link { target, description }), j + 2));
+        }
+        j += 1;
+    }
+    None
+}
+
+/// Split `target][desc` into its two halves at the first `][`.
+fn split_link_inner(inner: &[char]) -> (String, Option<Vec<char>>) {
+    for k in 0..inner.len().saturating_sub(1) {
+        if inner[k] == ']' && inner[k + 1] == '[' {
+            let target: String = inner[..k].iter().collect();
+            let desc: Vec<char> = inner[k + 2..].to_vec();
+            return (target, Some(desc));
+        }
+    }
+    (inner.iter().collect(), None)
+}
+
+fn parse_target(s: &str) -> LinkTarget {
+    if let Some(r) = s.strip_prefix('#') {
+        LinkTarget::CustomId(r.to_string())
+    } else if let Some(r) = s.strip_prefix("id:") {
+        LinkTarget::Id(r.to_string())
+    } else if let Some(r) = s.strip_prefix('*') {
+        LinkTarget::Heading(r.to_string())
+    } else if let Some(r) = s.strip_prefix("file:") {
+        LinkTarget::File {
+            path: r.into(),
+            search: None,
+        }
+    } else if is_external_scheme(s) {
+        LinkTarget::External(s.to_string())
+    } else {
+        LinkTarget::File {
+            path: s.into(),
+            search: None,
+        }
+    }
+}
+
+fn is_external_scheme(s: &str) -> bool {
+    let s = s.to_ascii_lowercase();
+    ["http://", "https://", "mailto:", "ftp://", "news:", "tel:"]
+        .iter()
+        .any(|p| s.starts_with(p))
+}
+
+fn is_scheme_start(chars: &[char], i: usize) -> bool {
+    let tail: String = chars[i..].iter().take(8).collect();
+    let tail = tail.to_ascii_lowercase();
+    tail.starts_with("http://") || tail.starts_with("https://") || tail.starts_with("mailto:")
+}
+
+/// A bare URL in running text, e.g. `https://example.com`.
+fn try_bare_url(chars: &[char], i: usize) -> Option<(Object, usize)> {
+    let n = chars.len();
+    let mut j = i;
+    while j < n {
+        let c = chars[j];
+        if c.is_whitespace() || matches!(c, '<' | '>' | '[' | ']' | '"' | '{' | '}') {
+            break;
+        }
+        j += 1;
+    }
+    // Trim trailing sentence punctuation that is unlikely to be part of the URL.
+    while j > i && matches!(chars[j - 1], '.' | ',' | ';' | ':' | '!' | '?' | ')') {
+        j -= 1;
+    }
+    if j <= i {
+        return None;
+    }
+    let url: String = chars[i..j].iter().collect();
+    Some((
+        Object::Link(Link {
+            target: LinkTarget::External(url),
+            description: None,
+        }),
+        j,
+    ))
+}
+
+fn is_marker(c: char) -> bool {
+    matches!(c, '*' | '/' | '_' | '+' | '=' | '~')
+}
+
+fn pre_ok(prev: Option<char>) -> bool {
+    match prev {
+        None => true,
+        Some(c) => c.is_whitespace() || matches!(c, '-' | '(' | '{' | '\'' | '"'),
+    }
+}
+
+fn post_ok(next: Option<char>) -> bool {
+    match next {
+        None => true,
+        Some(c) => {
+            c.is_whitespace() || matches!(c, '-' | '.' | ',' | ';' | ':' | '!' | '?' | ')' | '}' | '[' | '"' | '\'')
+        }
+    }
+}
+
+/// Org emphasis with pre/post-char boundary rules. `=`/`~` carry literal content.
+fn try_emphasis(chars: &[char], i: usize) -> Option<(Object, usize)> {
+    let n = chars.len();
+    let m = chars[i];
+    let prev = if i == 0 { None } else { Some(chars[i - 1]) };
+    if !pre_ok(prev) {
+        return None;
+    }
+    if i + 1 >= n {
+        return None;
+    }
+    let after = chars[i + 1];
+    if after.is_whitespace() || after == m {
+        return None;
+    }
+    let mut j = i + 1;
+    while j < n {
+        if chars[j] == m && j > i + 1 {
+            let before = chars[j - 1];
+            let next = chars.get(j + 1).copied();
+            if !before.is_whitespace() && post_ok(next) {
+                let inner = &chars[i + 1..j];
+                let obj = match m {
+                    '=' => Object::Verbatim(inner.iter().collect()),
+                    '~' => Object::Code(inner.iter().collect()),
+                    '*' => Object::Bold(parse_inline_run(inner)),
+                    '/' => Object::Italic(parse_inline_run(inner)),
+                    '_' => Object::Underline(parse_inline_run(inner)),
+                    '+' => Object::StrikeThrough(parse_inline_run(inner)),
+                    _ => unreachable!(),
+                };
+                return Some((obj, j + 1));
+            }
+        }
+        j += 1;
+    }
+    None
+}
+
+fn boundary_before(chars: &[char], i: usize) -> bool {
+    if i == 0 {
+        return true;
+    }
+    let c = chars[i - 1];
+    c.is_whitespace() || matches!(c, '(' | '[' | '{' | '<' | '"' | '\'')
+}
diff --git a/src/render.rs b/src/render.rs
new file mode 100644
index 0000000..1b00f53
--- /dev/null
+++ b/src/render.rs
@@ -0,0 +1,340 @@
+//! RENDER stage (spec §2.1, §2.4): resolved element tree → HTML fragment.
+//!
+//! A tree walk emitting HTML into a buffer. Two sub-concerns get care (spec §2.4):
+//! 1. Footnotes use a two-pass layout — definitions are collected up front, references
+//!    are numbered in order of first appearance during the walk, and a back-linked
+//!    notes section is emitted at page end.
+//! 2. Syntax highlighting happens HERE, not at parse time — it is an output concern
+//!    and its cost must be cache-skippable (spec §4.2). Emit CSS classes, not inline
+//!    styles, so themes live in the stylesheet (spec §3.2).
+//!
+//! v0.2 renders: headings (always anchored, with tags), paragraphs, plain lists
+//! (unordered/ordered + checkboxes), source/example blocks, horizontal rules, tables
+//! (with header band from the rule row), footnotes, and inline markup. Real syntect
+//! tokenizing remains a `<pre><code>` passthrough for now (see [`SyntectHighlighter`]).
+
+use std::collections::HashMap;
+
+use crate::model::{Checkbox, Element, LinkTarget, ListKind, Object, Section, TableRow};
+use crate::resolve::ResolvedDoc;
+use crate::util::{plain_text, slugify};
+
+/// A rendered HTML fragment (content only — no page chrome; spec §2.4).
+#[derive(Debug, Clone)]
+pub struct Html(pub String);
+
+/// Pluggable highlighter so tree-sitter can replace syntect per-language later
+/// without touching the renderer (spec §3.2, R6).
+pub trait Highlighter {
+    fn highlight(&self, code: &str, lang: Option<&str>) -> Html;
+}
+
+/// Default v1 highlighter. For now this is a plain `<pre><code>` passthrough that
+/// escapes the code and tags it with a `language-*` class; real syntect tokenizing
+/// to CSS-class spans is deferred (spec §3.2, §4.2).
+pub struct SyntectHighlighter;
+
+impl Highlighter for SyntectHighlighter {
+    fn highlight(&self, code: &str, lang: Option<&str>) -> Html {
+        let class = match lang {
+            Some(l) => format!(" class=\"language-{}\"", escape_attr(l)),
+            None => String::new(),
+        };
+        Html(format!(
+            "<pre><code{}>{}</code></pre>\n",
+            class,
+            escape_html(code)
+        ))
+    }
+}
+
+/// Carries the highlighter plus the footnote collector across the tree walk (spec §2.4).
+struct Renderer<'a> {
+    hl: &'a dyn Highlighter,
+    /// Block footnote definitions, keyed by label (collected before the walk).
+    block_defs: HashMap<String, Vec<Element>>,
+    /// Inline footnote definitions discovered at reference sites.
+    inline_defs: HashMap<String, Vec<Object>>,
+    /// Reference keys in order of first appearance — drives numbering and note order.
+    order: Vec<String>,
+}
+
+/// Render a resolved document to an HTML fragment.
+pub fn render(doc: &ResolvedDoc, highlighter: &dyn Highlighter) -> Html {
+    let mut r = Renderer {
+        hl: highlighter,
+        block_defs: HashMap::new(),
+        inline_defs: HashMap::new(),
+        order: Vec::new(),
+    };
+    r.collect_defs(&doc.document.root);
+    let mut out = String::new();
+    r.render_section(&doc.document.root, &mut out);
+    r.emit_footnotes(&mut out);
+    Html(out)
+}
+
+impl Renderer<'_> {
+    /// First footnote pass: gather every block definition in the tree by label.
+    fn collect_defs(&mut self, section: &Section) {
+        collect_defs_in(&section.content, &mut self.block_defs);
+        for child in &section.children {
+            self.collect_defs(child);
+        }
+    }
+
+    fn render_section(&mut self, section: &Section, out: &mut String) {
+        if let Some(h) = &section.heading {
+            let level = h.level.clamp(1, 6);
+            let anchor = h
+                .custom_id
+                .clone()
+                .or_else(|| h.id.clone())
+                .unwrap_or_else(|| slugify(&plain_text(&h.title)));
+            out.push_str(&format!("<h{} id=\"{}\">", level, escape_attr(&anchor)));
+            self.render_objects(&h.title, out);
+            for tag in &h.tags {
+                out.push_str(&format!(" <span class=\"tag\">{}</span>", escape_html(tag)));
+            }
+            out.push_str(&format!("</h{}>\n", level));
+        }
+        for element in &section.content {
+            self.render_element(element, out);
+        }
+        for child in &section.children {
+            self.render_section(child, out);
+        }
+    }
+
+    fn render_element(&mut self, element: &Element, out: &mut String) {
+        match element {
+            Element::Paragraph(objs) => {
+                out.push_str("<p>");
+                self.render_objects(objs, out);
+                out.push_str("</p>\n");
+            }
+            Element::List(list) => {
+                let tag = match list.kind {
+                    ListKind::Ordered => "ol",
+                    _ => "ul",
+                };
+                out.push_str(&format!("<{}>\n", tag));
+                for item in &list.items {
+                    out.push_str("<li>");
+                    if let Some(cb) = &item.checkbox {
+                        let checked = matches!(cb, Checkbox::On);
+                        out.push_str(&format!(
+                            "<input type=\"checkbox\" disabled{}> ",
+                            if checked { " checked" } else { "" }
+                        ));
+                    }
+                    match item.content.as_slice() {
+                        [Element::Paragraph(objs)] => self.render_objects(objs, out),
+                        els => {
+                            for el in els {
+                                self.render_element(el, out);
+                            }
+                        }
+                    }
+                    out.push_str("</li>\n");
+                }
+                out.push_str(&format!("</{}>\n", tag));
+            }
+            Element::Table(table) => self.render_table(table, out),
+            Element::SrcBlock { lang, code, .. } => {
+                let Html(h) = self.hl.highlight(code, lang.as_deref());
+                out.push_str(&h);
+            }
+            Element::ExampleBlock(code) => {
+                out.push_str(&format!("<pre>{}</pre>\n", escape_html(code)));
+            }
+            Element::HorizontalRule => out.push_str("<hr>\n"),
+            // Definitions are emitted in the footnotes section, not inline.
+            Element::FootnoteDefinition { .. } => {}
+            // Out of scope (non-HTML export, generic drawers, stray keywords, comments):
+            // emitted as nothing rather than crashing.
+            _ => {}
+        }
+    }
+
+    /// Rows before the first rule row become the `<thead>`; the rest are the `<tbody>`.
+    fn render_table(&mut self, table: &crate::model::Table, out: &mut String) {
+        let rule_at = table
+            .rows
+            .iter()
+            .position(|r| matches!(r, TableRow::Rule));
+        out.push_str("<table>\n");
+        let mut wrote_body = false;
+        let mut in_body = rule_at.is_none();
+        for (idx, row) in table.rows.iter().enumerate() {
+            match row {
+                TableRow::Rule => {
+                    if Some(idx) == rule_at {
+                        in_body = true;
+                    }
+                    continue;
+                }
+                TableRow::Cells(cells) => {
+                    let (open, cell_tag) = if in_body {
+                        if !wrote_body {
+                            wrote_body = true;
+                            ("<tbody>\n<tr>", "td")
+                        } else {
+                            ("<tr>", "td")
+                        }
+                    } else {
+                        ("<thead>\n<tr>", "th")
+                    };
+                    out.push_str(open);
+                    for cell in cells {
+                        out.push_str(&format!("<{}>", cell_tag));
+                        self.render_objects(cell, out);
+                        out.push_str(&format!("</{}>", cell_tag));
+                    }
+                    out.push_str("</tr>\n");
+                    // Close the header band right after its last row.
+                    if !in_body
+                        && rule_at.map(|r| idx + 1 == r).unwrap_or(false)
+                    {
+                        out.push_str("</thead>\n");
+                    }
+                }
+            }
+        }
+        if wrote_body {
+            out.push_str("</tbody>\n");
+        }
+        out.push_str("</table>\n");
+    }
+
+    fn render_objects(&mut self, objs: &[Object], out: &mut String) {
+        for obj in objs {
+            self.render_object(obj, out);
+        }
+    }
+
+    fn render_object(&mut self, obj: &Object, out: &mut String) {
+        match obj {
+            Object::Text(t) => out.push_str(&escape_html(t)),
+            Object::Bold(inner) => self.wrap(out, "strong", inner),
+            Object::Italic(inner) => self.wrap(out, "em", inner),
+            Object::Underline(inner) => self.wrap(out, "u", inner),
+            Object::StrikeThrough(inner) => self.wrap(out, "del", inner),
+            Object::Verbatim(s) => {
+                out.push_str(&format!("<code class=\"verbatim\">{}</code>", escape_html(s)))
+            }
+            Object::Code(s) => out.push_str(&format!("<code>{}</code>", escape_html(s))),
+            Object::Link(link) => {
+                let href = link_href(&link.target);
+                out.push_str(&format!("<a href=\"{}\">", escape_attr(&href)));
+                match &link.description {
+                    Some(desc) => self.render_objects(desc, out),
+                    None => out.push_str(&escape_html(&link_text(&link.target))),
+                }
+                out.push_str("</a>");
+            }
+            Object::FootnoteRef { label, inline } => {
+                let key = if label.is_empty() {
+                    format!("__anon{}", self.order.len() + 1)
+                } else {
+                    label.clone()
+                };
+                if let Some(objs) = inline {
+                    self.inline_defs.insert(key.clone(), objs.clone());
+                }
+                if !self.order.contains(&key) {
+                    self.order.push(key.clone());
+                }
+                let num = self.order.iter().position(|l| l == &key).unwrap() + 1;
+                out.push_str(&format!(
+                    "<sup class=\"footnote-ref\"><a id=\"fnr-{n}\" href=\"#fn-{n}\">{n}</a></sup>",
+                    n = num
+                ));
+            }
+            Object::LineBreak => out.push_str("<br>\n"),
+            // Timestamps, entities: out of scope for now.
+            _ => {}
+        }
+    }
+
+    fn wrap(&mut self, out: &mut String, tag: &str, inner: &[Object]) {
+        out.push_str(&format!("<{}>", tag));
+        self.render_objects(inner, out);
+        out.push_str(&format!("</{}>", tag));
+    }
+
+    /// Second footnote pass: emit the numbered, back-linked notes section (spec §2.4).
+    fn emit_footnotes(&mut self, out: &mut String) {
+        if self.order.is_empty() {
+            return;
+        }
+        let order = self.order.clone();
+        let inline_defs = self.inline_defs.clone();
+        let block_defs = self.block_defs.clone();
+        out.push_str("<section class=\"footnotes\">\n<hr>\n<ol>\n");
+        for (idx, label) in order.iter().enumerate() {
+            let n = idx + 1;
+            out.push_str(&format!("<li id=\"fn-{n}\">"));
+            if let Some(objs) = inline_defs.get(label) {
+                self.render_objects(objs, out);
+            } else if let Some(els) = block_defs.get(label) {
+                for el in els {
+                    self.render_element(el, out);
+                }
+            }
+            out.push_str(&format!(
+                " <a class=\"footnote-back\" href=\"#fnr-{n}\">&#8617;</a></li>\n"
+            ));
+        }
+        out.push_str("</ol>\n</section>\n");
+    }
+}
+
+fn collect_defs_in(elements: &[Element], defs: &mut HashMap<String, Vec<Element>>) {
+    for el in elements {
+        if let Element::FootnoteDefinition { label, content } = el {
+            defs.entry(label.clone()).or_insert_with(|| content.clone());
+        }
+    }
+}
+
+/// Best-effort URL for a link target. After RESOLVE, internal targets have been
+/// rewritten to `External` with their final URL; anything still internal here is an
+/// unresolved link, rendered to a plausible anchor so the page stays self-consistent.
+fn link_href(target: &LinkTarget) -> String {
+    match target {
+        LinkTarget::External(s) => s.clone(),
+        LinkTarget::CustomId(id) => format!("#{}", id),
+        LinkTarget::Id(id) => format!("#{}", id),
+        LinkTarget::Heading(text) => format!("#{}", slugify(text)),
+        LinkTarget::File { path, .. } => path.to_string(),
+    }
+}
+
+fn link_text(target: &LinkTarget) -> String {
+    match target {
+        LinkTarget::External(s) => s.clone(),
+        LinkTarget::CustomId(id) | LinkTarget::Id(id) => id.clone(),
+        LinkTarget::Heading(text) => text.clone(),
+        LinkTarget::File { path, .. } => path.to_string(),
+    }
+}
+
+fn escape_html(s: &str) -> String {
+    let mut out = String::with_capacity(s.len());
+    for c in s.chars() {
+        match c {
+            '&' => out.push_str("&amp;"),
+            '<' => out.push_str("&lt;"),
+            '>' => out.push_str("&gt;"),
+            _ => out.push(c),
+        }
+    }
+    out
+}
+
+fn escape_attr(s: &str) -> String {
+    let mut out = escape_html(s);
+    out = out.replace('"', "&quot;");
+    out
+}
diff --git a/src/resolve.rs b/src/resolve.rs
new file mode 100644
index 0000000..4120a5d
--- /dev/null
+++ b/src/resolve.rs
@@ -0,0 +1,173 @@
+//! RESOLVE stage (spec §2.1, §4.3): rewrite `LinkTarget`s to final URLs using the
+//! symbol table, AND record which targets each page consumed.
+//!
+//! Critical invariant (spec §4.3, R2): RESOLVE returns the used-target list as a
+//! first-class side output from v1, even before incrementality consumes it. Throwing
+//! it away would make renamed-heading invalidation impossible to compute later without
+//! re-resolving everything.
+//!
+//! Resolution rewrites each internal link into an [`crate::model::LinkTarget::External`]
+//! carrying its final URL, so the renderer needs no symbol-table knowledge. Unresolved
+//! links are left untouched (the renderer falls back to a best-effort anchor) and
+//! reported as [`BrokenLink`] warnings (spec §4.3.4).
+
+use camino::Utf8Path;
+
+use crate::index::{SymbolTable, TargetId};
+use crate::model::{Document, Element, Link, LinkTarget, Object, Section, TableRow};
+use crate::util::{normalize_link_path, output_url};
+
+/// A document whose links have been rewritten to concrete URLs.
+#[derive(Debug, Clone)]
+pub struct ResolvedDoc {
+    pub document: Document,
+}
+
+/// A broken internal link — surfaced as a warning, or an error under `--strict` (spec §4.3.4).
+#[derive(Debug, Clone)]
+pub struct BrokenLink {
+    pub target: TargetId,
+}
+
+/// Result of resolving one page. `used_targets` are the "uses" edges (spec §4.3).
+#[derive(Debug)]
+pub struct ResolveOutput {
+    pub resolved: ResolvedDoc,
+    /// The "uses" edges — MUST be captured from day one (spec §4.3, R2).
+    pub used_targets: Vec<TargetId>,
+    pub broken: Vec<BrokenLink>,
+}
+
+/// `resolve(doc, &SymbolTable) -> (ResolvedDoc, Vec<TargetId used>)` (spec §4.3).
+pub fn resolve(doc: &Document, symbols: &SymbolTable) -> ResolveOutput {
+    let mut document = doc.clone();
+    let from = doc.source_path.clone();
+    let mut used = Vec::new();
+    let mut broken = Vec::new();
+    let mut cx = Cx {
+        from: &from,
+        symbols,
+        used: &mut used,
+        broken: &mut broken,
+    };
+    cx.section(&mut document.root);
+    ResolveOutput {
+        resolved: ResolvedDoc { document },
+        used_targets: used,
+        broken,
+    }
+}
+
+/// The text a description-less internal link should display once its target becomes a URL.
+fn human_text(target: &LinkTarget) -> Option<String> {
+    match target {
+        LinkTarget::Heading(t) => Some(t.clone()),
+        LinkTarget::CustomId(id) | LinkTarget::Id(id) => Some(id.clone()),
+        LinkTarget::File { path, .. } => Some(path.to_string()),
+        LinkTarget::External(_) => None,
+    }
+}
+
+struct Cx<'a> {
+    from: &'a Utf8Path,
+    symbols: &'a SymbolTable,
+    used: &'a mut Vec<TargetId>,
+    broken: &'a mut Vec<BrokenLink>,
+}
+
+impl Cx<'_> {
+    fn section(&mut self, section: &mut Section) {
+        if let Some(h) = &mut section.heading {
+            self.objects(&mut h.title);
+        }
+        for el in &mut section.content {
+            self.element(el);
+        }
+        for child in &mut section.children {
+            self.section(child);
+        }
+    }
+
+    fn element(&mut self, el: &mut Element) {
+        match el {
+            Element::Paragraph(objs) => self.objects(objs),
+            Element::List(list) => {
+                for item in &mut list.items {
+                    if let Some(term) = &mut item.term {
+                        self.objects(term);
+                    }
+                    for e in &mut item.content {
+                        self.element(e);
+                    }
+                }
+            }
+            Element::Table(table) => {
+                for row in &mut table.rows {
+                    if let TableRow::Cells(cells) = row {
+                        for cell in cells {
+                            self.objects(cell);
+                        }
+                    }
+                }
+            }
+            Element::QuoteBlock(inner)
+            | Element::CenterBlock(inner)
+            | Element::Drawer { content: inner, .. }
+            | Element::FootnoteDefinition { content: inner, .. } => {
+                for e in inner {
+                    self.element(e);
+                }
+            }
+            _ => {}
+        }
+    }
+
+    fn objects(&mut self, objs: &mut [Object]) {
+        for obj in objs {
+            match obj {
+                Object::Link(link) => self.link(link),
+                Object::Bold(i)
+                | Object::Italic(i)
+                | Object::Underline(i)
+                | Object::StrikeThrough(i) => self.objects(i),
+                Object::FootnoteRef {
+                    inline: Some(i), ..
+                } => self.objects(i),
+                _ => {}
+            }
+        }
+    }
+
+    fn link(&mut self, link: &mut Link) {
+        if let Some(desc) = &mut link.description {
+            self.objects(desc);
+        }
+        let tid = match &link.target {
+            LinkTarget::External(_) => return,
+            LinkTarget::CustomId(id) => TargetId::CustomId(id.clone()),
+            LinkTarget::Id(id) => TargetId::Id(id.clone()),
+            LinkTarget::Heading(t) => TargetId::Heading(t.clone()),
+            LinkTarget::File { path, .. } => {
+                TargetId::File(normalize_link_path(self.from, path))
+            }
+        };
+        match self.symbols.targets.get(&tid) {
+            Some(loc) => {
+                self.used.push(tid.clone());
+                // Preserve the human-readable text before the target becomes a bare URL,
+                // so a description-less `[[*Heading]]` still renders as the heading text.
+                if link.description.is_none() {
+                    if let Some(text) = human_text(&link.target) {
+                        link.description = Some(vec![Object::Text(text)]);
+                    }
+                }
+                let url = output_url(self.from, &loc.source_path, loc.anchor.as_deref());
+                link.target = LinkTarget::External(url);
+            }
+            None => {
+                self.broken.push(BrokenLink { target: tid });
+                // Leave the original target for the renderer's best-effort fallback.
+            }
+        }
+    }
+}
diff --git a/src/site.rs b/src/site.rs
new file mode 100644
index 0000000..9567a75
--- /dev/null
+++ b/src/site.rs
@@ -0,0 +1,420 @@
+//! Site build: walk a source directory, PARSE every `.org` file, INDEX their targets,
+//! then RESOLVE + RENDER + TEMPLATE each page into a linked static site, copying
+//! non-`.org` assets through unchanged (spec §2.1 DISCOVER…EMIT).
+//!
+//! v0.3 wires in the incremental layer (spec §4, [`crate::incremental`]): a persisted
+//! cache manifest lets a rebuild re-render only the pages whose composed `render_key`
+//! changed, plus the pages that *link into* a changed file's targets (the dependency
+//! graph, spec §4.3). Unchanged pages keep their existing on-disk output untouched.
+//! `--no-cache` forces a full rebuild; the cache is never a correctness dependency, so a
+//! full rebuild and an incremental rebuild produce byte-identical output.
+
+use std::collections::HashSet;
+use std::fs;
+
+use anyhow::{Context, Result};
+use camino::{Utf8Path, Utf8PathBuf};
+use walkdir::WalkDir;
+
+use crate::incremental::{
+    self, combine, config_hash, render_key, resolved_links_hash, site_structure_hash,
+    template_hash, BuildConfig, DepGraph, Hash, Manifest, PageRecord, CACHE_FORMAT_VERSION,
+};
+use crate::index::{document_targets, SymbolTable, TargetId};
+use crate::model::{ContentHash, Document};
+use crate::parser::parse;
+use crate::render::{render, Html, SyntectHighlighter};
+use crate::resolve::resolve;
+use crate::template::{template_sources, NavItem, Templater};
+use crate::util::output_url;
+
+/// A fully built page: source and output paths (relative to their roots) and its
+/// final templated HTML.
+#[derive(Debug, Clone)]
+pub struct BuiltPage {
+    pub source: Utf8PathBuf,
+    pub output: Utf8PathBuf,
+    pub title: String,
+    pub html: String,
+}
+
+/// Unresolved internal links found during a build: `(page, target)` (spec §4.3.4).
+pub type BrokenLinks = Vec<(Utf8PathBuf, TargetId)>;
+
+/// Options controlling a site build.
+#[derive(Debug, Clone, Default)]
+pub struct BuildOptions {
+    /// Bypass the incremental cache and re-render every page (spec §4.5).
+    pub no_cache: bool,
+    /// Treat broken internal links as a build error rather than a warning (spec §4.3.4).
+    pub strict: bool,
+}
+
+/// Summary of a site build.
+#[derive(Debug, Default)]
+pub struct SiteReport {
+    /// Every output page (rendered this build or reused from cache).
+    pub pages: Vec<Utf8PathBuf>,
+    /// Pages actually re-rendered and written this build (the invalidation set).
+    pub rendered: Vec<Utf8PathBuf>,
+    /// Pages whose existing on-disk output was reused unchanged (spec §4.1 skip rule).
+    pub skipped: Vec<Utf8PathBuf>,
+    pub assets: Vec<Utf8PathBuf>,
+    /// Unresolved internal links: `(page, target)`. Warnings, not failures (spec §4.3.4).
+    pub broken: Vec<(Utf8PathBuf, TargetId)>,
+}
+
+/// Everything a build needs about one page *before* the decision to render it: its
+/// hashes, its resolved element tree, and the dependency edges it participates in.
+struct PagePrep {
+    source: Utf8PathBuf,
+    output: Utf8PathBuf,
+    title: String,
+    content_hash: ContentHash,
+    resolved: crate::resolve::ResolvedDoc,
+    used: HashSet<TargetId>,
+    defines: HashSet<TargetId>,
+    broken: Vec<TargetId>,
+    nav: Vec<NavItem>,
+}
+
+/// DISCOVER + PARSE + INDEX + RESOLVE the whole site, returning per-page prep and the
+/// global symbol table. RENDER/TEMPLATE is deferred to the caller so the incremental
+/// build can render only the pages it must. PARSE/INDEX/RESOLVE are cheap and pure, so
+/// they run for every file each build; the incremental win is on RENDER + EMIT (spec §4.4).
+fn prepare_pages(src: &Utf8Path) -> Result<(Vec<PagePrep>, SymbolTable)> {
+    let (org_rel, _assets) = discover(src)?;
+
+    // PARSE every file (relative paths keep snapshots and links machine-independent).
+    let mut docs: Vec<Document> = Vec::new();
+    for rel in &org_rel {
+        let abs = src.join(rel);
+        let source = fs::read_to_string(&abs).with_context(|| format!("reading {abs}"))?;
+        let doc = parse(rel.as_path(), &source).with_context(|| format!("parsing {rel}"))?;
+        docs.push(doc);
+    }
+
+    // INDEX: collect every link target across the corpus.
+    let mut symbols = SymbolTable::new();
+    for doc in &docs {
+        symbols.index_document(doc);
+    }
+
+    // Nav is global; titles come from #+TITLE (falling back to the file stem).
+    let entries: Vec<(Utf8PathBuf, String)> = docs
+        .iter()
+        .map(|d| (d.source_path.clone(), page_title(d)))
+        .collect();
+
+    let mut pages = Vec::new();
+    for doc in &docs {
+        let out = resolve(doc, &symbols);
+        let used: HashSet<TargetId> = out.used_targets.iter().cloned().collect();
+        let broken: Vec<TargetId> = out.broken.iter().map(|b| b.target.clone()).collect();
+        let defines: HashSet<TargetId> = document_targets(doc).into_iter().collect();
+
+        // Nav links are relative to *this* page (spec URL scheme, §8 Q3).
+        let nav: Vec<NavItem> = entries
+            .iter()
+            .map(|(path, title)| NavItem {
+                title: title.clone(),
+                url: output_url(&doc.source_path, path, None),
+            })
+            .collect();
+
+        pages.push(PagePrep {
+            source: doc.source_path.clone(),
+            output: doc.source_path.with_extension("html"),
+            title: page_title(doc),
+            content_hash: doc.content_hash,
+            resolved: out.resolved,
+            used,
+            defines,
+            broken,
+            nav,
+        });
+    }
+
+    Ok((pages, symbols))
+}
+
+/// Parse + index + resolve + render + template a whole site *in memory*, without
+/// touching the output directory. Shared by the tests (full render, every page).
+pub fn render_site(src: &Utf8Path) -> Result<(Vec<BuiltPage>, BrokenLinks)> {
+    let (preps, _symbols) = prepare_pages(src)?;
+    let highlighter = SyntectHighlighter;
+    let templater = Templater::new();
+
+    let mut pages = Vec::new();
+    let mut broken = Vec::new();
+    for p in &preps {
+        for t in &p.broken {
+            broken.push((p.source.clone(), t.clone()));
+        }
+        let html = render_page(&templater, &highlighter, p)?;
+        pages.push(BuiltPage {
+            source: p.source.clone(),
+            output: p.output.clone(),
+            title: p.title.clone(),
+            html,
+        });
+    }
+    Ok((pages, broken))
+}
+
+/// RENDER + TEMPLATE one prepared page into its final HTML string.
+fn render_page(
+    templater: &Templater,
+    highlighter: &SyntectHighlighter,
+    p: &PagePrep,
+) -> Result<String> {
+    let Html(fragment) = render(&p.resolved, highlighter);
+    templater
+        .render_page(&p.title, &fragment, &p.nav)
+        .with_context(|| format!("templating {}", p.source))
+}
+
+/// Full site build with the incremental layer (spec §4). Renders only the pages whose
+/// `render_key` changed or that link into a changed file's targets; reuses the on-disk
+/// output of everything else; persists an updated cache manifest.
+pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result<SiteReport> {
+    let (_org_rel, assets) = discover(src)?;
+    let (preps, symbols) = prepare_pages(src)?;
+
+    // The global hash classes (spec §4.1): a change in any invalidates the site. The
+    // config hash is combined with a site-structure hash because the nav bar — global
+    // chrome on every page — is built from every page's (path, title), so a title/path
+    // change or a page add/remove must re-render every page (else stale nav on disk).
+    let cfg = BuildConfig::default();
+    let nav_entries: Vec<(String, String)> = preps
+        .iter()
+        .map(|p| (p.source.to_string(), p.title.clone()))
+        .collect();
+    let cfg_hash = combine(config_hash(&cfg), site_structure_hash(&nav_entries));
+    let tmpl_hash = template_hash(template_sources());
+
+    // Compose each page's render key and record its dependency edges.
+    let mut new_graph = DepGraph::default();
+    let mut new_records: Vec<(Utf8PathBuf, PageRecord, Hash)> = Vec::new();
+    for p in &preps {
+        let rlh = resolved_links_hash(&p.source, &p.used, &symbols);
+        let key = render_key(p.content_hash, rlh, cfg_hash, tmpl_hash);
+        new_graph.defines.insert(p.source.clone(), p.defines.clone());
+        new_graph.uses.insert(p.source.clone(), p.used.clone());
+        new_records.push((
+            p.source.clone(),
+            PageRecord {
+                content_hash: p.content_hash,
+                render_key: key,
+                output_path: p.output.clone(),
+            },
+            key,
+        ));
+    }
+
+    // Load the prior manifest (unless bypassed). Absent/corrupt/version-mismatch ⇒ None
+    // ⇒ full rebuild (spec §4.5).
+    let prior = if opts.no_cache {
+        None
+    } else {
+        incremental::load_manifest(out)
+    };
+
+    let rebuild: HashSet<Utf8PathBuf> = compute_rebuild_set(
+        &preps,
+        &new_records,
+        &new_graph,
+        cfg_hash,
+        tmpl_hash,
+        out,
+        prior.as_ref(),
+    );
+
+    // Delete outputs for pages that existed last build but are gone now (spec §4.3 step 1:
+    // removed files). Their targets are already in the merged graph, so their linkers were
+    // invalidated above.
+    if let Some(prior) = &prior {
+        let current: HashSet<&Utf8PathBuf> = preps.iter().map(|p| &p.source).collect();
+        for (src_path, rec) in &prior.pages {
+            if !current.contains(src_path) {
+                let dest = out.join(&rec.output_path);
+                let _ = fs::remove_file(&dest);
+            }
+        }
+    }
+
+    let highlighter = SyntectHighlighter;
+    let templater = Templater::new();
+    let mut report = SiteReport::default();
+
+    for p in &preps {
+        for t in &p.broken {
+            report.broken.push((p.source.clone(), t.clone()));
+        }
+        report.pages.push(p.output.clone());
+
+        let dest = out.join(&p.output);
+        if rebuild.contains(&p.source) {
+            if let Some(parent) = dest.parent() {
+                fs::create_dir_all(parent).with_context(|| format!("creating {parent}"))?;
+            }
+            let html = render_page(&templater, &highlighter, p)?;
+            fs::write(&dest, &html).with_context(|| format!("writing {dest}"))?;
+            report.rendered.push(p.output.clone());
+        } else {
+            // Skip: the on-disk output is already correct (spec §4.1). Leave it untouched.
+            report.skipped.push(p.output.clone());
+        }
+    }
+
+    // Assets are a dumb copy in v0.3 (spec §8 Q11): copy every run. Cheap, and keeps the
+    // full-vs-incremental byte equivalence trivially true for non-`.org` files.
+    for rel in &assets {
+        let from = src.join(rel);
+        let dest = out.join(rel);
+        if let Some(parent) = dest.parent() {
+            fs::create_dir_all(parent).with_context(|| format!("creating {parent}"))?;
+        }
+        fs::copy(&from, &dest).with_context(|| format!("copying {from} -> {dest}"))?;
+        report.assets.push(rel.clone());
+    }
+
+    // Persist the manifest for the next build.
+    let manifest = Manifest {
+        format_version: CACHE_FORMAT_VERSION,
+        config_hash: Some(cfg_hash),
+        template_hash: Some(tmpl_hash),
+        pages: new_records
+            .into_iter()
+            .map(|(src_path, rec, _)| (src_path, rec))
+            .collect(),
+        graph: new_graph,
+    };
+    incremental::save_manifest(out, &manifest)
+        .with_context(|| format!("writing cache manifest under {out}"))?;
+
+    if opts.strict && !report.broken.is_empty() {
+        for (page, target) in &report.broken {
+            eprintln!("error: unresolved link in {page}: {target:?}");
+        }
+        anyhow::bail!(
+            "{} unresolved internal link(s) under --strict",
+            report.broken.len()
+        );
+    }
+    for (page, target) in &report.broken {
+        eprintln!("warning: unresolved link in {page}: {target:?}");
+    }
+
+    Ok(report)
+}
+
+/// The set of source files to (re)render this build (spec §4.3 invalidation algorithm),
+/// as the union of:
+/// - **no prior cache** (absent/corrupt/version-mismatch/`--no-cache`) ⇒ every page;
+/// - a **global** config- or template-hash change ⇒ every page (spec §4.1);
+/// - **content-changed** files ∪ pages that link into a changed file's targets, via the
+///   dependency graph merged with the prior build's `defines` (spec §4.3, so a removed
+///   target still invalidates its linkers);
+/// - any page whose composed **render_key** differs from the cached one (catches URL
+///   changes on linked targets precisely);
+/// - any page whose **output file is missing** on disk.
+fn compute_rebuild_set(
+    preps: &[PagePrep],
+    new_records: &[(Utf8PathBuf, PageRecord, Hash)],
+    new_graph: &DepGraph,
+    cfg_hash: Hash,
+    tmpl_hash: Hash,
+    out: &Utf8Path,
+    prior: Option<&Manifest>,
+) -> HashSet<Utf8PathBuf> {
+    let all: HashSet<Utf8PathBuf> = preps.iter().map(|p| p.source.clone()).collect();
+
+    let Some(prior) = prior else {
+        return all; // No usable cache ⇒ full rebuild.
+    };
+
+    // A global config/template change invalidates every page (spec §4.1).
+    if prior.config_hash != Some(cfg_hash) || prior.template_hash != Some(tmpl_hash) {
+        return all;
+    }
+
+    // Content-changed = hash differs from the cached record, or the file is new.
+    let mut changed: HashSet<Utf8PathBuf> = HashSet::new();
+    for p in preps {
+        match prior.pages.get(&p.source) {
+            Some(rec) if rec.content_hash == p.content_hash => {}
+            _ => {
+                changed.insert(p.source.clone());
+            }
+        }
+    }
+
+    // Graph expansion: changed files ∪ pages that link into a changed file's targets.
+    // Merge prior `defines` so a target a changed file removed still pulls its linkers.
+    let merged = prior.graph.merged_defines_with(new_graph);
+    let mut rebuild = incremental::invalidation_set(&changed, &merged);
+
+    // Precise render_key delta (catches a linked target's URL change; also a belt for the
+    // graph). A page whose render_key matches the cache and whose output exists is correct.
+    for (src_path, _rec, key) in new_records {
+        let unchanged = prior
+            .pages
+            .get(src_path)
+            .map(|old| old.render_key == *key)
+            .unwrap_or(false);
+        if !unchanged {
+            rebuild.insert(src_path.clone());
+        }
+    }
+
+    // Any page whose output file is missing must be re-emitted regardless.
+    for p in preps {
+        if !out.join(&p.output).exists() {
+            rebuild.insert(p.source.clone());
+        }
+    }
+
+    rebuild
+}
+
+/// Walk `src`, returning `.org` source paths and non-`.org` asset paths, both relative
+/// to `src` and sorted for deterministic output. The cache manifest is not an asset.
+fn discover(src: &Utf8Path) -> Result<(Vec<Utf8PathBuf>, Vec<Utf8PathBuf>)> {
+    let mut org = Vec::new();
+    let mut assets = Vec::new();
+    for entry in WalkDir::new(src).sort_by_file_name() {
+        let entry = entry.with_context(|| format!("walking {src}"))?;
+        if !entry.file_type().is_file() {
+            continue;
+        }
+        let abs = Utf8PathBuf::from_path_buf(entry.into_path())
+            .map_err(|p| anyhow::anyhow!("non-UTF-8 path: {}", p.display()))?;
+        let rel = abs
+            .strip_prefix(src)
+            .map(|p| p.to_owned())
+            .unwrap_or_else(|_| abs.clone());
+        if rel.extension() == Some("org") {
+            org.push(rel);
+        } else {
+            assets.push(rel);
+        }
+    }
+    org.sort();
+    assets.sort();
+    Ok((org, assets))
+}
+
+fn page_title(doc: &Document) -> String {
+    doc.keywords
+        .entries
+        .iter()
+        .find(|(k, _)| k.eq_ignore_ascii_case("TITLE"))
+        .map(|(_, v)| v.clone())
+        .unwrap_or_else(|| {
+            doc.source_path
+                .file_stem()
+                .unwrap_or("untitled")
+                .to_string()
+        })
+}
diff --git a/src/template.rs b/src/template.rs
new file mode 100644
index 0000000..d731cf9
--- /dev/null
+++ b/src/template.rs
@@ -0,0 +1,85 @@
+//! TEMPLATE stage (spec §2.1, §2.4, §3.3): rendered fragment + page metadata → full HTML.
+//!
+//! minijinja (Jinja2 semantics, runtime templates: edit-and-rebuild, no recompile).
+//! Templates are a hashing input for incrementality (spec §4.1): a base-layout edit
+//! invalidates every page that transitively uses it. Keep the fragment/template
+//! boundary sharp so content HTML can be snapshot-tested independently of chrome.
+
+use minijinja::{context, Environment};
+use serde::Serialize;
+
+/// A navigation entry: a page title and the URL to reach it from the current page.
+#[derive(Debug, Clone, Serialize)]
+pub struct NavItem {
+    pub title: String,
+    pub url: String,
+}
+
+/// The base layout applied to every page: `<title>`, a nav bar, and the body.
+/// Minimal but real — a single `base` template, no partials yet.
+const BASE_TEMPLATE: &str = r#"<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>{{ title }}</title>
+</head>
+<body>
+<nav>
+{%- for item in nav %}
+<a href="{{ item.url }}">{{ item.title }}</a>
+{%- endfor %}
+</nav>
+<main>
+{{ body | safe }}</main>
+</body>
+</html>
+"#;
+
+/// The source text of every template that participates in the page layout. Hashed by
+/// the incremental layer (spec §4.1): a base-layout edit invalidates every page that
+/// uses it. There is a single `base` template today; when partials arrive this returns
+/// the transitive closure so a single-partial edit invalidates only its users.
+pub fn template_sources() -> &'static [(&'static str, &'static str)] {
+    &[("base", BASE_TEMPLATE)]
+}
+
+#[derive(Debug, thiserror::Error)]
+pub enum TemplateError {
+    #[error("template error: {0}")]
+    Render(String),
+}
+
+/// Wraps a rendered fragment in its page template.
+pub struct Templater {
+    env: Environment<'static>,
+}
+
+impl Templater {
+    pub fn new() -> Self {
+        let mut env = Environment::new();
+        env.add_template("base", BASE_TEMPLATE)
+            .expect("base template compiles");
+        Templater { env }
+    }
+
+    /// fragment + page metadata → full HTML page.
+    pub fn render_page(
+        &self,
+        title: &str,
+        body: &str,
+        nav: &[NavItem],
+    ) -> Result<String, TemplateError> {
+        let tmpl = self
+            .env
+            .get_template("base")
+            .map_err(|e| TemplateError::Render(e.to_string()))?;
+        tmpl.render(context! { title => title, body => body, nav => nav })
+            .map_err(|e| TemplateError::Render(e.to_string()))
+    }
+}
+
+impl Default for Templater {
+    fn default() -> Self {
+        Self::new()
+    }
+}
diff --git a/src/util.rs b/src/util.rs
new file mode 100644
index 0000000..e4a299d
--- /dev/null
+++ b/src/util.rs
@@ -0,0 +1,110 @@
+//! Small shared helpers used across INDEX, RESOLVE, RENDER and the site build:
+//! flattening inline objects to plain text, slugifying heading text into anchors,
+//! and computing relative output URLs between pages.
+
+use camino::{Utf8Path, Utf8PathBuf};
+
+use crate::model::Object;
+
+/// Flatten inline objects to their plain-text content (markup stripped). Used to
+/// derive heading anchors and `[[*Heading]]` link identities (spec §4.3).
+pub fn plain_text(objs: &[Object]) -> String {
+    let mut out = String::new();
+    plain_text_into(objs, &mut out);
+    out
+}
+
+fn plain_text_into(objs: &[Object], out: &mut String) {
+    for obj in objs {
+        match obj {
+            Object::Text(t) => out.push_str(t),
+            Object::Bold(i)
+            | Object::Italic(i)
+            | Object::Underline(i)
+            | Object::StrikeThrough(i) => plain_text_into(i, out),
+            Object::Verbatim(s) | Object::Code(s) | Object::Entity(s) => out.push_str(s),
+            Object::Link(l) => {
+                if let Some(desc) = &l.description {
+                    plain_text_into(desc, out);
+                }
+            }
+            Object::FootnoteRef { .. } | Object::Timestamp(_) | Object::LineBreak => {}
+        }
+    }
+}
+
+/// Turn heading text into a URL-safe anchor slug.
+pub fn slugify(text: &str) -> String {
+    let mut out = String::new();
+    let mut prev_dash = false;
+    for c in text.chars() {
+        if c.is_alphanumeric() {
+            out.extend(c.to_lowercase());
+            prev_dash = false;
+        } else if !prev_dash {
+            out.push('-');
+            prev_dash = true;
+        }
+    }
+    out.trim_matches('-').to_string()
+}
+
+/// The output URL to reach `to_rel` (a source `.org` path relative to the site root)
+/// from the page at `from_rel`, honoring an optional `anchor`. Same-file links reduce
+/// to a bare `#anchor` fragment; cross-file links become a relative `.html` path.
+pub fn output_url(from_rel: &Utf8Path, to_rel: &Utf8Path, anchor: Option<&str>) -> String {
+    let path = if from_rel == to_rel {
+        String::new()
+    } else {
+        let to_html = to_rel.with_extension("html");
+        let from_dir = from_rel.parent().unwrap_or_else(|| Utf8Path::new(""));
+        relative_path(from_dir, &to_html)
+    };
+    match anchor {
+        Some(a) if !a.is_empty() => {
+            if path.is_empty() {
+                format!("#{a}")
+            } else {
+                format!("{path}#{a}")
+            }
+        }
+        _ => {
+            if path.is_empty() {
+                "#".to_string()
+            } else {
+                path
+            }
+        }
+    }
+}
+
+/// Relative path from `from_dir` to `to`, using `../` where needed. `/`-joined for URLs.
+fn relative_path(from_dir: &Utf8Path, to: &Utf8Path) -> String {
+    let from_c: Vec<&str> = from_dir.components().map(|c| c.as_str()).collect();
+    let to_c: Vec<&str> = to.components().map(|c| c.as_str()).collect();
+    let mut i = 0;
+    while i < from_c.len() && i < to_c.len() && from_c[i] == to_c[i] {
+        i += 1;
+    }
+    let mut parts: Vec<&str> = vec![".."; from_c.len() - i];
+    parts.extend(&to_c[i..]);
+    parts.join("/")
+}
+
+/// Resolve a `file:` link path (as written) against the linking page's directory,
+/// normalizing `.`/`..` so it can be matched against indexed file targets.
+pub fn normalize_link_path(from_rel: &Utf8Path, path: &Utf8Path) -> Utf8PathBuf {
+    let base = from_rel.parent().unwrap_or_else(|| Utf8Path::new(""));
+    let joined = base.join(path);
+    let mut stack: Vec<&str> = Vec::new();
+    for comp in joined.components() {
+        match comp.as_str() {
+            "." => {}
+            ".." => {
+                stack.pop();
+            }
+            other => stack.push(other),
+        }
+    }
+    Utf8PathBuf::from(stack.join("/"))
+}
diff --git a/tests/harness.rs b/tests/harness.rs
new file mode 100644
index 0000000..47510b2
--- /dev/null
+++ b/tests/harness.rs
@@ -0,0 +1,34 @@
+//! Proves the test harness works end to end while the pipeline is still stubbed.
+//!
+//! Two layers, matching the spec's testing philosophy (§5):
+//! - a plain unit test on the one real function (`content_hash`);
+//! - an `insta` JSON snapshot of a hand-built element-tree value, standing in for the
+//!   element-tree snapshots the parser will produce from Phase 1 onward.
+
+use org_ssg::model::{Link, LinkTarget, Object};
+use org_ssg::parser::content_hash;
+
+#[test]
+fn content_hash_is_deterministic_blake3() {
+    let a = content_hash(b"hello\n");
+    let b = content_hash(b"hello\n");
+    let c = content_hash(b"goodbye\n");
+    assert_eq!(a, b, "same bytes must hash identically");
+    assert_ne!(a, c, "different bytes must hash differently");
+    // blake3 is 32 bytes.
+    assert_eq!(a.0.len(), 32);
+}
+
+#[test]
+fn element_tree_json_snapshot() {
+    // A small inline-object tree built by hand — the shape the parser will emit.
+    let tree = vec![
+        Object::Text("see ".into()),
+        Object::Bold(vec![Object::Text("bold".into())]),
+        Object::Link(Link {
+            target: LinkTarget::CustomId("first".into()),
+            description: Some(vec![Object::Text("the first heading".into())]),
+        }),
+    ];
+    insta::assert_json_snapshot!(tree);
+}
diff --git a/tests/incremental.rs b/tests/incremental.rs
new file mode 100644
index 0000000..528e140
--- /dev/null
+++ b/tests/incremental.rs
@@ -0,0 +1,296 @@
+//! Incremental build layer gates (spec §4, Phase 6). These are the hard correctness
+//! tests the incremental design exists to satisfy:
+//!
+//! - **Byte-equivalence**: a full (`--no-cache`) build and an incremental rebuild of an
+//!   unchanged site produce byte-identical output, and the second build re-renders ZERO
+//!   pages (spec §4.5, R5).
+//! - **Edit-one-file**: editing a page re-renders exactly that page plus the pages that
+//!   link into it — no more, no less (spec §4.3).
+//! - **Renamed-heading**: renaming a heading a cross-page link points at invalidates the
+//!   linking page and updates its emitted anchor (spec §4.3, R2 — the load-bearing case).
+//! - **Cache fallback**: a version bump, a missing cache, or a corrupt cache all fall
+//!   back to a full rebuild (spec §4.5).
+
+use std::collections::BTreeMap;
+use std::sync::atomic::{AtomicU32, Ordering};
+
+use camino::Utf8PathBuf;
+
+use org_ssg::incremental::{manifest_path, Manifest, CACHE_FORMAT_VERSION};
+use org_ssg::site::{build_site, BuildOptions};
+
+/// A fresh, empty temp directory unique to this process + call.
+fn tmpdir(tag: &str) -> Utf8PathBuf {
+    static N: AtomicU32 = AtomicU32::new(0);
+    let n = N.fetch_add(1, Ordering::Relaxed);
+    let base = Utf8PathBuf::from_path_buf(std::env::temp_dir())
+        .expect("utf-8 temp dir")
+        .join(format!("org-ssg-it-{}-{tag}-{n}", std::process::id()));
+    if base.exists() {
+        std::fs::remove_dir_all(&base).unwrap();
+    }
+    std::fs::create_dir_all(&base).unwrap();
+    base
+}
+
+fn write(dir: &Utf8PathBuf, name: &str, content: &str) {
+    std::fs::write(dir.join(name), content).unwrap();
+}
+
+/// Every output file (relative path → bytes) except the cache manifest, which is an
+/// internal artifact with non-deterministic map ordering.
+fn output_files(out: &Utf8PathBuf) -> BTreeMap<String, Vec<u8>> {
+    let mut map = BTreeMap::new();
+    for entry in walkdir::WalkDir::new(out).sort_by_file_name() {
+        let entry = entry.unwrap();
+        if !entry.file_type().is_file() {
+            continue;
+        }
+        let path = Utf8PathBuf::from_path_buf(entry.path().to_owned()).unwrap();
+        if path.file_name() == Some(".org-ssg-cache.json") {
+            continue;
+        }
+        let rel = path.strip_prefix(out).unwrap().to_string();
+        map.insert(rel, std::fs::read(&path).unwrap());
+    }
+    map
+}
+
+fn out(p: &str) -> Utf8PathBuf {
+    Utf8PathBuf::from(p)
+}
+
+/// Two linked pages: `b.org` links to a `:CUSTOM_ID:` heading in `a.org`, plus a css asset.
+fn write_linked_site(src: &Utf8PathBuf) {
+    write(
+        src,
+        "a.org",
+        "#+TITLE: A\n\n* Setup\n:PROPERTIES:\n:CUSTOM_ID: setup\n:END:\nOriginal body.\n",
+    );
+    write(src, "b.org", "#+TITLE: B\n\nSee [[#setup][the setup]].\n");
+    write(src, "style.css", "body { color: black; }\n");
+}
+
+#[test]
+fn full_and_incremental_are_byte_identical_and_second_build_renders_nothing() {
+    let root = tmpdir("byteeq");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    write_linked_site(&src);
+
+    // Full build (cache bypassed) to a reference directory.
+    let full = root.join("full");
+    let rfull = build_site(
+        &src,
+        &full,
+        &BuildOptions {
+            no_cache: true,
+            strict: false,
+        },
+    )
+    .unwrap();
+    assert_eq!(rfull.rendered.len(), 2, "full build renders every page");
+
+    // Incremental directory: first build populates the cache and renders everything.
+    let inc = root.join("inc");
+    let r1 = build_site(&src, &inc, &BuildOptions::default()).unwrap();
+    assert_eq!(r1.rendered.len(), 2, "first incremental build renders all");
+
+    // Second incremental build of the UNCHANGED site must re-render ZERO pages.
+    let r2 = build_site(&src, &inc, &BuildOptions::default()).unwrap();
+    assert!(
+        r2.rendered.is_empty(),
+        "unchanged rebuild must render nothing, rendered: {:?}",
+        r2.rendered
+    );
+    assert_eq!(r2.skipped.len(), 2, "both pages reused from cache");
+
+    // Full output == incremental output, byte for byte.
+    assert_eq!(
+        output_files(&full),
+        output_files(&inc),
+        "incremental output must be byte-identical to a full build"
+    );
+}
+
+#[test]
+fn editing_a_page_rebuilds_it_and_its_linkers_exactly() {
+    let root = tmpdir("editone");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    write_linked_site(&src);
+    let out_dir = root.join("out");
+
+    // Prime the cache.
+    build_site(&src, &out_dir, &BuildOptions::default()).unwrap();
+
+    // Edit a.org's body (not its heading/custom-id): b.org links into a.org, so the
+    // invalidation set is exactly {a, b} — b is re-rendered because it links to a.
+    write(
+        &src,
+        "a.org",
+        "#+TITLE: A\n\n* Setup\n:PROPERTIES:\n:CUSTOM_ID: setup\n:END:\nEdited body.\n",
+    );
+    let r = build_site(&src, &out_dir, &BuildOptions::default()).unwrap();
+
+    let mut rendered = r.rendered.clone();
+    rendered.sort();
+    assert_eq!(
+        rendered,
+        vec![out("a.html"), out("b.html")],
+        "editing a.org re-renders exactly a.html and its linker b.html"
+    );
+    assert_eq!(r.skipped, Vec::<Utf8PathBuf>::new(), "nothing else exists to skip");
+}
+
+#[test]
+fn editing_a_leaf_page_rebuilds_only_itself() {
+    let root = tmpdir("editleaf");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    write_linked_site(&src);
+    let out_dir = root.join("out");
+
+    build_site(&src, &out_dir, &BuildOptions::default()).unwrap();
+
+    // b.org has NO inbound links, so editing it invalidates only itself.
+    write(&src, "b.org", "#+TITLE: B\n\nSee [[#setup][the setup]]. Edited.\n");
+    let r = build_site(&src, &out_dir, &BuildOptions::default()).unwrap();
+
+    assert_eq!(r.rendered, vec![out("b.html")], "only the edited leaf re-renders");
+    assert!(
+        r.skipped.contains(&out("a.html")),
+        "the unlinked page a.html is reused"
+    );
+}
+
+#[test]
+fn renaming_a_linked_heading_invalidates_the_linking_page() {
+    let root = tmpdir("rename");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    // b.org links to a.org's heading BY TEXT (the fragile `[[*Heading]]` case, spec §4.3).
+    write(&src, "a.org", "#+TITLE: A\n\n* Target Heading\nBody.\n");
+    write(&src, "b.org", "#+TITLE: B\n\nJump to [[*Target Heading][there]].\n");
+    let out_dir = root.join("out");
+
+    build_site(&src, &out_dir, &BuildOptions::default()).unwrap();
+    let b_before = std::fs::read_to_string(out_dir.join("b.html")).unwrap();
+    assert!(
+        b_before.contains("a.html#target-heading"),
+        "b.html should link to the target heading anchor initially:\n{b_before}"
+    );
+
+    // Rename the heading a.org owns. b.org's [[*Target Heading]] now dangles.
+    write(&src, "a.org", "#+TITLE: A\n\n* Renamed Heading\nBody.\n");
+    let r = build_site(&src, &out_dir, &BuildOptions::default()).unwrap();
+
+    assert!(
+        r.rendered.contains(&out("b.html")),
+        "the linking page must be invalidated by the rename, rendered: {:?}",
+        r.rendered
+    );
+    assert!(
+        r.rendered.contains(&out("a.html")),
+        "the renamed page itself is re-rendered"
+    );
+
+    let b_after = std::fs::read_to_string(out_dir.join("b.html")).unwrap();
+    assert_ne!(b_before, b_after, "b.html's emitted link must change");
+    assert!(
+        !b_after.contains("a.html#target-heading"),
+        "the stale cross-file anchor must be gone:\n{b_after}"
+    );
+    assert!(
+        !r.broken.is_empty(),
+        "the now-dangling link should be reported as broken"
+    );
+}
+
+#[test]
+fn changing_a_title_rebuilds_every_page_for_the_shared_nav() {
+    let root = tmpdir("navtitle");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    write_linked_site(&src);
+    let out_dir = root.join("out");
+
+    build_site(&src, &out_dir, &BuildOptions::default()).unwrap();
+    let b_before = std::fs::read_to_string(out_dir.join("b.html")).unwrap();
+
+    // a.org's #+TITLE feeds the nav bar on every page, so changing it must re-render all.
+    write(
+        &src,
+        "a.org",
+        "#+TITLE: A Renamed\n\n* Setup\n:PROPERTIES:\n:CUSTOM_ID: setup\n:END:\nOriginal body.\n",
+    );
+    let r = build_site(&src, &out_dir, &BuildOptions::default()).unwrap();
+
+    assert_eq!(r.rendered.len(), 2, "a title change re-renders every page");
+    let b_after = std::fs::read_to_string(out_dir.join("b.html")).unwrap();
+    assert_ne!(b_before, b_after, "b.html's nav must reflect a.org's new title");
+    assert!(b_after.contains("A Renamed"), "b.html nav shows the updated title");
+}
+
+#[test]
+fn missing_cache_falls_back_to_full_rebuild() {
+    let root = tmpdir("nocache");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    write_linked_site(&src);
+    let out_dir = root.join("out");
+
+    build_site(&src, &out_dir, &BuildOptions::default()).unwrap();
+    // Delete the cache manifest → next build has nothing to skip against.
+    std::fs::remove_file(manifest_path(&out_dir)).unwrap();
+
+    let r = build_site(&src, &out_dir, &BuildOptions::default()).unwrap();
+    assert_eq!(r.rendered.len(), 2, "a missing cache forces a full rebuild");
+    assert!(r.skipped.is_empty());
+}
+
+#[test]
+fn cache_version_mismatch_falls_back_to_full_rebuild() {
+    let root = tmpdir("versionbump");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    write_linked_site(&src);
+    let out_dir = root.join("out");
+
+    build_site(&src, &out_dir, &BuildOptions::default()).unwrap();
+
+    // Rewrite the manifest with a future cache-format version. On mismatch the loader
+    // discards it (spec §4.5), so the next build re-renders everything.
+    let stale = Manifest {
+        format_version: CACHE_FORMAT_VERSION + 1,
+        ..Default::default()
+    };
+    std::fs::write(
+        manifest_path(&out_dir),
+        serde_json::to_vec(&stale).unwrap(),
+    )
+    .unwrap();
+
+    let r = build_site(&src, &out_dir, &BuildOptions::default()).unwrap();
+    assert_eq!(
+        r.rendered.len(),
+        2,
+        "a cache-format version bump forces a full rebuild"
+    );
+    assert!(r.skipped.is_empty());
+}
+
+#[test]
+fn corrupt_cache_falls_back_without_crashing() {
+    let root = tmpdir("corrupt");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    write_linked_site(&src);
+    let out_dir = root.join("out");
+
+    build_site(&src, &out_dir, &BuildOptions::default()).unwrap();
+    std::fs::write(manifest_path(&out_dir), b"this is not json{{{").unwrap();
+
+    let r = build_site(&src, &out_dir, &BuildOptions::default()).unwrap();
+    assert_eq!(r.rendered.len(), 2, "a corrupt cache is never a correctness dependency");
+}
diff --git a/tests/pipeline.rs b/tests/pipeline.rs
new file mode 100644
index 0000000..0acfa91
--- /dev/null
+++ b/tests/pipeline.rs
@@ -0,0 +1,48 @@
+//! End-to-end PARSE → RENDER snapshots for the v0.1 core org subset (spec §5).
+//!
+//! Two layers, per the spec's testing philosophy: an element-tree JSON snapshot
+//! (parser correctness) and a rendered-HTML snapshot (renderer correctness), for
+//! each fixture.
+
+use camino::Utf8PathBuf;
+
+use org_ssg::model::Document;
+use org_ssg::parser::parse;
+use org_ssg::render::{render, Html, SyntectHighlighter};
+use org_ssg::resolve::ResolvedDoc;
+
+fn parse_fixture(name: &str) -> Document {
+    let path = Utf8PathBuf::from(env!("CARGO_MANIFEST_DIR"))
+        .join("fixtures")
+        .join(name);
+    let source = std::fs::read_to_string(&path).expect("read fixture");
+    // Use a stable relative path so snapshots don't embed an absolute machine path.
+    parse(Utf8PathBuf::from("fixtures").join(name).as_path(), &source).expect("parse fixture")
+}
+
+fn render_fixture(name: &str) -> String {
+    let document = parse_fixture(name);
+    let resolved = ResolvedDoc { document };
+    let Html(html) = render(&resolved, &SyntectHighlighter);
+    html
+}
+
+#[test]
+fn minimal_element_tree() {
+    insta::assert_json_snapshot!(parse_fixture("minimal.org").root);
+}
+
+#[test]
+fn minimal_html() {
+    insta::assert_snapshot!(render_fixture("minimal.org"));
+}
+
+#[test]
+fn core_element_tree() {
+    insta::assert_json_snapshot!(parse_fixture("core.org").root);
+}
+
+#[test]
+fn core_html() {
+    insta::assert_snapshot!(render_fixture("core.org"));
+}
diff --git a/tests/site.rs b/tests/site.rs
new file mode 100644
index 0000000..8b95b51
--- /dev/null
+++ b/tests/site.rs
@@ -0,0 +1,124 @@
+//! Multi-file site build + new-construct snapshots for v0.2 (spec §5, Phase 4/5).
+//!
+//! Layers, per the spec's testing philosophy: rendered-HTML snapshots (resolved links,
+//! templated pages, tables, footnotes) plus explicit assertions that a cross-file link
+//! resolves to the right URL and that unresolved links are reported, not fatal.
+
+use camino::Utf8PathBuf;
+
+use org_ssg::index::{SymbolTable, TargetId};
+use org_ssg::parser::parse;
+use org_ssg::render::{render, Html, SyntectHighlighter};
+use org_ssg::resolve::{resolve, ResolvedDoc};
+use org_ssg::site::{render_site, BuiltPage};
+
+fn fixtures() -> Utf8PathBuf {
+    Utf8PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("fixtures")
+}
+
+fn build_fixture_site() -> Vec<BuiltPage> {
+    let (pages, broken) = render_site(&fixtures().join("site")).expect("build site");
+    assert!(broken.is_empty(), "fixture site has no broken links: {broken:?}");
+    pages
+}
+
+fn page<'a>(pages: &'a [BuiltPage], source: &str) -> &'a BuiltPage {
+    pages
+        .iter()
+        .find(|p| p.source == source)
+        .unwrap_or_else(|| panic!("no page for {source}"))
+}
+
+fn render_fragment(name: &str) -> String {
+    let path = fixtures().join(name);
+    let source = std::fs::read_to_string(&path).expect("read fixture");
+    let document = parse(Utf8PathBuf::from(name).as_path(), &source).expect("parse");
+    let Html(html) = render(&ResolvedDoc { document }, &SyntectHighlighter);
+    html
+}
+
+#[test]
+fn site_index_html() {
+    let pages = build_fixture_site();
+    insta::assert_snapshot!(page(&pages, "index.org").html);
+}
+
+#[test]
+fn site_guide_html() {
+    let pages = build_fixture_site();
+    insta::assert_snapshot!(page(&pages, "guide.org").html);
+}
+
+/// The invariant the whole RESOLVE stage exists for: a cross-file `[[#setup]]` link on
+/// the home page must resolve to the guide page's URL plus the target anchor.
+#[test]
+fn cross_file_link_resolves() {
+    let pages = build_fixture_site();
+    let index = &page(&pages, "index.org").html;
+    assert!(
+        index.contains("href=\"guide.html#setup\""),
+        "cross-file custom-id link should resolve to guide.html#setup, got:\n{index}"
+    );
+    // The `file:` link resolves to the bare output path.
+    assert!(
+        index.contains("href=\"guide.html\""),
+        "file: link should resolve to guide.html"
+    );
+    // A same-page `[[*Overview]]` link stays a local fragment.
+    assert!(
+        index.contains("href=\"#overview\""),
+        "same-page heading link should be a local fragment"
+    );
+}
+
+/// Unresolved internal links are reported as warnings, never a crash (spec §4.3.4).
+#[test]
+fn unresolved_link_is_reported_not_fatal() {
+    let source = "Broken [[#does-not-exist][link]] here.\n";
+    let doc = parse(Utf8PathBuf::from("orphan.org").as_path(), source).expect("parse");
+    let mut symbols = SymbolTable::new();
+    symbols.index_document(&doc);
+    let out = resolve(&doc, &symbols);
+    assert_eq!(out.broken.len(), 1, "one unresolved link expected");
+    assert_eq!(
+        out.broken[0].target,
+        TargetId::CustomId("does-not-exist".into())
+    );
+    assert!(out.used_targets.is_empty(), "nothing resolved, nothing used");
+}
+
+/// RESOLVE records the `uses` edges (spec §4.3, R2) even though incrementality does
+/// not consume them yet.
+#[test]
+fn resolve_records_used_targets() {
+    let pages = build_fixture_site();
+    // index.org uses: guide's #setup, guide.org (file), and its own *Overview → 3.
+    let _ = pages; // pages already assert no broken links; check the edge count directly.
+    let src = fixtures().join("site").join("index.org");
+    let source = std::fs::read_to_string(&src).unwrap();
+    let doc = parse(Utf8PathBuf::from("index.org").as_path(), &source).unwrap();
+
+    let guide_src = fixtures().join("site").join("guide.org");
+    let guide = parse(
+        Utf8PathBuf::from("guide.org").as_path(),
+        &std::fs::read_to_string(&guide_src).unwrap(),
+    )
+    .unwrap();
+
+    let mut symbols = SymbolTable::new();
+    symbols.index_document(&doc);
+    symbols.index_document(&guide);
+    let out = resolve(&doc, &symbols);
+    assert_eq!(out.used_targets.len(), 3, "three internal links resolved");
+    assert!(out.broken.is_empty());
+}
+
+#[test]
+fn table_render() {
+    insta::assert_snapshot!(render_fragment("table.org"));
+}
+
+#[test]
+fn footnote_render() {
+    insta::assert_snapshot!(render_fragment("footnote.org"));
+}
diff --git a/tests/snapshots/harness__element_tree_json_snapshot.snap b/tests/snapshots/harness__element_tree_json_snapshot.snap
new file mode 100644
index 0000000..5831d7f
--- /dev/null
+++ b/tests/snapshots/harness__element_tree_json_snapshot.snap
@@ -0,0 +1,28 @@
+---
+source: tests/harness.rs
+expression: tree
+---
+[
+  {
+    "Text": "see "
+  },
+  {
+    "Bold": [
+      {
+        "Text": "bold"
+      }
+    ]
+  },
+  {
+    "Link": {
+      "target": {
+        "CustomId": "first"
+      },
+      "description": [
+        {
+          "Text": "the first heading"
+        }
+      ]
+    }
+  }
+]
diff --git a/tests/snapshots/pipeline__core_element_tree.snap b/tests/snapshots/pipeline__core_element_tree.snap
new file mode 100644
index 0000000..d3aca71
--- /dev/null
+++ b/tests/snapshots/pipeline__core_element_tree.snap
@@ -0,0 +1,200 @@
+---
+source: tests/pipeline.rs
+expression: "parse_fixture(\"core.org\").root"
+---
+{
+  "heading": null,
+  "content": [
+    {
+      "Paragraph": [
+        {
+          "Text": "Intro paragraph with a bare URL "
+        },
+        {
+          "Link": {
+            "target": {
+              "External": "https://example.com"
+            },
+            "description": null
+          }
+        },
+        {
+          "Text": " and some "
+        },
+        {
+          "Code": "inline code"
+        },
+        {
+          "Text": "."
+        }
+      ]
+    }
+  ],
+  "children": [
+    {
+      "heading": {
+        "level": 1,
+        "todo": null,
+        "priority": null,
+        "title": [
+          {
+            "Text": "Ordered and checked"
+          }
+        ],
+        "tags": [],
+        "properties": {
+          "entries": []
+        },
+        "id": null,
+        "custom_id": null
+      },
+      "content": [
+        {
+          "List": {
+            "kind": "Ordered",
+            "items": [
+              {
+                "bullet": {
+                  "Ordered": 1
+                },
+                "checkbox": null,
+                "term": null,
+                "content": [
+                  {
+                    "Paragraph": [
+                      {
+                        "Text": "first item"
+                      }
+                    ]
+                  }
+                ]
+              },
+              {
+                "bullet": {
+                  "Ordered": 2
+                },
+                "checkbox": null,
+                "term": null,
+                "content": [
+                  {
+                    "Paragraph": [
+                      {
+                        "Text": "second item with "
+                      },
+                      {
+                        "Italic": [
+                          {
+                            "Text": "emphasis"
+                          }
+                        ]
+                      }
+                    ]
+                  }
+                ]
+              }
+            ]
+          }
+        },
+        {
+          "List": {
+            "kind": "Unordered",
+            "items": [
+              {
+                "bullet": "Dash",
+                "checkbox": "Off",
+                "term": null,
+                "content": [
+                  {
+                    "Paragraph": [
+                      {
+                        "Text": "todo item"
+                      }
+                    ]
+                  }
+                ]
+              },
+              {
+                "bullet": "Dash",
+                "checkbox": "On",
+                "term": null,
+                "content": [
+                  {
+                    "Paragraph": [
+                      {
+                        "Text": "done item"
+                      }
+                    ]
+                  }
+                ]
+              }
+            ]
+          }
+        }
+      ],
+      "children": []
+    },
+    {
+      "heading": {
+        "level": 1,
+        "todo": null,
+        "priority": null,
+        "title": [
+          {
+            "Text": "Links and code"
+          }
+        ],
+        "tags": [],
+        "properties": {
+          "entries": []
+        },
+        "id": null,
+        "custom_id": null
+      },
+      "content": [
+        {
+          "Paragraph": [
+            {
+              "Text": "An external "
+            },
+            {
+              "Link": {
+                "target": {
+                  "External": "https://example.org"
+                },
+                "description": [
+                  {
+                    "Text": "site"
+                  }
+                ]
+              }
+            },
+            {
+              "Text": " and a bare "
+            },
+            {
+              "Link": {
+                "target": {
+                  "External": "https://bare.example"
+                },
+                "description": null
+              }
+            },
+            {
+              "Text": "."
+            }
+          ]
+        },
+        {
+          "SrcBlock": {
+            "lang": "rust",
+            "params": {
+              "raw": ""
+            },
+            "code": "fn main() {\n    println!(\"hello\");\n}"
+          }
+        }
+      ],
+      "children": []
+    }
+  ]
+}
diff --git a/tests/snapshots/pipeline__core_html.snap b/tests/snapshots/pipeline__core_html.snap
new file mode 100644
index 0000000..c28838e
--- /dev/null
+++ b/tests/snapshots/pipeline__core_html.snap
@@ -0,0 +1,19 @@
+---
+source: tests/pipeline.rs
+expression: "render_fixture(\"core.org\")"
+---
+<p>Intro paragraph with a bare URL <a href="https://example.com">https://example.com</a> and some <code>inline code</code>.</p>
+<h1 id="ordered-and-checked">Ordered and checked</h1>
+<ol>
+<li>first item</li>
+<li>second item with <em>emphasis</em></li>
+</ol>
+<ul>
+<li><input type="checkbox" disabled> todo item</li>
+<li><input type="checkbox" disabled checked> done item</li>
+</ul>
+<h1 id="links-and-code">Links and code</h1>
+<p>An external <a href="https://example.org">site</a> and a bare <a href="https://bare.example">https://bare.example</a>.</p>
+<pre><code class="language-rust">fn main() {
+    println!("hello");
+}</code></pre>
diff --git a/tests/snapshots/pipeline__minimal_element_tree.snap b/tests/snapshots/pipeline__minimal_element_tree.snap
new file mode 100644
index 0000000..0aa1737
--- /dev/null
+++ b/tests/snapshots/pipeline__minimal_element_tree.snap
@@ -0,0 +1,180 @@
+---
+source: tests/pipeline.rs
+expression: "parse_fixture(\"minimal.org\").root"
+---
+{
+  "heading": null,
+  "content": [
+    {
+      "Paragraph": [
+        {
+          "Text": "A single paragraph of preamble text before any heading."
+        }
+      ]
+    }
+  ],
+  "children": [
+    {
+      "heading": {
+        "level": 1,
+        "todo": null,
+        "priority": null,
+        "title": [
+          {
+            "Text": "First Heading"
+          }
+        ],
+        "tags": [],
+        "properties": {
+          "entries": [
+            [
+              "CUSTOM_ID",
+              "first"
+            ]
+          ]
+        },
+        "id": null,
+        "custom_id": "first"
+      },
+      "content": [
+        {
+          "Paragraph": [
+            {
+              "Text": "Some body text with "
+            },
+            {
+              "Bold": [
+                {
+                  "Text": "bold"
+                }
+              ]
+            },
+            {
+              "Text": ", "
+            },
+            {
+              "Italic": [
+                {
+                  "Text": "italic"
+                }
+              ]
+            },
+            {
+              "Text": ", and "
+            },
+            {
+              "Verbatim": "verbatim"
+            },
+            {
+              "Text": "."
+            }
+          ]
+        }
+      ],
+      "children": [
+        {
+          "heading": {
+            "level": 2,
+            "todo": null,
+            "priority": null,
+            "title": [
+              {
+                "Text": "A Subheading"
+              }
+            ],
+            "tags": [
+              "tag1",
+              "tag2"
+            ],
+            "properties": {
+              "entries": []
+            },
+            "id": null,
+            "custom_id": null
+          },
+          "content": [
+            {
+              "List": {
+                "kind": "Unordered",
+                "items": [
+                  {
+                    "bullet": "Dash",
+                    "checkbox": null,
+                    "term": null,
+                    "content": [
+                      {
+                        "Paragraph": [
+                          {
+                            "Text": "an unordered item"
+                          }
+                        ]
+                      }
+                    ]
+                  },
+                  {
+                    "bullet": "Dash",
+                    "checkbox": null,
+                    "term": null,
+                    "content": [
+                      {
+                        "Paragraph": [
+                          {
+                            "Text": "another with a checkbox [ ]"
+                          }
+                        ]
+                      }
+                    ]
+                  }
+                ]
+              }
+            }
+          ],
+          "children": []
+        }
+      ]
+    },
+    {
+      "heading": {
+        "level": 1,
+        "todo": null,
+        "priority": null,
+        "title": [
+          {
+            "Text": "Second Heading"
+          }
+        ],
+        "tags": [],
+        "properties": {
+          "entries": []
+        },
+        "id": null,
+        "custom_id": null
+      },
+      "content": [
+        {
+          "Paragraph": [
+            {
+              "Text": "See "
+            },
+            {
+              "Link": {
+                "target": {
+                  "CustomId": "first"
+                },
+                "description": [
+                  {
+                    "Text": "the first heading"
+                  }
+                ]
+              }
+            },
+            {
+              "Text": "."
+            }
+          ]
+        }
+      ],
+      "children": []
+    }
+  ]
+}
diff --git a/tests/snapshots/pipeline__minimal_html.snap b/tests/snapshots/pipeline__minimal_html.snap
new file mode 100644
index 0000000..8a81d72
--- /dev/null
+++ b/tests/snapshots/pipeline__minimal_html.snap
@@ -0,0 +1,14 @@
+---
+source: tests/pipeline.rs
+expression: "render_fixture(\"minimal.org\")"
+---
+<p>A single paragraph of preamble text before any heading.</p>
+<h1 id="first">First Heading</h1>
+<p>Some body text with <strong>bold</strong>, <em>italic</em>, and <code class="verbatim">verbatim</code>.</p>
+<h2 id="a-subheading">A Subheading <span class="tag">tag1</span> <span class="tag">tag2</span></h2>
+<ul>
+<li>an unordered item</li>
+<li>another with a checkbox [ ]</li>
+</ul>
+<h1 id="second-heading">Second Heading</h1>
+<p>See <a href="#first">the first heading</a>.</p>
diff --git a/tests/snapshots/site__footnote_render.snap b/tests/snapshots/site__footnote_render.snap
new file mode 100644
index 0000000..5ad0725
--- /dev/null
+++ b/tests/snapshots/site__footnote_render.snap
@@ -0,0 +1,16 @@
+---
+source: tests/site.rs
+expression: "render_fragment(\"footnote.org\")"
+---
+<p>Text with a reference.<sup class="footnote-ref"><a id="fnr-1" href="#fn-1">1</a></sup> And a second one.<sup class="footnote-ref"><a id="fnr-2" href="#fn-2">2</a></sup></p>
+<p>An inline footnote.<sup class="footnote-ref"><a id="fnr-3" href="#fn-3">3</a></sup></p>
+<section class="footnotes">
+<hr>
+<ol>
+<li id="fn-1"><p>The first definition.</p>
+ <a class="footnote-back" href="#fnr-1">&#8617;</a></li>
+<li id="fn-2"><p>The second definition, with <em>emphasis</em>.</p>
+ <a class="footnote-back" href="#fnr-2">&#8617;</a></li>
+<li id="fn-3">defined right here <a class="footnote-back" href="#fnr-3">&#8617;</a></li>
+</ol>
+</section>
diff --git a/tests/snapshots/site__site_guide_html.snap b/tests/snapshots/site__site_guide_html.snap
new file mode 100644
index 0000000..4587729
--- /dev/null
+++ b/tests/snapshots/site__site_guide_html.snap
@@ -0,0 +1,39 @@
+---
+source: tests/site.rs
+expression: "page(&pages, \"guide.org\").html"
+---
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>Guide</title>
+</head>
+<body>
+<nav>
+<a href="about.html">About</a>
+<a href="#">Guide</a>
+<a href="index.html">Home</a>
+</nav>
+<main>
+<h1 id="setup">Setup</h1>
+<p>Install the steps in order.<sup class="footnote-ref"><a id="fnr-1" href="#fn-1">1</a></sup> Then return <a href="index.html">home</a>.</p>
+<h1 id="data">Data</h1>
+<table>
+<thead>
+<tr><th>Name</th><th>Score</th></tr>
+</thead>
+<tbody>
+<tr><td>alpha</td><td>10</td></tr>
+<tr><td>beta</td><td>20</td></tr>
+</tbody>
+</table>
+<section class="footnotes">
+<hr>
+<ol>
+<li id="fn-1"><p>Read the manual before you begin.</p>
+ <a class="footnote-back" href="#fnr-1">&#8617;</a></li>
+</ol>
+</section>
+</main>
+</body>
+</html>
diff --git a/tests/snapshots/site__site_index_html.snap b/tests/snapshots/site__site_index_html.snap
new file mode 100644
index 0000000..217d60f
--- /dev/null
+++ b/tests/snapshots/site__site_index_html.snap
@@ -0,0 +1,24 @@
+---
+source: tests/site.rs
+expression: "page(&pages, \"index.org\").html"
+---
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>Home</title>
+</head>
+<body>
+<nav>
+<a href="about.html">About</a>
+<a href="guide.html">Guide</a>
+<a href="#">Home</a>
+</nav>
+<main>
+<p>Welcome. See the <a href="guide.html">guide</a> and jump straight to its <a href="guide.html#setup">setup section</a> across files.</p>
+<p>Also see <a href="#overview">Overview</a> further down this page.</p>
+<h1 id="overview">Overview</h1>
+<p>The overview lives on the home page.</p>
+</main>
+</body>
+</html>
diff --git a/tests/snapshots/site__table_render.snap b/tests/snapshots/site__table_render.snap
new file mode 100644
index 0000000..8bdda0f
--- /dev/null
+++ b/tests/snapshots/site__table_render.snap
@@ -0,0 +1,13 @@
+---
+source: tests/site.rs
+expression: "render_fragment(\"table.org\")"
+---
+<table>
+<thead>
+<tr><th>Name</th><th>Score</th></tr>
+</thead>
+<tbody>
+<tr><td>alpha</td><td>10</td></tr>
+<tr><td>beta</td><td>20</td></tr>
+</tbody>
+</table>