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
.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(+) new file mode 100644 @@ -0,0 +1 @@ +/target new file mode 100644 @@ -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" new file mode 100644 @@ -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"] } new file mode 100644 @@ -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. new file mode 100644 @@ -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 new file mode 100644 @@ -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 + +----- new file mode 100644 @@ -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/. new file mode 100644 @@ -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]]. new file mode 100644 @@ -0,0 +1,3 @@ +#+TITLE: About + +This site is built by org-ssg. Back to [[file:index.org][home]]. new file mode 100644 @@ -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 | new file mode 100644 @@ -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. new file mode 100644 @@ -0,0 +1,2 @@ +body { font-family: system-ui, sans-serif; max-width: 40rem; margin: 2rem auto; } +nav a { margin-right: 1rem; } new file mode 100644 @@ -0,0 +1,6 @@ +#+TITLE: Table Sample + +| Name | Score | +|-------+-------| +| alpha | 10 | +| beta | 20 | new file mode 100644 @@ -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 +} new file mode 100644 @@ -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) = §ion.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 §ion.children { + collect_targets(child, out); + } +} + +fn index_section(section: &Section, path: &Utf8Path, targets: &mut HashMap<TargetId, TargetLocation>) { + if let Some(h) = §ion.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 §ion.children { + index_section(child, path, targets); + } +} new file mode 100644 @@ -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; new file mode 100644 @@ -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(()) +} new file mode 100644 @@ -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, +} new file mode 100644 @@ -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(¶.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, '(' | '[' | '{' | '<' | '"' | '\'') +} new file mode 100644 @@ -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(§ion.content, &mut self.block_defs); + for child in §ion.children { + self.collect_defs(child); + } + } + + fn render_section(&mut self, section: &Section, out: &mut String) { + if let Some(h) = §ion.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 §ion.content { + self.render_element(element, out); + } + for child in §ion.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}\">↩</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("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + _ => out.push(c), + } + } + out +} + +fn escape_attr(s: &str) -> String { + let mut out = escape_html(s); + out = out.replace('"', """); + out +} new file mode 100644 @@ -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. + } + } + } +} new file mode 100644 @@ -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() + }) +} new file mode 100644 @@ -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() + } +} new file mode 100644 @@ -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("/")) +} new file mode 100644 @@ -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); +} new file mode 100644 @@ -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"); +} new file mode 100644 @@ -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")); +} new file mode 100644 @@ -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")); +} new file mode 100644 @@ -0,0 +1,28 @@ +--- +source: tests/harness.rs +expression: tree +--- +[ + { + "Text": "see " + }, + { + "Bold": [ + { + "Text": "bold" + } + ] + }, + { + "Link": { + "target": { + "CustomId": "first" + }, + "description": [ + { + "Text": "the first heading" + } + ] + } + } +] new file mode 100644 @@ -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": [] + } + ] +} new file mode 100644 @@ -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> new file mode 100644 @@ -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": [] + } + ] +} new file mode 100644 @@ -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> new file mode 100644 @@ -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">↩</a></li> +<li id="fn-2"><p>The second definition, with <em>emphasis</em>.</p> + <a class="footnote-back" href="#fnr-2">↩</a></li> +<li id="fn-3">defined right here <a class="footnote-back" href="#fnr-3">↩</a></li> +</ol> +</section> new file mode 100644 @@ -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">↩</a></li> +</ol> +</section> +</main> +</body> +</html> new file mode 100644 @@ -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> new file mode 100644 @@ -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>