Commit 4db41537b5
Verified · cmc
Layout: unified · split
.gitignore added +1
| @@ -0,0 +1 @@ | ||
| 1 | /target | |
Cargo.lock added +991
| @@ -0,0 +1,991 @@ | ||
| 1 | # This file is automatically @generated by Cargo. | |
| 2 | # It is not intended for manual editing. | |
| 3 | version = 4 | |
| 4 | ||
| 5 | [[package]] | |
| 6 | name = "adler2" | |
| 7 | version = "2.0.1" | |
| 8 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 9 | checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" | |
| 10 | ||
| 11 | [[package]] | |
| 12 | name = "android_system_properties" | |
| 13 | version = "0.1.6" | |
| 14 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 15 | checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" | |
| 16 | dependencies = [ | |
| 17 | "libc", | |
| 18 | ] | |
| 19 | ||
| 20 | [[package]] | |
| 21 | name = "anstream" | |
| 22 | version = "1.0.0" | |
| 23 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 24 | checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" | |
| 25 | dependencies = [ | |
| 26 | "anstyle", | |
| 27 | "anstyle-parse", | |
| 28 | "anstyle-query", | |
| 29 | "anstyle-wincon", | |
| 30 | "colorchoice", | |
| 31 | "is_terminal_polyfill", | |
| 32 | "utf8parse", | |
| 33 | ] | |
| 34 | ||
| 35 | [[package]] | |
| 36 | name = "anstyle" | |
| 37 | version = "1.0.14" | |
| 38 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 39 | checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" | |
| 40 | ||
| 41 | [[package]] | |
| 42 | name = "anstyle-parse" | |
| 43 | version = "1.0.0" | |
| 44 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 45 | checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" | |
| 46 | dependencies = [ | |
| 47 | "utf8parse", | |
| 48 | ] | |
| 49 | ||
| 50 | [[package]] | |
| 51 | name = "anstyle-query" | |
| 52 | version = "1.1.5" | |
| 53 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 54 | checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" | |
| 55 | dependencies = [ | |
| 56 | "windows-sys", | |
| 57 | ] | |
| 58 | ||
| 59 | [[package]] | |
| 60 | name = "anstyle-wincon" | |
| 61 | version = "3.0.11" | |
| 62 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 63 | checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" | |
| 64 | dependencies = [ | |
| 65 | "anstyle", | |
| 66 | "once_cell_polyfill", | |
| 67 | "windows-sys", | |
| 68 | ] | |
| 69 | ||
| 70 | [[package]] | |
| 71 | name = "anyhow" | |
| 72 | version = "1.0.104" | |
| 73 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 74 | checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" | |
| 75 | ||
| 76 | [[package]] | |
| 77 | name = "arrayref" | |
| 78 | version = "0.3.9" | |
| 79 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 80 | checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" | |
| 81 | ||
| 82 | [[package]] | |
| 83 | name = "arrayvec" | |
| 84 | version = "0.7.8" | |
| 85 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 86 | checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" | |
| 87 | ||
| 88 | [[package]] | |
| 89 | name = "autocfg" | |
| 90 | version = "1.5.1" | |
| 91 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 92 | checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" | |
| 93 | ||
| 94 | [[package]] | |
| 95 | name = "base64" | |
| 96 | version = "0.22.1" | |
| 97 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 98 | checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" | |
| 99 | ||
| 100 | [[package]] | |
| 101 | name = "bincode" | |
| 102 | version = "1.3.3" | |
| 103 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 104 | checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" | |
| 105 | dependencies = [ | |
| 106 | "serde", | |
| 107 | ] | |
| 108 | ||
| 109 | [[package]] | |
| 110 | name = "bitflags" | |
| 111 | version = "2.13.1" | |
| 112 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 113 | checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" | |
| 114 | ||
| 115 | [[package]] | |
| 116 | name = "blake3" | |
| 117 | version = "1.8.6" | |
| 118 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 119 | checksum = "76ae7bad254120e9e4c63bafc385310756f90c484eac0e36b8317cf09cb92a77" | |
| 120 | dependencies = [ | |
| 121 | "arrayref", | |
| 122 | "arrayvec", | |
| 123 | "cc", | |
| 124 | "cfg-if", | |
| 125 | "constant_time_eq", | |
| 126 | "cpufeatures", | |
| 127 | ] | |
| 128 | ||
| 129 | [[package]] | |
| 130 | name = "bumpalo" | |
| 131 | version = "3.20.3" | |
| 132 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 133 | checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" | |
| 134 | ||
| 135 | [[package]] | |
| 136 | name = "camino" | |
| 137 | version = "1.2.5" | |
| 138 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 139 | checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d" | |
| 140 | dependencies = [ | |
| 141 | "serde_core", | |
| 142 | ] | |
| 143 | ||
| 144 | [[package]] | |
| 145 | name = "cc" | |
| 146 | version = "1.4.2" | |
| 147 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 148 | checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" | |
| 149 | dependencies = [ | |
| 150 | "find-msvc-tools", | |
| 151 | "shlex", | |
| 152 | ] | |
| 153 | ||
| 154 | [[package]] | |
| 155 | name = "cfg-if" | |
| 156 | version = "1.0.4" | |
| 157 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 158 | checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" | |
| 159 | ||
| 160 | [[package]] | |
| 161 | name = "chrono" | |
| 162 | version = "0.4.45" | |
| 163 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 164 | checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" | |
| 165 | dependencies = [ | |
| 166 | "iana-time-zone", | |
| 167 | "js-sys", | |
| 168 | "num-traits", | |
| 169 | "serde", | |
| 170 | "wasm-bindgen", | |
| 171 | "windows-link", | |
| 172 | ] | |
| 173 | ||
| 174 | [[package]] | |
| 175 | name = "clap" | |
| 176 | version = "4.6.6" | |
| 177 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 178 | checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" | |
| 179 | dependencies = [ | |
| 180 | "clap_builder", | |
| 181 | "clap_derive", | |
| 182 | ] | |
| 183 | ||
| 184 | [[package]] | |
| 185 | name = "clap_builder" | |
| 186 | version = "4.6.6" | |
| 187 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 188 | checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" | |
| 189 | dependencies = [ | |
| 190 | "anstream", | |
| 191 | "anstyle", | |
| 192 | "clap_lex", | |
| 193 | "strsim", | |
| 194 | ] | |
| 195 | ||
| 196 | [[package]] | |
| 197 | name = "clap_derive" | |
| 198 | version = "4.6.4" | |
| 199 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 200 | checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" | |
| 201 | dependencies = [ | |
| 202 | "heck", | |
| 203 | "proc-macro2", | |
| 204 | "quote", | |
| 205 | "syn 3.0.3", | |
| 206 | ] | |
| 207 | ||
| 208 | [[package]] | |
| 209 | name = "clap_lex" | |
| 210 | version = "1.1.0" | |
| 211 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 212 | checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" | |
| 213 | ||
| 214 | [[package]] | |
| 215 | name = "colorchoice" | |
| 216 | version = "1.0.5" | |
| 217 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 218 | checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" | |
| 219 | ||
| 220 | [[package]] | |
| 221 | name = "console" | |
| 222 | version = "0.16.4" | |
| 223 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 224 | checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" | |
| 225 | dependencies = [ | |
| 226 | "encode_unicode", | |
| 227 | "libc", | |
| 228 | "windows-sys", | |
| 229 | ] | |
| 230 | ||
| 231 | [[package]] | |
| 232 | name = "constant_time_eq" | |
| 233 | version = "0.4.2" | |
| 234 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 235 | checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" | |
| 236 | ||
| 237 | [[package]] | |
| 238 | name = "core-foundation-sys" | |
| 239 | version = "0.8.7" | |
| 240 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 241 | checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" | |
| 242 | ||
| 243 | [[package]] | |
| 244 | name = "cpufeatures" | |
| 245 | version = "0.3.0" | |
| 246 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 247 | checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" | |
| 248 | dependencies = [ | |
| 249 | "libc", | |
| 250 | ] | |
| 251 | ||
| 252 | [[package]] | |
| 253 | name = "crc32fast" | |
| 254 | version = "1.5.0" | |
| 255 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 256 | checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" | |
| 257 | dependencies = [ | |
| 258 | "cfg-if", | |
| 259 | ] | |
| 260 | ||
| 261 | [[package]] | |
| 262 | name = "deranged" | |
| 263 | version = "0.5.8" | |
| 264 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 265 | checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" | |
| 266 | ||
| 267 | [[package]] | |
| 268 | name = "encode_unicode" | |
| 269 | version = "1.0.0" | |
| 270 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 271 | checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" | |
| 272 | ||
| 273 | [[package]] | |
| 274 | name = "equivalent" | |
| 275 | version = "1.0.2" | |
| 276 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 277 | checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" | |
| 278 | ||
| 279 | [[package]] | |
| 280 | name = "errno" | |
| 281 | version = "0.3.14" | |
| 282 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 283 | checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" | |
| 284 | dependencies = [ | |
| 285 | "libc", | |
| 286 | "windows-sys", | |
| 287 | ] | |
| 288 | ||
| 289 | [[package]] | |
| 290 | name = "fastrand" | |
| 291 | version = "2.5.0" | |
| 292 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 293 | checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" | |
| 294 | ||
| 295 | [[package]] | |
| 296 | name = "find-msvc-tools" | |
| 297 | version = "0.1.10" | |
| 298 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 299 | checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" | |
| 300 | ||
| 301 | [[package]] | |
| 302 | name = "flate2" | |
| 303 | version = "1.1.9" | |
| 304 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 305 | checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" | |
| 306 | dependencies = [ | |
| 307 | "crc32fast", | |
| 308 | "miniz_oxide", | |
| 309 | ] | |
| 310 | ||
| 311 | [[package]] | |
| 312 | name = "fnv" | |
| 313 | version = "1.0.7" | |
| 314 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 315 | checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" | |
| 316 | ||
| 317 | [[package]] | |
| 318 | name = "futures-core" | |
| 319 | version = "0.3.33" | |
| 320 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 321 | checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" | |
| 322 | ||
| 323 | [[package]] | |
| 324 | name = "futures-task" | |
| 325 | version = "0.3.33" | |
| 326 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 327 | checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" | |
| 328 | ||
| 329 | [[package]] | |
| 330 | name = "futures-util" | |
| 331 | version = "0.3.33" | |
| 332 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 333 | checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" | |
| 334 | dependencies = [ | |
| 335 | "futures-core", | |
| 336 | "futures-task", | |
| 337 | "pin-project-lite", | |
| 338 | "slab", | |
| 339 | ] | |
| 340 | ||
| 341 | [[package]] | |
| 342 | name = "getrandom" | |
| 343 | version = "0.4.3" | |
| 344 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 345 | checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" | |
| 346 | dependencies = [ | |
| 347 | "cfg-if", | |
| 348 | "libc", | |
| 349 | "r-efi", | |
| 350 | ] | |
| 351 | ||
| 352 | [[package]] | |
| 353 | name = "hashbrown" | |
| 354 | version = "0.17.1" | |
| 355 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 356 | checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" | |
| 357 | ||
| 358 | [[package]] | |
| 359 | name = "heck" | |
| 360 | version = "0.5.0" | |
| 361 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 362 | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" | |
| 363 | ||
| 364 | [[package]] | |
| 365 | name = "iana-time-zone" | |
| 366 | version = "0.1.65" | |
| 367 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 368 | checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" | |
| 369 | dependencies = [ | |
| 370 | "android_system_properties", | |
| 371 | "core-foundation-sys", | |
| 372 | "iana-time-zone-haiku", | |
| 373 | "js-sys", | |
| 374 | "log", | |
| 375 | "wasm-bindgen", | |
| 376 | "windows-core", | |
| 377 | ] | |
| 378 | ||
| 379 | [[package]] | |
| 380 | name = "iana-time-zone-haiku" | |
| 381 | version = "0.1.2" | |
| 382 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 383 | checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" | |
| 384 | dependencies = [ | |
| 385 | "cc", | |
| 386 | ] | |
| 387 | ||
| 388 | [[package]] | |
| 389 | name = "indexmap" | |
| 390 | version = "2.14.0" | |
| 391 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 392 | checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" | |
| 393 | dependencies = [ | |
| 394 | "equivalent", | |
| 395 | "hashbrown", | |
| 396 | ] | |
| 397 | ||
| 398 | [[package]] | |
| 399 | name = "insta" | |
| 400 | version = "1.48.0" | |
| 401 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 402 | checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82" | |
| 403 | dependencies = [ | |
| 404 | "console", | |
| 405 | "once_cell", | |
| 406 | "serde", | |
| 407 | "similar", | |
| 408 | "tempfile", | |
| 409 | ] | |
| 410 | ||
| 411 | [[package]] | |
| 412 | name = "is_terminal_polyfill" | |
| 413 | version = "1.70.2" | |
| 414 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 415 | checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" | |
| 416 | ||
| 417 | [[package]] | |
| 418 | name = "itoa" | |
| 419 | version = "1.0.18" | |
| 420 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 421 | checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" | |
| 422 | ||
| 423 | [[package]] | |
| 424 | name = "js-sys" | |
| 425 | version = "0.3.104" | |
| 426 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 427 | checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" | |
| 428 | dependencies = [ | |
| 429 | "cfg-if", | |
| 430 | "futures-util", | |
| 431 | "wasm-bindgen", | |
| 432 | ] | |
| 433 | ||
| 434 | [[package]] | |
| 435 | name = "libc" | |
| 436 | version = "0.2.189" | |
| 437 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 438 | checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" | |
| 439 | ||
| 440 | [[package]] | |
| 441 | name = "linked-hash-map" | |
| 442 | version = "0.5.6" | |
| 443 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 444 | checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" | |
| 445 | ||
| 446 | [[package]] | |
| 447 | name = "linux-raw-sys" | |
| 448 | version = "0.12.1" | |
| 449 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 450 | checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" | |
| 451 | ||
| 452 | [[package]] | |
| 453 | name = "log" | |
| 454 | version = "0.4.33" | |
| 455 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 456 | checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" | |
| 457 | ||
| 458 | [[package]] | |
| 459 | name = "memchr" | |
| 460 | version = "2.8.3" | |
| 461 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 462 | checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" | |
| 463 | ||
| 464 | [[package]] | |
| 465 | name = "memo-map" | |
| 466 | version = "0.3.3" | |
| 467 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 468 | checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b" | |
| 469 | ||
| 470 | [[package]] | |
| 471 | name = "minijinja" | |
| 472 | version = "2.23.0" | |
| 473 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 474 | checksum = "42d74234349a775546a83af0f0c0c0e3a73227dee4950a542cda81a47240b3e6" | |
| 475 | dependencies = [ | |
| 476 | "memo-map", | |
| 477 | "serde", | |
| 478 | ] | |
| 479 | ||
| 480 | [[package]] | |
| 481 | name = "miniz_oxide" | |
| 482 | version = "0.8.9" | |
| 483 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 484 | checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" | |
| 485 | dependencies = [ | |
| 486 | "adler2", | |
| 487 | "simd-adler32", | |
| 488 | ] | |
| 489 | ||
| 490 | [[package]] | |
| 491 | name = "num-conv" | |
| 492 | version = "0.2.2" | |
| 493 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 494 | checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" | |
| 495 | ||
| 496 | [[package]] | |
| 497 | name = "num-traits" | |
| 498 | version = "0.2.19" | |
| 499 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 500 | checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" | |
| 501 | dependencies = [ | |
| 502 | "autocfg", | |
| 503 | ] | |
| 504 | ||
| 505 | [[package]] | |
| 506 | name = "once_cell" | |
| 507 | version = "1.21.4" | |
| 508 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 509 | checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" | |
| 510 | ||
| 511 | [[package]] | |
| 512 | name = "once_cell_polyfill" | |
| 513 | version = "1.70.2" | |
| 514 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 515 | checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" | |
| 516 | ||
| 517 | [[package]] | |
| 518 | name = "onig" | |
| 519 | version = "6.5.3" | |
| 520 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 521 | checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2" | |
| 522 | dependencies = [ | |
| 523 | "bitflags", | |
| 524 | "libc", | |
| 525 | "once_cell", | |
| 526 | "onig_sys", | |
| 527 | ] | |
| 528 | ||
| 529 | [[package]] | |
| 530 | name = "onig_sys" | |
| 531 | version = "69.9.3" | |
| 532 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 533 | checksum = "1e68317604e77e53b85896388e1a803c1d21b74c899ec9e5e1112db90735edd7" | |
| 534 | dependencies = [ | |
| 535 | "cc", | |
| 536 | "pkg-config", | |
| 537 | ] | |
| 538 | ||
| 539 | [[package]] | |
| 540 | name = "org-ssg" | |
| 541 | version = "0.3.0" | |
| 542 | dependencies = [ | |
| 543 | "anyhow", | |
| 544 | "blake3", | |
| 545 | "camino", | |
| 546 | "chrono", | |
| 547 | "clap", | |
| 548 | "insta", | |
| 549 | "minijinja", | |
| 550 | "serde", | |
| 551 | "serde_json", | |
| 552 | "syntect", | |
| 553 | "thiserror", | |
| 554 | "walkdir", | |
| 555 | ] | |
| 556 | ||
| 557 | [[package]] | |
| 558 | name = "pin-project-lite" | |
| 559 | version = "0.2.17" | |
| 560 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 561 | checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" | |
| 562 | ||
| 563 | [[package]] | |
| 564 | name = "pkg-config" | |
| 565 | version = "0.3.33" | |
| 566 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 567 | checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" | |
| 568 | ||
| 569 | [[package]] | |
| 570 | name = "plist" | |
| 571 | version = "1.10.0" | |
| 572 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 573 | checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" | |
| 574 | dependencies = [ | |
| 575 | "base64", | |
| 576 | "indexmap", | |
| 577 | "quick-xml", | |
| 578 | "serde", | |
| 579 | "time", | |
| 580 | ] | |
| 581 | ||
| 582 | [[package]] | |
| 583 | name = "powerfmt" | |
| 584 | version = "0.2.0" | |
| 585 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 586 | checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" | |
| 587 | ||
| 588 | [[package]] | |
| 589 | name = "proc-macro2" | |
| 590 | version = "1.0.107" | |
| 591 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 592 | checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" | |
| 593 | dependencies = [ | |
| 594 | "unicode-ident", | |
| 595 | ] | |
| 596 | ||
| 597 | [[package]] | |
| 598 | name = "quick-xml" | |
| 599 | version = "0.41.0" | |
| 600 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 601 | checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" | |
| 602 | dependencies = [ | |
| 603 | "memchr", | |
| 604 | ] | |
| 605 | ||
| 606 | [[package]] | |
| 607 | name = "quote" | |
| 608 | version = "1.0.47" | |
| 609 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 610 | checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" | |
| 611 | dependencies = [ | |
| 612 | "proc-macro2", | |
| 613 | ] | |
| 614 | ||
| 615 | [[package]] | |
| 616 | name = "r-efi" | |
| 617 | version = "6.0.0" | |
| 618 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 619 | checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" | |
| 620 | ||
| 621 | [[package]] | |
| 622 | name = "regex-syntax" | |
| 623 | version = "0.8.11" | |
| 624 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 625 | checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" | |
| 626 | ||
| 627 | [[package]] | |
| 628 | name = "rustix" | |
| 629 | version = "1.1.4" | |
| 630 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 631 | checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" | |
| 632 | dependencies = [ | |
| 633 | "bitflags", | |
| 634 | "errno", | |
| 635 | "libc", | |
| 636 | "linux-raw-sys", | |
| 637 | "windows-sys", | |
| 638 | ] | |
| 639 | ||
| 640 | [[package]] | |
| 641 | name = "rustversion" | |
| 642 | version = "1.0.23" | |
| 643 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 644 | checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" | |
| 645 | ||
| 646 | [[package]] | |
| 647 | name = "same-file" | |
| 648 | version = "1.0.6" | |
| 649 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 650 | checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" | |
| 651 | dependencies = [ | |
| 652 | "winapi-util", | |
| 653 | ] | |
| 654 | ||
| 655 | [[package]] | |
| 656 | name = "serde" | |
| 657 | version = "1.0.229" | |
| 658 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 659 | checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" | |
| 660 | dependencies = [ | |
| 661 | "serde_core", | |
| 662 | "serde_derive", | |
| 663 | ] | |
| 664 | ||
| 665 | [[package]] | |
| 666 | name = "serde_core" | |
| 667 | version = "1.0.229" | |
| 668 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 669 | checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" | |
| 670 | dependencies = [ | |
| 671 | "serde_derive", | |
| 672 | ] | |
| 673 | ||
| 674 | [[package]] | |
| 675 | name = "serde_derive" | |
| 676 | version = "1.0.229" | |
| 677 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 678 | checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" | |
| 679 | dependencies = [ | |
| 680 | "proc-macro2", | |
| 681 | "quote", | |
| 682 | "syn 3.0.3", | |
| 683 | ] | |
| 684 | ||
| 685 | [[package]] | |
| 686 | name = "serde_json" | |
| 687 | version = "1.0.151" | |
| 688 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 689 | checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" | |
| 690 | dependencies = [ | |
| 691 | "itoa", | |
| 692 | "memchr", | |
| 693 | "serde", | |
| 694 | "serde_core", | |
| 695 | "zmij", | |
| 696 | ] | |
| 697 | ||
| 698 | [[package]] | |
| 699 | name = "shlex" | |
| 700 | version = "2.0.1" | |
| 701 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 702 | checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" | |
| 703 | ||
| 704 | [[package]] | |
| 705 | name = "simd-adler32" | |
| 706 | version = "0.3.10" | |
| 707 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 708 | checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" | |
| 709 | ||
| 710 | [[package]] | |
| 711 | name = "similar" | |
| 712 | version = "2.7.0" | |
| 713 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 714 | checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" | |
| 715 | ||
| 716 | [[package]] | |
| 717 | name = "slab" | |
| 718 | version = "0.4.12" | |
| 719 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 720 | checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" | |
| 721 | ||
| 722 | [[package]] | |
| 723 | name = "strsim" | |
| 724 | version = "0.11.1" | |
| 725 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 726 | checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" | |
| 727 | ||
| 728 | [[package]] | |
| 729 | name = "syn" | |
| 730 | version = "2.0.119" | |
| 731 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 732 | checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" | |
| 733 | dependencies = [ | |
| 734 | "proc-macro2", | |
| 735 | "quote", | |
| 736 | "unicode-ident", | |
| 737 | ] | |
| 738 | ||
| 739 | [[package]] | |
| 740 | name = "syn" | |
| 741 | version = "3.0.3" | |
| 742 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 743 | checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" | |
| 744 | dependencies = [ | |
| 745 | "proc-macro2", | |
| 746 | "quote", | |
| 747 | "unicode-ident", | |
| 748 | ] | |
| 749 | ||
| 750 | [[package]] | |
| 751 | name = "syntect" | |
| 752 | version = "5.3.0" | |
| 753 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 754 | checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" | |
| 755 | dependencies = [ | |
| 756 | "bincode", | |
| 757 | "flate2", | |
| 758 | "fnv", | |
| 759 | "once_cell", | |
| 760 | "onig", | |
| 761 | "plist", | |
| 762 | "regex-syntax", | |
| 763 | "serde", | |
| 764 | "serde_derive", | |
| 765 | "serde_json", | |
| 766 | "thiserror", | |
| 767 | "walkdir", | |
| 768 | "yaml-rust", | |
| 769 | ] | |
| 770 | ||
| 771 | [[package]] | |
| 772 | name = "tempfile" | |
| 773 | version = "3.27.0" | |
| 774 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 775 | checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" | |
| 776 | dependencies = [ | |
| 777 | "fastrand", | |
| 778 | "getrandom", | |
| 779 | "once_cell", | |
| 780 | "rustix", | |
| 781 | "windows-sys", | |
| 782 | ] | |
| 783 | ||
| 784 | [[package]] | |
| 785 | name = "thiserror" | |
| 786 | version = "2.0.20" | |
| 787 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 788 | checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" | |
| 789 | dependencies = [ | |
| 790 | "thiserror-impl", | |
| 791 | ] | |
| 792 | ||
| 793 | [[package]] | |
| 794 | name = "thiserror-impl" | |
| 795 | version = "2.0.20" | |
| 796 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 797 | checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" | |
| 798 | dependencies = [ | |
| 799 | "proc-macro2", | |
| 800 | "quote", | |
| 801 | "syn 3.0.3", | |
| 802 | ] | |
| 803 | ||
| 804 | [[package]] | |
| 805 | name = "time" | |
| 806 | version = "0.3.55" | |
| 807 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 808 | checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" | |
| 809 | dependencies = [ | |
| 810 | "deranged", | |
| 811 | "num-conv", | |
| 812 | "powerfmt", | |
| 813 | "serde_core", | |
| 814 | "time-core", | |
| 815 | "time-macros", | |
| 816 | ] | |
| 817 | ||
| 818 | [[package]] | |
| 819 | name = "time-core" | |
| 820 | version = "0.1.9" | |
| 821 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 822 | checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" | |
| 823 | ||
| 824 | [[package]] | |
| 825 | name = "time-macros" | |
| 826 | version = "0.2.32" | |
| 827 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 828 | checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" | |
| 829 | dependencies = [ | |
| 830 | "num-conv", | |
| 831 | "time-core", | |
| 832 | ] | |
| 833 | ||
| 834 | [[package]] | |
| 835 | name = "unicode-ident" | |
| 836 | version = "1.0.24" | |
| 837 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 838 | checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" | |
| 839 | ||
| 840 | [[package]] | |
| 841 | name = "utf8parse" | |
| 842 | version = "0.2.2" | |
| 843 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 844 | checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" | |
| 845 | ||
| 846 | [[package]] | |
| 847 | name = "walkdir" | |
| 848 | version = "2.5.0" | |
| 849 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 850 | checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" | |
| 851 | dependencies = [ | |
| 852 | "same-file", | |
| 853 | "winapi-util", | |
| 854 | ] | |
| 855 | ||
| 856 | [[package]] | |
| 857 | name = "wasm-bindgen" | |
| 858 | version = "0.2.127" | |
| 859 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 860 | checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" | |
| 861 | dependencies = [ | |
| 862 | "cfg-if", | |
| 863 | "once_cell", | |
| 864 | "rustversion", | |
| 865 | "wasm-bindgen-macro", | |
| 866 | "wasm-bindgen-shared", | |
| 867 | ] | |
| 868 | ||
| 869 | [[package]] | |
| 870 | name = "wasm-bindgen-macro" | |
| 871 | version = "0.2.127" | |
| 872 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 873 | checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" | |
| 874 | dependencies = [ | |
| 875 | "quote", | |
| 876 | "wasm-bindgen-macro-support", | |
| 877 | ] | |
| 878 | ||
| 879 | [[package]] | |
| 880 | name = "wasm-bindgen-macro-support" | |
| 881 | version = "0.2.127" | |
| 882 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 883 | checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" | |
| 884 | dependencies = [ | |
| 885 | "bumpalo", | |
| 886 | "proc-macro2", | |
| 887 | "quote", | |
| 888 | "syn 2.0.119", | |
| 889 | "wasm-bindgen-shared", | |
| 890 | ] | |
| 891 | ||
| 892 | [[package]] | |
| 893 | name = "wasm-bindgen-shared" | |
| 894 | version = "0.2.127" | |
| 895 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 896 | checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" | |
| 897 | dependencies = [ | |
| 898 | "unicode-ident", | |
| 899 | ] | |
| 900 | ||
| 901 | [[package]] | |
| 902 | name = "winapi-util" | |
| 903 | version = "0.1.11" | |
| 904 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 905 | checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" | |
| 906 | dependencies = [ | |
| 907 | "windows-sys", | |
| 908 | ] | |
| 909 | ||
| 910 | [[package]] | |
| 911 | name = "windows-core" | |
| 912 | version = "0.62.2" | |
| 913 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 914 | checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" | |
| 915 | dependencies = [ | |
| 916 | "windows-implement", | |
| 917 | "windows-interface", | |
| 918 | "windows-link", | |
| 919 | "windows-result", | |
| 920 | "windows-strings", | |
| 921 | ] | |
| 922 | ||
| 923 | [[package]] | |
| 924 | name = "windows-implement" | |
| 925 | version = "0.60.2" | |
| 926 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 927 | checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" | |
| 928 | dependencies = [ | |
| 929 | "proc-macro2", | |
| 930 | "quote", | |
| 931 | "syn 2.0.119", | |
| 932 | ] | |
| 933 | ||
| 934 | [[package]] | |
| 935 | name = "windows-interface" | |
| 936 | version = "0.59.3" | |
| 937 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 938 | checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" | |
| 939 | dependencies = [ | |
| 940 | "proc-macro2", | |
| 941 | "quote", | |
| 942 | "syn 2.0.119", | |
| 943 | ] | |
| 944 | ||
| 945 | [[package]] | |
| 946 | name = "windows-link" | |
| 947 | version = "0.2.1" | |
| 948 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 949 | checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" | |
| 950 | ||
| 951 | [[package]] | |
| 952 | name = "windows-result" | |
| 953 | version = "0.4.1" | |
| 954 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 955 | checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" | |
| 956 | dependencies = [ | |
| 957 | "windows-link", | |
| 958 | ] | |
| 959 | ||
| 960 | [[package]] | |
| 961 | name = "windows-strings" | |
| 962 | version = "0.5.1" | |
| 963 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 964 | checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" | |
| 965 | dependencies = [ | |
| 966 | "windows-link", | |
| 967 | ] | |
| 968 | ||
| 969 | [[package]] | |
| 970 | name = "windows-sys" | |
| 971 | version = "0.61.2" | |
| 972 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 973 | checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" | |
| 974 | dependencies = [ | |
| 975 | "windows-link", | |
| 976 | ] | |
| 977 | ||
| 978 | [[package]] | |
| 979 | name = "yaml-rust" | |
| 980 | version = "0.4.5" | |
| 981 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 982 | checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" | |
| 983 | dependencies = [ | |
| 984 | "linked-hash-map", | |
| 985 | ] | |
| 986 | ||
| 987 | [[package]] | |
| 988 | name = "zmij" | |
| 989 | version = "1.0.23" | |
| 990 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 991 | checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" | |
Cargo.toml added +35
| @@ -0,0 +1,35 @@ | ||
| 1 | [package] | |
| 2 | name = "org-ssg" | |
| 3 | version = "0.3.0" | |
| 4 | edition = "2021" | |
| 5 | description = "Org-mode static site generator that renders the org element tree straight to HTML" | |
| 6 | license = "MIT" | |
| 7 | ||
| 8 | [lib] | |
| 9 | name = "org_ssg" | |
| 10 | path = "src/lib.rs" | |
| 11 | ||
| 12 | [[bin]] | |
| 13 | name = "org-ssg" | |
| 14 | path = "src/main.rs" | |
| 15 | ||
| 16 | [dependencies] | |
| 17 | # Parsing / model | |
| 18 | serde = { version = "1", features = ["derive"] } | |
| 19 | serde_json = "1" | |
| 20 | chrono = { version = "0.4", features = ["serde"] } | |
| 21 | camino = { version = "1", features = ["serde1"] } | |
| 22 | ||
| 23 | # Pipeline | |
| 24 | walkdir = "2" | |
| 25 | syntect = "5" | |
| 26 | minijinja = "2" | |
| 27 | blake3 = "1" | |
| 28 | ||
| 29 | # CLI / errors | |
| 30 | clap = { version = "4", features = ["derive"] } | |
| 31 | anyhow = "1" | |
| 32 | thiserror = "2" | |
| 33 | ||
| 34 | [dev-dependencies] | |
| 35 | insta = { version = "1", features = ["json"] } | |
README.md added +164
| @@ -0,0 +1,164 @@ | ||
| 1 | # org-ssg | |
| 2 | ||
| 3 | An org-mode static site generator, in Rust. Org is treated as the *source language*, | |
| 4 | not an inconvenient input to be normalized into markdown. The org element tree — | |
| 5 | headings, drawers, blocks, links with their org-specific semantics — **is** the | |
| 6 | document model, and we render that tree straight to HTML. We never round-trip through | |
| 7 | a markdown-shaped intermediate representation, because the point is to preserve what | |
| 8 | markdown cannot express: property drawers, TODO/priority/tag metadata on headings, | |
| 9 | `#+` directives, ID links, named/captioned blocks, footnote semantics. | |
| 10 | ||
| 11 | The one non-obvious early commitment is **incremental builds keyed on content | |
| 12 | hashing**, treated as a first-class architectural concern from day one. The discipline | |
| 13 | it imposes on the data model — pure, hashable, dependency-tracked units — is the real | |
| 14 | deliverable, even while the corpus is small enough that a full rebuild is instant. | |
| 15 | ||
| 16 | ## Pipeline | |
| 17 | ||
| 18 | ``` | |
| 19 | DISCOVER → PARSE → INDEX → RESOLVE → RENDER → TEMPLATE → EMIT | |
| 20 | ``` | |
| 21 | ||
| 22 | PARSE and RENDER are pure functions of their inputs (cacheable, hashable). INDEX/RESOLVE | |
| 23 | is the only inherently global stage — it is where the link dependency graph is born. | |
| 24 | ||
| 25 | | Stage | Module | Notes | | |
| 26 | |---|---|---| | |
| 27 | | PARSE | `src/parser.rs` | Hand-written recursive descent: line lexer → element builder → inline tokenizer. | | |
| 28 | | model | `src/model.rs` | The org element tree — Elements (block) vs Objects (inline). | | |
| 29 | | INDEX | `src/index.rs` | Collect link targets into a symbol table. | | |
| 30 | | RESOLVE | `src/resolve.rs` | Rewrite links to URLs; return the used-target list (dependency edges). | | |
| 31 | | RENDER | `src/render.rs` | Tree → HTML fragment; syntect highlighting; footnote two-pass. | | |
| 32 | | TEMPLATE | `src/template.rs` | minijinja: fragment + metadata → full page. | | |
| 33 | | incremental | `src/incremental.rs` | Content/config/template hashing, dep graph, cache manifest, invalidation. | | |
| 34 | ||
| 35 | ## v1 scope (recommended; must be reconciled against a corpus audit first) | |
| 36 | ||
| 37 | **IN — v1 must handle:** headings with nesting; TODO keywords; priorities `[#A]`; tags; | |
| 38 | property drawers; plain lists (unordered/ordered/description, checkboxes, nesting); | |
| 39 | tables (with rule rows, no `#+TBLFM:`); source blocks with syntax highlighting; | |
| 40 | example/quote/center blocks; links (external, internal `[[*Heading]]`/`[[#custom-id]]`, | |
| 41 | `id:`); footnotes (inline and referenced); `#+` keywords/directives; inline markup | |
| 42 | (bold/italic/underline/verbatim/code/strike); timestamps (active/inactive, ranges); | |
| 43 | paragraphs and horizontal rules; images with `#+CAPTION`/`#+ATTR_HTML`. | |
| 44 | ||
| 45 | **OUT — explicitly not v1 (parse-and-ignore or reject loudly):** Babel execution / | |
| 46 | `:results`; `#+TBLFM:` formulas; LaTeX / MathJax; `#+INCLUDE:`; radio targets and | |
| 47 | macros; drawers other than PROPERTIES/LOGBOOK; column view / clocking / agenda | |
| 48 | semantics; non-HTML export blocks; the full Unicode entity set. | |
| 49 | ||
| 50 | **Scope guardrail:** every IN item gets a golden-file fixture from a real document; | |
| 51 | every OUT item gets a test asserting it degrades predictably (ignored, no crash). The | |
| 52 | IN/OUT line is enforced by tests, defending against the project's #1 risk: scope creep | |
| 53 | back toward all-of-org. | |
| 54 | ||
| 55 | ## Phase plan | |
| 56 | ||
| 57 | | Phase | Scope | Status | | |
| 58 | |---|---|---| | |
| 59 | | **M0** | **Buildable skeleton: crate layout, module stubs, deps, test harness, fixtures** | **done** | | |
| 60 | | **v0.1** | **End-to-end core parse → render: `build` a single `.org` file to HTML** | **done** | | |
| 61 | | **v0.2** | **Multi-file SITE build: INDEX + RESOLVE internal links, minijinja templates, `build <src-dir> <out-dir>`, tables + footnotes** | **done** | | |
| 62 | | **v0.3** | **Incremental build layer: content/config/template hashing, dependency graph, per-page render keys, persisted cache manifest, invalidation** | **done** | | |
| 63 | | 0 | Corpus audit + `emacs --batch` ground-truth oracle | todo | | |
| 64 | | 1 | Line lexer + heading/section skeleton | done | | |
| 65 | | 2 | Block elements — lists, source blocks, tables, footnote defs done; generic drawers | partial | | |
| 66 | | 3 | Inline objects — emphasis, links, bare URLs, footnote refs done; timestamps | partial | | |
| 67 | | 4 | Rendering to HTML — tree walk, tables, footnote two-pass, minijinja templating done; real syntect highlighting | partial | | |
| 68 | | 5 | Link resolution + symbol table (INDEX + RESOLVE, used-target list, broken-link reporting) | done | | |
| 69 | | 6 | Incremental build layer (hashing, dep graph, invalidation) done; `watch` is a simple poll loop | done | | |
| 70 | | 7 | Hardening: rayon parallelism, CLI polish, error locations | todo | | |
| 71 | ||
| 72 | ### v0.2 in / out | |
| 73 | ||
| 74 | **Added in v0.2:** the INDEX stage (`SymbolTable` of `:ID:`/`:CUSTOM_ID:`/heading/`file:` | |
| 75 | targets across a directory); the RESOLVE stage — rewrites `[[#custom-id]]`, `[[id:...]]`, | |
| 76 | `[[*Heading]]` and `[[file:other.org]]` links to real relative output URLs, returns the | |
| 77 | `used_targets` list (the `uses` edges, spec §4.3/R2) and reports unresolved links as | |
| 78 | warnings rather than crashing; a minijinja base layout (title, nav, body) applied to every | |
| 79 | page; a `build <src-dir> <out-dir>` path that walks the tree, parses + resolves + renders + | |
| 80 | templates every `.org` into a linked static site and copies non-`.org` assets through; | |
| 81 | plus two new constructs — pipe **tables** (with header band from the rule row) and | |
| 82 | **footnotes** (block `[fn:1]` definitions, referenced `[fn:1]`, and inline `[fn:1:text]`, | |
| 83 | rendered as a numbered, back-linked notes section). | |
| 84 | ||
| 85 | **Still stubbed (`todo!`):** timestamps; TODO keywords and priorities; generic | |
| 86 | (non-PROPERTIES) drawers. Source-block syntax highlighting remains a `<pre><code>` | |
| 87 | passthrough behind the `Highlighter` trait; real syntect tokenizing is deferred. | |
| 88 | ||
| 89 | ### v0.3 in / out | |
| 90 | ||
| 91 | **Added in v0.3 — the incremental build layer (spec §4, the flagship, non-retrofittable | |
| 92 | feature):** | |
| 93 | ||
| 94 | - **Three hash classes (spec §4.1)** in `src/incremental.rs`: a **content hash** (blake3 | |
| 95 | of a file's bytes), a **config hash** (blake3 of the resolved `BuildConfig`), and a | |
| 96 | **template hash** (blake3 of the template sources). A change in any one invalidates the | |
| 97 | pages it affects. | |
| 98 | - **Dependency graph (spec §4.3)** built from RESOLVE's `defines`/`uses` edges: a page | |
| 99 | depends on the targets it links to, so editing (or renaming a heading in) a file | |
| 100 | invalidates the pages that *link into* it, not just the file itself — the load-bearing | |
| 101 | R2 invariant. On rebuild the graph is merged with the previous build's `defines` so a | |
| 102 | *removed* target still pulls in its linkers. | |
| 103 | - **Per-page `render_key`** = `H(content ⊕ resolved-links ⊕ config ⊕ template)`. If a | |
| 104 | page's render key is unchanged, its on-disk output is already correct and it is skipped. | |
| 105 | The config component folds in a **site-structure hash** (every page's `(path, title)`), | |
| 106 | because the shared nav bar is global chrome — a title change or a page add/remove alters | |
| 107 | the nav on every page and so must re-render them all (otherwise byte-equivalence breaks). | |
| 108 | - **Persisted cache manifest** (`<out>/.org-ssg-cache.json`, JSON), carrying per-page | |
| 109 | records, the config/template hashes, and the serialized dependency graph, tagged with | |
| 110 | `CACHE_FORMAT_VERSION`. A version mismatch, a missing file, or a corrupt file all fall | |
| 111 | back to a clean full rebuild — the cache is an optimization, never a correctness | |
| 112 | dependency. | |
| 113 | - **Wired into `build_site`**: only pages whose render key changed (or that link into a | |
| 114 | changed file's targets) are re-rendered; unchanged outputs are left in place. `--no-cache` | |
| 115 | forces a full rebuild; `clean <out-dir>` removes the output directory (and its cache). | |
| 116 | `SiteReport` now reports `rendered` vs `skipped` counts. | |
| 117 | ||
| 118 | The hard gates are enforced by `tests/incremental.rs`: full-vs-incremental **byte | |
| 119 | equivalence** (and a second unchanged build re-rendering **zero** pages); **edit-one-file** | |
| 120 | re-renders exactly the changed page plus its linkers; **renamed-heading** invalidates the | |
| 121 | linking page and updates its emitted anchor; and cache **version-bump / missing / corrupt** | |
| 122 | all fall back to a full rebuild. | |
| 123 | ||
| 124 | **Out of scope in v0.3 (unchanged from v0.2):** real syntect highlighting; timestamps and | |
| 125 | TODO keywords. `watch` is a minimal mtime poll loop (`watch <src-dir> -o <out-dir>`), not an | |
| 126 | OS file-watcher — the fs-notify integration is deferred. The parse-tree cache (spec §4.5, | |
| 127 | "optionally") is not persisted: PARSE/INDEX/RESOLVE run for every file each build (cheap and | |
| 128 | pure); the incremental win is on RENDER + EMIT. | |
| 129 | ||
| 130 | **From v0.1 (core subset):** headings with nesting and anchors (every heading is now | |
| 131 | anchored — `:CUSTOM_ID:`/`:ID:` else a slug of its text) and trailing tags; paragraphs; | |
| 132 | plain lists (unordered + ordered) with checkboxes; source blocks; inline markup (`*bold*`, | |
| 133 | `/italic/`, `_underline_`, `+strike+`, `=verbatim=`, `~code~`); links and bare URLs. | |
| 134 | ||
| 135 | ## Dependencies | |
| 136 | ||
| 137 | Parser is hand-written recursive descent (not `nom`/`chumsky`/`pest` — org is | |
| 138 | line-oriented and context-sensitive, not clean CFG). Key crates: `syntect` (syntax | |
| 139 | highlighting, behind a `Highlighter` trait so tree-sitter can be swapped in later), | |
| 140 | `minijinja` (runtime templates), `blake3` (content/cache hashing), `chrono`, | |
| 141 | `camino`, `walkdir`, `clap`, `anyhow`/`thiserror`. `insta` for snapshot tests. | |
| 142 | ||
| 143 | ## Build & test | |
| 144 | ||
| 145 | ``` | |
| 146 | cargo build | |
| 147 | cargo test | |
| 148 | cargo run -- build fixtures/minimal.org -o minimal.html # single file | |
| 149 | cargo run -- build fixtures/site -o _site # whole site (incremental) | |
| 150 | cargo run -- build fixtures/site -o _site --no-cache # force a full rebuild | |
| 151 | cargo run -- watch fixtures/site -o _site # poll + rebuild on change | |
| 152 | cargo run -- clean _site # remove output + cache | |
| 153 | ``` | |
| 154 | ||
| 155 | A second `build` of an unchanged site re-renders nothing; editing a page re-renders only | |
| 156 | that page and the pages that link into it (watch the `rendered`/`cached` counts). | |
| 157 | ||
| 158 | `fixtures/` holds tiny `.org` samples: single-file ones (`minimal.org`, `core.org`, | |
| 159 | `elements.org`, `table.org`, `footnote.org`) and a linked multi-file site under | |
| 160 | `fixtures/site/` (`index.org`, `guide.org`, `about.org` + a `style.css` asset). The | |
| 161 | real corpus (golden files derived from actual documents) lands in Phase 0. `cargo test` | |
| 162 | includes `insta` snapshots of the element tree and rendered HTML for the single-file | |
| 163 | fixtures, the two templated site pages (proving cross-file link resolution), and the | |
| 164 | table and footnote constructs. | |
fixtures/core.org added +21
| @@ -0,0 +1,21 @@ | ||
| 1 | #+TITLE: Core Constructs | |
| 2 | ||
| 3 | Intro paragraph with a bare URL https://example.com and some ~inline code~. | |
| 4 | ||
| 5 | * Ordered and checked | |
| 6 | ||
| 7 | 1. first item | |
| 8 | 2. second item with /emphasis/ | |
| 9 | ||
| 10 | - [ ] todo item | |
| 11 | - [X] done item | |
| 12 | ||
| 13 | * Links and code | |
| 14 | ||
| 15 | An external [[https://example.org][site]] and a bare [[https://bare.example]]. | |
| 16 | ||
| 17 | #+BEGIN_SRC rust | |
| 18 | fn main() { | |
| 19 | println!("hello"); | |
| 20 | } | |
| 21 | #+END_SRC | |
fixtures/elements.org added +31
| @@ -0,0 +1,31 @@ | ||
| 1 | #+TITLE: Element Sampler | |
| 2 | #+FILETAGS: :sample: | |
| 3 | ||
| 4 | * Code and tables | |
| 5 | ||
| 6 | #+BEGIN_SRC rust | |
| 7 | fn main() { | |
| 8 | println!("hello"); | |
| 9 | } | |
| 10 | #+END_SRC | |
| 11 | ||
| 12 | | Name | Score | | |
| 13 | |-------+-------| | |
| 14 | | alpha | 10 | | |
| 15 | | beta | 20 | | |
| 16 | ||
| 17 | * Links and footnotes | |
| 18 | ||
| 19 | An external link: [[https://example.com][Example]] and an id link [[id:abc-123]]. | |
| 20 | ||
| 21 | Text with a footnote reference.[fn:1] | |
| 22 | ||
| 23 | [fn:1] The footnote definition. | |
| 24 | ||
| 25 | * Blocks | |
| 26 | ||
| 27 | #+BEGIN_QUOTE | |
| 28 | A quoted paragraph. | |
| 29 | #+END_QUOTE | |
| 30 | ||
| 31 | ----- | |
fixtures/footnote.org added +8
| @@ -0,0 +1,8 @@ | ||
| 1 | #+TITLE: Footnote Sample | |
| 2 | ||
| 3 | Text with a reference.[fn:1] And a second one.[fn:2] | |
| 4 | ||
| 5 | An inline footnote.[fn:3:defined right here] | |
| 6 | ||
| 7 | [fn:1] The first definition. | |
| 8 | [fn:2] The second definition, with /emphasis/. | |
fixtures/minimal.org added +20
| @@ -0,0 +1,20 @@ | ||
| 1 | #+TITLE: Minimal Fixture | |
| 2 | #+DATE: 2026-08-08 | |
| 3 | #+AUTHOR: Owner | |
| 4 | ||
| 5 | A single paragraph of preamble text before any heading. | |
| 6 | ||
| 7 | * First Heading | |
| 8 | :PROPERTIES: | |
| 9 | :CUSTOM_ID: first | |
| 10 | :END: | |
| 11 | ||
| 12 | Some body text with *bold*, /italic/, and =verbatim=. | |
| 13 | ||
| 14 | ** A Subheading :tag1:tag2: | |
| 15 | ||
| 16 | - an unordered item | |
| 17 | - another with a checkbox [ ] | |
| 18 | ||
| 19 | * Second Heading | |
| 20 | See [[#first][the first heading]]. | |
fixtures/site/about.org added +3
| @@ -0,0 +1,3 @@ | ||
| 1 | #+TITLE: About | |
| 2 | ||
| 3 | This site is built by org-ssg. Back to [[file:index.org][home]]. | |
fixtures/site/guide.org added +17
| @@ -0,0 +1,17 @@ | ||
| 1 | #+TITLE: Guide | |
| 2 | ||
| 3 | * Setup | |
| 4 | :PROPERTIES: | |
| 5 | :CUSTOM_ID: setup | |
| 6 | :END: | |
| 7 | ||
| 8 | Install the steps in order.[fn:1] Then return [[file:index.org][home]]. | |
| 9 | ||
| 10 | [fn:1] Read the manual before you begin. | |
| 11 | ||
| 12 | * Data | |
| 13 | ||
| 14 | | Name | Score | | |
| 15 | |-------+-------| | |
| 16 | | alpha | 10 | | |
| 17 | | beta | 20 | | |
fixtures/site/index.org added +9
| @@ -0,0 +1,9 @@ | ||
| 1 | #+TITLE: Home | |
| 2 | ||
| 3 | Welcome. See the [[file:guide.org][guide]] and jump straight to its | |
| 4 | [[#setup][setup section]] across files. | |
| 5 | ||
| 6 | Also see [[*Overview]] further down this page. | |
| 7 | ||
| 8 | * Overview | |
| 9 | The overview lives on the home page. | |
fixtures/site/style.css added +2
| @@ -0,0 +1,2 @@ | ||
| 1 | body { font-family: system-ui, sans-serif; max-width: 40rem; margin: 2rem auto; } | |
| 2 | nav a { margin-right: 1rem; } | |
fixtures/table.org added +6
| @@ -0,0 +1,6 @@ | ||
| 1 | #+TITLE: Table Sample | |
| 2 | ||
| 3 | | Name | Score | | |
| 4 | |-------+-------| | |
| 5 | | alpha | 10 | | |
| 6 | | beta | 20 | | |
src/incremental.rs added +257
| @@ -0,0 +1,257 @@ | ||
| 1 | //! Incremental build layer (spec §4): content/config/template hashing, the link | |
| 2 | //! dependency graph, the cache manifest, and the invalidation algorithm. | |
| 3 | //! | |
| 4 | //! This is the hard, non-retrofittable part (spec §4). The data model is already | |
| 5 | //! shaped for it — pure, hashable, dependency-tracked units — so this layer is mostly | |
| 6 | //! bookkeeping over the graph. | |
| 7 | //! | |
| 8 | //! The three hash classes (spec §4.1) all feed a page's composed `render_key`: | |
| 9 | //! 1. **content hash** — blake3 of a source file's raw bytes (drives re-parse). | |
| 10 | //! 2. **config hash** — blake3 of the resolved global config (a base-URL/options change | |
| 11 | //! can invalidate everything). | |
| 12 | //! 3. **template hash** — blake3 of the templates (a base-layout edit invalidates every | |
| 13 | //! page that uses it). | |
| 14 | //! | |
| 15 | //! Skip rule (spec §4.1): if a page's `render_key` is unchanged, its emitted file on | |
| 16 | //! disk is already correct — skip it. The dependency graph (spec §4.3) additionally | |
| 17 | //! invalidates pages that *link into* a changed file's targets, so a renamed/removed | |
| 18 | //! heading invalidates the pages that link to it, not just the file that owns it. | |
| 19 | ||
| 20 | use std::collections::{HashMap, HashSet}; | |
| 21 | ||
| 22 | use camino::{Utf8Path, Utf8PathBuf}; | |
| 23 | use serde::{Deserialize, Serialize}; | |
| 24 | ||
| 25 | use crate::index::{SymbolTable, TargetId}; | |
| 26 | use crate::model::ContentHash; | |
| 27 | use crate::util::output_url; | |
| 28 | ||
| 29 | /// Bump whenever the `Document` type, hashing scheme, or resolution rules change. | |
| 30 | /// On mismatch: discard cache, full rebuild (spec §4.5). The blake3 crate's major | |
| 31 | /// version is folded in as the "hash-algo version" so a hash upgrade also busts. | |
| 32 | pub const CACHE_FORMAT_VERSION: u32 = 3; | |
| 33 | ||
| 34 | /// blake3 hex identity for a content/config/template/render-key hash class (spec §4.1). | |
| 35 | pub type Hash = ContentHash; | |
| 36 | ||
| 37 | /// Resolved global build config. Its hash is a component of every page's render key | |
| 38 | /// (spec §4.1): a change here can invalidate the whole site. Kept minimal for v0.3 — | |
| 39 | /// there is no user-facing config yet — but structured so real knobs (base URL, TODO | |
| 40 | /// keyword set, highlighter theme id, inline features) flow into the hash when added. | |
| 41 | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 42 | pub struct BuildConfig { | |
| 43 | pub output_extension: String, | |
| 44 | pub highlighter_theme: String, | |
| 45 | } | |
| 46 | ||
| 47 | impl Default for BuildConfig { | |
| 48 | fn default() -> Self { | |
| 49 | BuildConfig { | |
| 50 | output_extension: "html".to_string(), | |
| 51 | highlighter_theme: "passthrough".to_string(), | |
| 52 | } | |
| 53 | } | |
| 54 | } | |
| 55 | ||
| 56 | /// Compose bytes into a blake3 hash. The one place hashing happens for composite keys. | |
| 57 | fn hash_bytes(bytes: &[u8]) -> Hash { | |
| 58 | ContentHash(*blake3::hash(bytes).as_bytes()) | |
| 59 | } | |
| 60 | ||
| 61 | /// blake3 of the resolved global config (spec §4.1, hash class 2). | |
| 62 | pub fn config_hash(config: &BuildConfig) -> Hash { | |
| 63 | let json = serde_json::to_vec(config).expect("BuildConfig serializes"); | |
| 64 | hash_bytes(&json) | |
| 65 | } | |
| 66 | ||
| 67 | /// Fold two hashes into one composite (order-sensitive). | |
| 68 | pub fn combine(a: Hash, b: Hash) -> Hash { | |
| 69 | let mut hasher = blake3::Hasher::new(); | |
| 70 | hasher.update(&a.0); | |
| 71 | hasher.update(&b.0); | |
| 72 | ContentHash(*hasher.finalize().as_bytes()) | |
| 73 | } | |
| 74 | ||
| 75 | /// Hash of the global site structure that appears in every page's chrome. The nav bar is | |
| 76 | /// built from every page's `(path, title)`, so any title change, path change, page | |
| 77 | /// addition, or removal alters the nav on ALL pages and must invalidate them. This is a | |
| 78 | /// genuine global dependency (like the config/template hashes, spec §4.1), so it is | |
| 79 | /// folded into every page's render key. Deterministic: entries are sorted. | |
| 80 | pub fn site_structure_hash(entries: &[(String, String)]) -> Hash { | |
| 81 | let mut sorted = entries.to_vec(); | |
| 82 | sorted.sort(); | |
| 83 | let mut hasher = blake3::Hasher::new(); | |
| 84 | for (path, title) in &sorted { | |
| 85 | hasher.update(path.as_bytes()); | |
| 86 | hasher.update(&[0]); | |
| 87 | hasher.update(title.as_bytes()); | |
| 88 | hasher.update(&[0]); | |
| 89 | } | |
| 90 | ContentHash(*hasher.finalize().as_bytes()) | |
| 91 | } | |
| 92 | ||
| 93 | /// blake3 over the template sources (spec §4.1, hash class 3). One combined hash over | |
| 94 | /// all templates; when partials land, split this per-template so a single-partial edit | |
| 95 | /// invalidates only its users. | |
| 96 | pub fn template_hash(sources: &[(&str, &str)]) -> Hash { | |
| 97 | let mut hasher = blake3::Hasher::new(); | |
| 98 | for (name, src) in sources { | |
| 99 | hasher.update(name.as_bytes()); | |
| 100 | hasher.update(&[0]); | |
| 101 | hasher.update(src.as_bytes()); | |
| 102 | hasher.update(&[0]); | |
| 103 | } | |
| 104 | ContentHash(*hasher.finalize().as_bytes()) | |
| 105 | } | |
| 106 | ||
| 107 | /// The link dependency graph (spec §4.3). `defines`: file → targets it owns. | |
| 108 | /// `uses`: page → targets it resolved a link to (the invalidation-critical edges). | |
| 109 | #[derive(Debug, Default, Clone, Serialize, Deserialize)] | |
| 110 | pub struct DepGraph { | |
| 111 | pub defines: HashMap<Utf8PathBuf, HashSet<TargetId>>, | |
| 112 | pub uses: HashMap<Utf8PathBuf, HashSet<TargetId>>, | |
| 113 | } | |
| 114 | ||
| 115 | impl DepGraph { | |
| 116 | /// Merge `self` (typically the previous build's graph) with `other` (this build's), | |
| 117 | /// unioning the `defines` targets per file and taking `other`'s `uses`. Used to build | |
| 118 | /// the graph handed to [`invalidation_set`]: a target that a changed file *removed* | |
| 119 | /// is still present in the old `defines`, so pages that linked to it are still found | |
| 120 | /// (the renamed/removed-heading case, spec §4.3 step 2). | |
| 121 | pub fn merged_defines_with(&self, other: &DepGraph) -> DepGraph { | |
| 122 | let mut defines = self.defines.clone(); | |
| 123 | for (file, targets) in &other.defines { | |
| 124 | defines.entry(file.clone()).or_default().extend(targets.iter().cloned()); | |
| 125 | } | |
| 126 | DepGraph { | |
| 127 | defines, | |
| 128 | uses: other.uses.clone(), | |
| 129 | } | |
| 130 | } | |
| 131 | } | |
| 132 | ||
| 133 | /// blake3 over the resolved URLs of the targets a page consumed (spec §4.1: the | |
| 134 | /// `resolved_links_hash` component). Computed relative to the linking page, so a target | |
| 135 | /// whose resolved URL/anchor changed — a renamed `*Heading`, a moved file — flips this | |
| 136 | /// hash and therefore the page's `render_key`. Deterministic: targets are sorted. | |
| 137 | pub fn resolved_links_hash( | |
| 138 | from: &Utf8Path, | |
| 139 | used: &HashSet<TargetId>, | |
| 140 | symbols: &SymbolTable, | |
| 141 | ) -> Hash { | |
| 142 | let mut pairs: Vec<(String, String)> = used | |
| 143 | .iter() | |
| 144 | .map(|tid| { | |
| 145 | let url = symbols | |
| 146 | .targets | |
| 147 | .get(tid) | |
| 148 | .map(|loc| output_url(from, &loc.source_path, loc.anchor.as_deref())) | |
| 149 | .unwrap_or_default(); | |
| 150 | (tid.sort_key(), url) | |
| 151 | }) | |
| 152 | .collect(); | |
| 153 | pairs.sort(); | |
| 154 | let mut hasher = blake3::Hasher::new(); | |
| 155 | for (tid, url) in &pairs { | |
| 156 | hasher.update(tid.as_bytes()); | |
| 157 | hasher.update(&[0]); | |
| 158 | hasher.update(url.as_bytes()); | |
| 159 | hasher.update(&[0]); | |
| 160 | } | |
| 161 | ContentHash(*hasher.finalize().as_bytes()) | |
| 162 | } | |
| 163 | ||
| 164 | /// Compose a page's final-output cache key (spec §4.1): | |
| 165 | /// | |
| 166 | /// ```text | |
| 167 | /// render_key = H( parse_result_hash ⊕ resolved_links_hash ⊕ config_hash ⊕ template_hash ) | |
| 168 | /// ``` | |
| 169 | /// | |
| 170 | /// `parse_result_hash` is the source content hash (PARSE is a pure function of the file | |
| 171 | /// bytes, so the content hash fully identifies the parse result). If `render_key` is | |
| 172 | /// unchanged the on-disk output is already correct and the page is skipped. | |
| 173 | pub fn render_key( | |
| 174 | parse_result_hash: Hash, | |
| 175 | resolved_links_hash: Hash, | |
| 176 | config_hash: Hash, | |
| 177 | template_hash: Hash, | |
| 178 | ) -> Hash { | |
| 179 | let mut hasher = blake3::Hasher::new(); | |
| 180 | hasher.update(&parse_result_hash.0); | |
| 181 | hasher.update(&resolved_links_hash.0); | |
| 182 | hasher.update(&config_hash.0); | |
| 183 | hasher.update(&template_hash.0); | |
| 184 | ContentHash(*hasher.finalize().as_bytes()) | |
| 185 | } | |
| 186 | ||
| 187 | /// Per-page cache record persisted in the manifest (spec §4.5). | |
| 188 | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 189 | pub struct PageRecord { | |
| 190 | pub content_hash: ContentHash, | |
| 191 | pub render_key: Hash, | |
| 192 | pub output_path: Utf8PathBuf, | |
| 193 | } | |
| 194 | ||
| 195 | /// The on-disk cache manifest (spec §4.5). Serialized as JSON (human-diffable; the | |
| 196 | /// cache is an optimization, never a correctness dependency — a `--no-cache`/`clean` | |
| 197 | /// run always produces byte-identical output). | |
| 198 | #[derive(Debug, Default, Serialize, Deserialize)] | |
| 199 | pub struct Manifest { | |
| 200 | pub format_version: u32, | |
| 201 | pub config_hash: Option<Hash>, | |
| 202 | pub template_hash: Option<Hash>, | |
| 203 | pub pages: HashMap<Utf8PathBuf, PageRecord>, | |
| 204 | pub graph: DepGraph, | |
| 205 | } | |
| 206 | ||
| 207 | /// The cache-manifest file lives inside the output directory (spec §4.5: an on-disk | |
| 208 | /// cache dir). `clean` removes the output directory, taking the cache with it. | |
| 209 | pub fn manifest_path(out: &Utf8Path) -> Utf8PathBuf { | |
| 210 | out.join(".org-ssg-cache.json") | |
| 211 | } | |
| 212 | ||
| 213 | /// Load the manifest, returning `None` on ANY of: missing file, read/parse error, or a | |
| 214 | /// cache-format version mismatch (spec §4.5). `None` ⇒ the caller does a full rebuild. | |
| 215 | /// The cache is never a correctness dependency, so a corrupt cache is never a crash. | |
| 216 | pub fn load_manifest(out: &Utf8Path) -> Option<Manifest> { | |
| 217 | let bytes = std::fs::read(manifest_path(out)).ok()?; | |
| 218 | let manifest: Manifest = serde_json::from_slice(&bytes).ok()?; | |
| 219 | if manifest.format_version != CACHE_FORMAT_VERSION { | |
| 220 | return None; | |
| 221 | } | |
| 222 | Some(manifest) | |
| 223 | } | |
| 224 | ||
| 225 | /// Persist the manifest into the output directory. A write failure is surfaced to the | |
| 226 | /// caller (a failed cache write only costs the next build a full rebuild). | |
| 227 | pub fn save_manifest(out: &Utf8Path, manifest: &Manifest) -> std::io::Result<()> { | |
| 228 | let json = serde_json::to_vec_pretty(manifest).expect("manifest serializes"); | |
| 229 | std::fs::write(manifest_path(out), json) | |
| 230 | } | |
| 231 | ||
| 232 | /// Given the set of changed files and the dependency graph, compute the set of pages to | |
| 233 | /// rebuild (spec §4.3 invalidation algorithm): the changed files themselves, plus every | |
| 234 | /// page with a `uses` edge into a target *defined by* a changed file. Pass a graph whose | |
| 235 | /// `defines` is the union of the previous and current builds (see | |
| 236 | /// [`DepGraph::merged_defines_with`]) so that a target a changed file *removed* still | |
| 237 | /// pulls in the pages that linked to it (the renamed/removed-heading case). | |
| 238 | pub fn invalidation_set( | |
| 239 | changed: &HashSet<Utf8PathBuf>, | |
| 240 | graph: &DepGraph, | |
| 241 | ) -> HashSet<Utf8PathBuf> { | |
| 242 | // Targets touched by any changed file (added, removed, or possibly-moved). | |
| 243 | let mut delta_targets: HashSet<&TargetId> = HashSet::new(); | |
| 244 | for file in changed { | |
| 245 | if let Some(targets) = graph.defines.get(file) { | |
| 246 | delta_targets.extend(targets.iter()); | |
| 247 | } | |
| 248 | } | |
| 249 | // Changed files themselves, plus any page that links into a delta'd target. | |
| 250 | let mut result: HashSet<Utf8PathBuf> = changed.clone(); | |
| 251 | for (page, used) in &graph.uses { | |
| 252 | if used.iter().any(|t| delta_targets.contains(t)) { | |
| 253 | result.insert(page.clone()); | |
| 254 | } | |
| 255 | } | |
| 256 | result | |
| 257 | } | |
src/index.rs added +133
| @@ -0,0 +1,133 @@ | ||
| 1 | //! INDEX stage (spec §2.1, §4.3): collect link targets across all documents into a | |
| 2 | //! global symbol table. This is the only inherently global stage before RESOLVE. | |
| 3 | ||
| 4 | use std::collections::HashMap; | |
| 5 | ||
| 6 | use camino::{Utf8Path, Utf8PathBuf}; | |
| 7 | use serde::{Deserialize, Serialize}; | |
| 8 | ||
| 9 | use crate::model::{Document, Section}; | |
| 10 | use crate::util::{plain_text, slugify}; | |
| 11 | ||
| 12 | /// Identity of a link target. A target is owned by exactly one file (spec §4.3). | |
| 13 | /// | |
| 14 | /// `Serialize`/`Deserialize` so the dependency graph (defines/uses edges) round-trips | |
| 15 | /// through the on-disk cache manifest (spec §4.5). | |
| 16 | #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] | |
| 17 | pub enum TargetId { | |
| 18 | Id(String), | |
| 19 | CustomId(String), | |
| 20 | Heading(String), | |
| 21 | File(Utf8PathBuf), | |
| 22 | } | |
| 23 | ||
| 24 | impl TargetId { | |
| 25 | /// A stable string form used to order targets deterministically when hashing | |
| 26 | /// (so a page's `resolved_links_hash` does not depend on `HashSet` iteration order). | |
| 27 | pub fn sort_key(&self) -> String { | |
| 28 | match self { | |
| 29 | TargetId::Id(s) => format!("id:{s}"), | |
| 30 | TargetId::CustomId(s) => format!("custom:{s}"), | |
| 31 | TargetId::Heading(s) => format!("heading:{s}"), | |
| 32 | TargetId::File(p) => format!("file:{p}"), | |
| 33 | } | |
| 34 | } | |
| 35 | } | |
| 36 | ||
| 37 | /// Where a resolved target lives, once INDEX has seen its defining file. | |
| 38 | #[derive(Debug, Clone)] | |
| 39 | pub struct TargetLocation { | |
| 40 | pub source_path: Utf8PathBuf, | |
| 41 | /// Final URL fragment/anchor for the target, filled during resolution. | |
| 42 | pub anchor: Option<String>, | |
| 43 | } | |
| 44 | ||
| 45 | /// Maps every collected target to its owning location (spec §4.3 "defines" edges). | |
| 46 | #[derive(Debug, Default)] | |
| 47 | pub struct SymbolTable { | |
| 48 | pub targets: HashMap<TargetId, TargetLocation>, | |
| 49 | } | |
| 50 | ||
| 51 | impl SymbolTable { | |
| 52 | pub fn new() -> Self { | |
| 53 | Self::default() | |
| 54 | } | |
| 55 | ||
| 56 | /// Walk one document, registering every `:ID:`/`:CUSTOM_ID:`/heading/file target. | |
| 57 | /// A target is owned by exactly one file (spec §4.3); the anchor is the fragment | |
| 58 | /// the renderer emits for that target's heading. | |
| 59 | pub fn index_document(&mut self, doc: &Document) { | |
| 60 | let path = &doc.source_path; | |
| 61 | self.targets.insert( | |
| 62 | TargetId::File(path.clone()), | |
| 63 | TargetLocation { | |
| 64 | source_path: path.clone(), | |
| 65 | anchor: None, | |
| 66 | }, | |
| 67 | ); | |
| 68 | index_section(&doc.root, path, &mut self.targets); | |
| 69 | } | |
| 70 | } | |
| 71 | ||
| 72 | /// The set of link targets a single document *defines* (owns) — the `defines` edges of | |
| 73 | /// the dependency graph (spec §4.3). Mirrors [`SymbolTable::index_document`] but returns | |
| 74 | /// the targets for one file in isolation, which is what the incremental layer records | |
| 75 | /// per-file in the cache manifest. | |
| 76 | pub fn document_targets(doc: &Document) -> Vec<TargetId> { | |
| 77 | let mut out = vec![TargetId::File(doc.source_path.clone())]; | |
| 78 | collect_targets(&doc.root, &mut out); | |
| 79 | out | |
| 80 | } | |
| 81 | ||
| 82 | fn collect_targets(section: &Section, out: &mut Vec<TargetId>) { | |
| 83 | if let Some(h) = §ion.heading { | |
| 84 | if let Some(cid) = &h.custom_id { | |
| 85 | out.push(TargetId::CustomId(cid.clone())); | |
| 86 | } | |
| 87 | if let Some(id) = &h.id { | |
| 88 | out.push(TargetId::Id(id.clone())); | |
| 89 | } | |
| 90 | out.push(TargetId::Heading(plain_text(&h.title))); | |
| 91 | } | |
| 92 | for child in §ion.children { | |
| 93 | collect_targets(child, out); | |
| 94 | } | |
| 95 | } | |
| 96 | ||
| 97 | fn index_section(section: &Section, path: &Utf8Path, targets: &mut HashMap<TargetId, TargetLocation>) { | |
| 98 | if let Some(h) = §ion.heading { | |
| 99 | let anchor = h | |
| 100 | .custom_id | |
| 101 | .clone() | |
| 102 | .or_else(|| h.id.clone()) | |
| 103 | .unwrap_or_else(|| slugify(&plain_text(&h.title))); | |
| 104 | if let Some(cid) = &h.custom_id { | |
| 105 | targets.insert( | |
| 106 | TargetId::CustomId(cid.clone()), | |
| 107 | TargetLocation { | |
| 108 | source_path: path.to_owned(), | |
| 109 | anchor: Some(cid.clone()), | |
| 110 | }, | |
| 111 | ); | |
| 112 | } | |
| 113 | if let Some(id) = &h.id { | |
| 114 | targets.insert( | |
| 115 | TargetId::Id(id.clone()), | |
| 116 | TargetLocation { | |
| 117 | source_path: path.to_owned(), | |
| 118 | anchor: Some(id.clone()), | |
| 119 | }, | |
| 120 | ); | |
| 121 | } | |
| 122 | targets.insert( | |
| 123 | TargetId::Heading(plain_text(&h.title)), | |
| 124 | TargetLocation { | |
| 125 | source_path: path.to_owned(), | |
| 126 | anchor: Some(anchor), | |
| 127 | }, | |
| 128 | ); | |
| 129 | } | |
| 130 | for child in §ion.children { | |
| 131 | index_section(child, path, targets); | |
| 132 | } | |
| 133 | } | |
src/lib.rs added +19
| @@ -0,0 +1,19 @@ | ||
| 1 | //! org-ssg — an org-mode static site generator. | |
| 2 | //! | |
| 3 | //! Org is the source language, not an input to be normalized into markdown. The org | |
| 4 | //! element tree ([`model`]) *is* the document model; we render it straight to HTML. | |
| 5 | //! | |
| 6 | //! Pipeline (spec §2.1), one module per stage: | |
| 7 | //! DISCOVER → [`parser`] (PARSE) → [`index`] (INDEX) → [`resolve`] (RESOLVE) → | |
| 8 | //! [`render`] (RENDER) → [`template`] (TEMPLATE) → EMIT, with [`incremental`] | |
| 9 | //! deciding which pages actually need rewriting. | |
| 10 | ||
| 11 | pub mod incremental; | |
| 12 | pub mod index; | |
| 13 | pub mod model; | |
| 14 | pub mod parser; | |
| 15 | pub mod render; | |
| 16 | pub mod resolve; | |
| 17 | pub mod site; | |
| 18 | pub mod template; | |
| 19 | pub mod util; | |
src/main.rs added +180
| @@ -0,0 +1,180 @@ | ||
| 1 | //! CLI entry point (spec §3.5): `build`, `watch`, `clean`. | |
| 2 | ||
| 3 | use std::fs; | |
| 4 | ||
| 5 | use anyhow::{Context, Result}; | |
| 6 | use camino::{Utf8Path, Utf8PathBuf}; | |
| 7 | use clap::{Parser, Subcommand}; | |
| 8 | ||
| 9 | use org_ssg::parser::parse; | |
| 10 | use org_ssg::render::{render, Html, SyntectHighlighter}; | |
| 11 | use org_ssg::resolve::ResolvedDoc; | |
| 12 | use org_ssg::site::{build_site, BuildOptions}; | |
| 13 | use org_ssg::template::Templater; | |
| 14 | ||
| 15 | #[derive(Parser)] | |
| 16 | #[command(name = "org-ssg", about = "Org-mode static site generator")] | |
| 17 | struct Cli { | |
| 18 | #[command(subcommand)] | |
| 19 | command: Command, | |
| 20 | } | |
| 21 | ||
| 22 | #[derive(Subcommand)] | |
| 23 | enum Command { | |
| 24 | /// Build a site. If INPUT is a directory, walk it and emit a linked static site | |
| 25 | /// to OUTPUT (a directory); if INPUT is a single `.org` file, emit one HTML file. | |
| 26 | Build { | |
| 27 | /// Input `.org` file, or a source directory for a whole-site build. | |
| 28 | input: Utf8PathBuf, | |
| 29 | /// Output path: an `.html` file for a single input, or a directory for a site. | |
| 30 | #[arg(short, long)] | |
| 31 | output: Option<Utf8PathBuf>, | |
| 32 | /// Bypass the incremental cache and re-render every page (spec §4.5). | |
| 33 | #[arg(long)] | |
| 34 | no_cache: bool, | |
| 35 | /// Treat broken internal links as errors (spec §4.3.4). | |
| 36 | #[arg(long)] | |
| 37 | strict: bool, | |
| 38 | }, | |
| 39 | /// Watch a source directory and rebuild incrementally on change (simple poll loop). | |
| 40 | Watch { | |
| 41 | /// Source directory to watch. | |
| 42 | input: Utf8PathBuf, | |
| 43 | /// Output directory. | |
| 44 | #[arg(short, long)] | |
| 45 | output: Utf8PathBuf, | |
| 46 | }, | |
| 47 | /// Remove the build output directory (which holds the cache manifest). | |
| 48 | Clean { | |
| 49 | /// Output directory to remove. | |
| 50 | output: Utf8PathBuf, | |
| 51 | }, | |
| 52 | } | |
| 53 | ||
| 54 | fn main() -> Result<()> { | |
| 55 | let cli = Cli::parse(); | |
| 56 | match cli.command { | |
| 57 | Command::Build { | |
| 58 | input, | |
| 59 | output, | |
| 60 | no_cache, | |
| 61 | strict, | |
| 62 | } => { | |
| 63 | if input.is_dir() { | |
| 64 | let out = output | |
| 65 | .context("site build requires an output directory: build <src-dir> -o <out-dir>")?; | |
| 66 | let opts = BuildOptions { no_cache, strict }; | |
| 67 | let report = build_site(&input, &out, &opts)?; | |
| 68 | println!( | |
| 69 | "built {} page(s) ({} rendered, {} cached), copied {} asset(s) from {} -> {} ({} unresolved link(s))", | |
| 70 | report.pages.len(), | |
| 71 | report.rendered.len(), | |
| 72 | report.skipped.len(), | |
| 73 | report.assets.len(), | |
| 74 | input, | |
| 75 | out, | |
| 76 | report.broken.len() | |
| 77 | ); | |
| 78 | } else { | |
| 79 | let output = output.unwrap_or_else(|| input.with_extension("html")); | |
| 80 | build_file(&input, &output)?; | |
| 81 | println!("built {} -> {}", input, output); | |
| 82 | } | |
| 83 | Ok(()) | |
| 84 | } | |
| 85 | // Watch is intentionally a minimal poll loop, not an OS file-watch (spec §5 Phase | |
| 86 | // 6 lists `watch`; the real fs-notify integration is deferred). It rebuilds | |
| 87 | // incrementally whenever any source file's mtime advances. | |
| 88 | Command::Watch { input, output } => watch(&input, &output), | |
| 89 | Command::Clean { output } => { | |
| 90 | if output.exists() { | |
| 91 | fs::remove_dir_all(&output) | |
| 92 | .with_context(|| format!("removing output directory {output}"))?; | |
| 93 | println!("removed {output}"); | |
| 94 | } else { | |
| 95 | println!("nothing to clean: {output} does not exist"); | |
| 96 | } | |
| 97 | Ok(()) | |
| 98 | } | |
| 99 | } | |
| 100 | } | |
| 101 | ||
| 102 | /// Minimal poll-based watch loop: rebuild incrementally whenever a source file changes. | |
| 103 | /// Not an OS file-watcher (deferred); it snapshots source mtimes every 500ms. | |
| 104 | fn watch(input: &Utf8Path, output: &Utf8Path) -> Result<()> { | |
| 105 | use std::time::{Duration, SystemTime}; | |
| 106 | ||
| 107 | if !input.is_dir() { | |
| 108 | anyhow::bail!("watch requires a source directory: watch <src-dir> -o <out-dir>"); | |
| 109 | } | |
| 110 | let opts = BuildOptions::default(); | |
| 111 | ||
| 112 | let snapshot = |root: &Utf8Path| -> Vec<(Utf8PathBuf, SystemTime)> { | |
| 113 | let mut v = Vec::new(); | |
| 114 | for entry in walkdir::WalkDir::new(root).sort_by_file_name() { | |
| 115 | let Ok(entry) = entry else { continue }; | |
| 116 | if !entry.file_type().is_file() { | |
| 117 | continue; | |
| 118 | } | |
| 119 | if let (Ok(path), Ok(meta)) = ( | |
| 120 | Utf8PathBuf::from_path_buf(entry.path().to_owned()), | |
| 121 | entry.metadata(), | |
| 122 | ) { | |
| 123 | let mtime = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH); | |
| 124 | v.push((path, mtime)); | |
| 125 | } | |
| 126 | } | |
| 127 | v | |
| 128 | }; | |
| 129 | ||
| 130 | let report = build_site(input, output, &opts)?; | |
| 131 | println!( | |
| 132 | "watching {input} -> {output}: built {} page(s) ({} rendered). Ctrl-C to stop.", | |
| 133 | report.pages.len(), | |
| 134 | report.rendered.len() | |
| 135 | ); | |
| 136 | let mut last = snapshot(input); | |
| 137 | loop { | |
| 138 | std::thread::sleep(Duration::from_millis(500)); | |
| 139 | let now = snapshot(input); | |
| 140 | if now != last { | |
| 141 | match build_site(input, output, &opts) { | |
| 142 | Ok(report) => println!( | |
| 143 | "rebuilt: {} rendered, {} cached", | |
| 144 | report.rendered.len(), | |
| 145 | report.skipped.len() | |
| 146 | ), | |
| 147 | Err(e) => eprintln!("build error: {e:#}"), | |
| 148 | } | |
| 149 | last = now; | |
| 150 | } | |
| 151 | } | |
| 152 | } | |
| 153 | ||
| 154 | /// Single-file build: read → PARSE → RENDER → TEMPLATE → write. No cross-file link | |
| 155 | /// resolution (there is no corpus to resolve against); links keep their best-effort | |
| 156 | /// URLs. Whole-site link resolution lives in [`build_site`]. | |
| 157 | fn build_file(input: &Utf8Path, output: &Utf8Path) -> Result<()> { | |
| 158 | let source = fs::read_to_string(input) | |
| 159 | .with_context(|| format!("reading source file {input}"))?; | |
| 160 | let document = parse(input, &source).with_context(|| format!("parsing {input}"))?; | |
| 161 | ||
| 162 | let title = document | |
| 163 | .keywords | |
| 164 | .entries | |
| 165 | .iter() | |
| 166 | .find(|(k, _)| k.eq_ignore_ascii_case("TITLE")) | |
| 167 | .map(|(_, v)| v.clone()) | |
| 168 | .unwrap_or_else(|| input.file_stem().unwrap_or("untitled").to_string()); | |
| 169 | ||
| 170 | let resolved = ResolvedDoc { document }; | |
| 171 | let highlighter = SyntectHighlighter; | |
| 172 | let Html(fragment) = render(&resolved, &highlighter); | |
| 173 | ||
| 174 | let templater = Templater::new(); | |
| 175 | let page = templater | |
| 176 | .render_page(&title, &fragment, &[]) | |
| 177 | .with_context(|| format!("templating {input}"))?; | |
| 178 | fs::write(output, page).with_context(|| format!("writing output file {output}"))?; | |
| 179 | Ok(()) | |
| 180 | } | |
src/model.rs added +224
| @@ -0,0 +1,224 @@ | ||
| 1 | //! The org element tree. This *is* the document model (spec §2.2) — the parser's | |
| 2 | //! output type is what the renderer consumes; there is no separate document AST. | |
| 3 | //! | |
| 4 | //! Org's two-tier structure is mirrored in the type system: | |
| 5 | //! - [`Element`] — block-level things (headings, paragraphs, lists, tables, blocks). | |
| 6 | //! - [`Object`] — inline things inside an element's content (bold, links, timestamps). | |
| 7 | //! | |
| 8 | //! This split lets the renderer never accidentally nest a heading inside emphasis. | |
| 9 | //! The whole tree is `serde`-serializable so the parse cache and golden-file | |
| 10 | //! snapshots share one representation (spec §4, §5). | |
| 11 | ||
| 12 | use camino::Utf8PathBuf; | |
| 13 | use chrono::NaiveDateTime; | |
| 14 | use serde::{Deserialize, Serialize}; | |
| 15 | ||
| 16 | /// Content hash of raw source bytes. blake3 (spec §4.1). Serialized as hex. | |
| 17 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] | |
| 18 | pub struct ContentHash(pub [u8; 32]); | |
| 19 | ||
| 20 | /// Parsed `#+KEYWORD:` directives (`#+TITLE`, `#+DATE`, `#+OPTIONS`, ...). | |
| 21 | #[derive(Debug, Clone, Default, Serialize, Deserialize)] | |
| 22 | pub struct Keywords { | |
| 23 | pub entries: Vec<(String, String)>, | |
| 24 | } | |
| 25 | ||
| 26 | /// Parsed `:PROPERTIES:` drawer as an ordered key/value map. | |
| 27 | #[derive(Debug, Clone, Default, Serialize, Deserialize)] | |
| 28 | pub struct Properties { | |
| 29 | pub entries: Vec<(String, String)>, | |
| 30 | } | |
| 31 | ||
| 32 | /// A TODO keyword resolved against the configured keyword set. | |
| 33 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] | |
| 34 | pub struct TodoKeyword { | |
| 35 | pub name: String, | |
| 36 | pub done: bool, | |
| 37 | } | |
| 38 | ||
| 39 | /// One source file → one Document. This is the unit of parsing and caching (spec §2.3). | |
| 40 | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 41 | pub struct Document { | |
| 42 | pub source_path: Utf8PathBuf, | |
| 43 | pub content_hash: ContentHash, | |
| 44 | pub keywords: Keywords, | |
| 45 | /// Pre-first-heading content plus child headings. | |
| 46 | pub root: Section, | |
| 47 | } | |
| 48 | ||
| 49 | /// A section = content directly under a heading (or the file preamble), followed by | |
| 50 | /// nested subsections. Recursive, mirroring org's headline hierarchy — so that | |
| 51 | /// "renaming a heading invalidates its subtree's link targets" is a local operation. | |
| 52 | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 53 | pub struct Section { | |
| 54 | /// `None` for the file preamble. | |
| 55 | pub heading: Option<Heading>, | |
| 56 | /// Block-level content of THIS section. | |
| 57 | pub content: Vec<Element>, | |
| 58 | /// Nested sub-headings. | |
| 59 | pub children: Vec<Section>, | |
| 60 | } | |
| 61 | ||
| 62 | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 63 | pub struct Heading { | |
| 64 | pub level: u8, | |
| 65 | pub todo: Option<TodoKeyword>, | |
| 66 | /// `'A'..` from `[#A]`. | |
| 67 | pub priority: Option<char>, | |
| 68 | /// Inline objects — headings can contain markup/links. | |
| 69 | pub title: Vec<Object>, | |
| 70 | pub tags: Vec<String>, | |
| 71 | pub properties: Properties, | |
| 72 | /// `:ID:`. | |
| 73 | pub id: Option<String>, | |
| 74 | /// `:CUSTOM_ID:`. | |
| 75 | pub custom_id: Option<String>, | |
| 76 | } | |
| 77 | ||
| 78 | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 79 | pub enum Element { | |
| 80 | Paragraph(Vec<Object>), | |
| 81 | List(List), | |
| 82 | Table(Table), | |
| 83 | SrcBlock { | |
| 84 | lang: Option<String>, | |
| 85 | params: BlockParams, | |
| 86 | code: String, | |
| 87 | }, | |
| 88 | ExampleBlock(String), | |
| 89 | QuoteBlock(Vec<Element>), | |
| 90 | CenterBlock(Vec<Element>), | |
| 91 | /// html passes through; others dropped at render (spec §1 OUT). | |
| 92 | ExportBlock { | |
| 93 | backend: String, | |
| 94 | raw: String, | |
| 95 | }, | |
| 96 | HorizontalRule, | |
| 97 | FootnoteDefinition { | |
| 98 | label: String, | |
| 99 | content: Vec<Element>, | |
| 100 | }, | |
| 101 | /// Stray `#+FOO:` kept as metadata. | |
| 102 | Keyword { | |
| 103 | key: String, | |
| 104 | value: String, | |
| 105 | }, | |
| 106 | /// Generic drawer; LOGBOOK special-cased (spec §8 Q7). | |
| 107 | Drawer { | |
| 108 | name: String, | |
| 109 | content: Vec<Element>, | |
| 110 | }, | |
| 111 | Comment(String), | |
| 112 | } | |
| 113 | ||
| 114 | /// `#+BEGIN_SRC` switches / header args. Parsed but mostly ignored in v1. | |
| 115 | #[derive(Debug, Clone, Default, Serialize, Deserialize)] | |
| 116 | pub struct BlockParams { | |
| 117 | pub raw: String, | |
| 118 | } | |
| 119 | ||
| 120 | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 121 | pub struct List { | |
| 122 | pub kind: ListKind, | |
| 123 | pub items: Vec<ListItem>, | |
| 124 | } | |
| 125 | ||
| 126 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] | |
| 127 | pub enum ListKind { | |
| 128 | Unordered, | |
| 129 | Ordered, | |
| 130 | Description, | |
| 131 | } | |
| 132 | ||
| 133 | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 134 | pub struct ListItem { | |
| 135 | pub bullet: Bullet, | |
| 136 | pub checkbox: Option<Checkbox>, | |
| 137 | /// Description-list term before `::`. | |
| 138 | pub term: Option<Vec<Object>>, | |
| 139 | /// Items hold block content (may nest lists). | |
| 140 | pub content: Vec<Element>, | |
| 141 | } | |
| 142 | ||
| 143 | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 144 | pub enum Bullet { | |
| 145 | Dash, | |
| 146 | Plus, | |
| 147 | /// `1.` / `1)` — carries the ordinal. | |
| 148 | Ordered(u32), | |
| 149 | } | |
| 150 | ||
| 151 | /// `[ ]` / `[X]` / `[-]`. | |
| 152 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] | |
| 153 | pub enum Checkbox { | |
| 154 | Off, | |
| 155 | On, | |
| 156 | Trans, | |
| 157 | } | |
| 158 | ||
| 159 | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 160 | pub struct Table { | |
| 161 | /// Rule rows preserved to locate the header band. | |
| 162 | pub rows: Vec<TableRow>, | |
| 163 | } | |
| 164 | ||
| 165 | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 166 | pub enum TableRow { | |
| 167 | Cells(Vec<Vec<Object>>), | |
| 168 | Rule, | |
| 169 | } | |
| 170 | ||
| 171 | /// Inline objects (spec §2.3). | |
| 172 | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 173 | pub enum Object { | |
| 174 | Text(String), | |
| 175 | Bold(Vec<Object>), | |
| 176 | Italic(Vec<Object>), | |
| 177 | Underline(Vec<Object>), | |
| 178 | StrikeThrough(Vec<Object>), | |
| 179 | /// `=...=` : no nested markup (String, not Vec<Object>, by design). | |
| 180 | Verbatim(String), | |
| 181 | /// `~...~` : no nested markup. | |
| 182 | Code(String), | |
| 183 | Link(Link), | |
| 184 | FootnoteRef { | |
| 185 | label: String, | |
| 186 | inline: Option<Vec<Object>>, | |
| 187 | }, | |
| 188 | Timestamp(Timestamp), | |
| 189 | LineBreak, | |
| 190 | /// Resolved `\alpha`-style entity, only if enabled. | |
| 191 | Entity(String), | |
| 192 | } | |
| 193 | ||
| 194 | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 195 | pub struct Link { | |
| 196 | /// Unresolved at parse time — resolution is a separate global stage (spec §2.3). | |
| 197 | pub target: LinkTarget, | |
| 198 | pub description: Option<Vec<Object>>, | |
| 199 | } | |
| 200 | ||
| 201 | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 202 | pub enum LinkTarget { | |
| 203 | /// `https:`, `mailto:`, ... | |
| 204 | External(String), | |
| 205 | File { | |
| 206 | path: Utf8PathBuf, | |
| 207 | search: Option<String>, | |
| 208 | }, | |
| 209 | /// `[[id:...]]`. | |
| 210 | Id(String), | |
| 211 | /// `[[#...]]`. | |
| 212 | CustomId(String), | |
| 213 | /// `[[*Heading text]]`. | |
| 214 | Heading(String), | |
| 215 | } | |
| 216 | ||
| 217 | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 218 | pub struct Timestamp { | |
| 219 | /// `<...>` vs `[...]`. | |
| 220 | pub active: bool, | |
| 221 | pub start: NaiveDateTime, | |
| 222 | pub end: Option<NaiveDateTime>, | |
| 223 | pub has_time: bool, | |
| 224 | } | |
src/parser.rs added +886
| @@ -0,0 +1,886 @@ | ||
| 1 | //! PARSE stage (spec §2.1, §3.1): bytes → tokens → org element tree. | |
| 2 | //! | |
| 3 | //! Hand-written recursive descent, deliberately two-tier (spec §3.1): | |
| 4 | //! 1. [`line_lexer`] — cheap first pass classifying each line, context-free. | |
| 5 | //! 2. [`build_document`] — recursive descent over the line stream into `Section`s/`Element`s. | |
| 6 | //! 3. [`inline`] — scans an element's text runs into `Vec<Object>`, implementing | |
| 7 | //! org's emphasis pre/post-char rules explicitly. | |
| 8 | //! | |
| 9 | //! PARSE is a pure function of a single file's bytes (spec §2.1): it never depends on | |
| 10 | //! another file, which is what makes content-hash caching sound. | |
| 11 | //! | |
| 12 | //! v0.1 scope (the CORE subset): headings + nesting, property drawers on headings, | |
| 13 | //! paragraphs, plain lists (unordered + ordered) with checkboxes, source blocks, and | |
| 14 | //! inline markup (bold/italic/underline/strike/verbatim/code, links, bare URLs). | |
| 15 | //! Out of scope and left graceful (parsed-and-ignored, never crashing): tables, | |
| 16 | //! footnotes, timestamps, TODO keywords, non-SRC blocks (kept verbatim as example | |
| 17 | //! blocks), generic drawers other than PROPERTIES. | |
| 18 | ||
| 19 | use camino::Utf8Path; | |
| 20 | ||
| 21 | use crate::model::{ | |
| 22 | BlockParams, Bullet, Checkbox, ContentHash, Document, Element, Heading, Keywords, Link, | |
| 23 | LinkTarget, List, ListItem, ListKind, Object, Properties, Section, Table, TableRow, | |
| 24 | }; | |
| 25 | ||
| 26 | #[derive(Debug, thiserror::Error)] | |
| 27 | pub enum ParseError { | |
| 28 | #[error("parse error at line {line}: {message}")] | |
| 29 | At { line: usize, message: String }, | |
| 30 | } | |
| 31 | ||
| 32 | /// Classified lines produced by the first pass (spec §3.1). | |
| 33 | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 34 | pub enum Line { | |
| 35 | Heading, | |
| 36 | BlockBegin { kind: String }, | |
| 37 | BlockEnd, | |
| 38 | ListItem, | |
| 39 | TableRow, | |
| 40 | Keyword, | |
| 41 | DrawerBegin, | |
| 42 | DrawerEnd, | |
| 43 | Rule, | |
| 44 | Blank, | |
| 45 | Text, | |
| 46 | } | |
| 47 | ||
| 48 | /// First pass: classify each raw line. Context-free per line. | |
| 49 | pub fn line_lexer(source: &str) -> Vec<Line> { | |
| 50 | source.lines().map(classify_line).collect() | |
| 51 | } | |
| 52 | ||
| 53 | fn classify_line(line: &str) -> Line { | |
| 54 | if line.trim().is_empty() { | |
| 55 | return Line::Blank; | |
| 56 | } | |
| 57 | if heading_level(line).is_some() { | |
| 58 | return Line::Heading; | |
| 59 | } | |
| 60 | let t = line.trim_start(); | |
| 61 | let upper = t.to_ascii_uppercase(); | |
| 62 | if let Some(rest) = upper.strip_prefix("#+BEGIN_") { | |
| 63 | let kind = rest.split_whitespace().next().unwrap_or("").to_string(); | |
| 64 | return Line::BlockBegin { kind }; | |
| 65 | } | |
| 66 | if upper.starts_with("#+END_") { | |
| 67 | return Line::BlockEnd; | |
| 68 | } | |
| 69 | if keyword_kv(line).is_some() { | |
| 70 | return Line::Keyword; | |
| 71 | } | |
| 72 | if is_rule(line) { | |
| 73 | return Line::Rule; | |
| 74 | } | |
| 75 | if t.eq_ignore_ascii_case(":END:") { | |
| 76 | return Line::DrawerEnd; | |
| 77 | } | |
| 78 | if is_drawer_begin(t) { | |
| 79 | return Line::DrawerBegin; | |
| 80 | } | |
| 81 | if is_list_item(t).is_some() { | |
| 82 | return Line::ListItem; | |
| 83 | } | |
| 84 | if t.starts_with('|') { | |
| 85 | return Line::TableRow; | |
| 86 | } | |
| 87 | Line::Text | |
| 88 | } | |
| 89 | ||
| 90 | /// blake3 of raw source bytes — the content hash that drives re-parse decisions (spec §4.1). | |
| 91 | pub fn content_hash(bytes: &[u8]) -> ContentHash { | |
| 92 | ContentHash(*blake3::hash(bytes).as_bytes()) | |
| 93 | } | |
| 94 | ||
| 95 | /// Parse one source file into a [`Document`]. Pure over `(path, source)`. | |
| 96 | pub fn parse(path: &Utf8Path, source: &str) -> Result<Document, ParseError> { | |
| 97 | let content_hash = content_hash(source.as_bytes()); | |
| 98 | let lines: Vec<&str> = source.lines().collect(); | |
| 99 | let classes = line_lexer(source); | |
| 100 | ||
| 101 | let mut keywords = Keywords::default(); | |
| 102 | let mut root = Section { | |
| 103 | heading: None, | |
| 104 | content: Vec::new(), | |
| 105 | children: Vec::new(), | |
| 106 | }; | |
| 107 | ||
| 108 | let heading_idxs: Vec<usize> = classes | |
| 109 | .iter() | |
| 110 | .enumerate() | |
| 111 | .filter(|(_, c)| **c == Line::Heading) | |
| 112 | .map(|(i, _)| i) | |
| 113 | .collect(); | |
| 114 | let first = heading_idxs.first().copied().unwrap_or(lines.len()); | |
| 115 | ||
| 116 | // Preamble: document-level keywords are lifted into `keywords`; the remaining | |
| 117 | // lines become the root section's block content. | |
| 118 | { | |
| 119 | let mut body: Vec<&str> = Vec::new(); | |
| 120 | for (l, c) in lines[..first].iter().zip(&classes[..first]) { | |
| 121 | if *c == Line::Keyword { | |
| 122 | if let Some((k, v)) = keyword_kv(l) { | |
| 123 | keywords.entries.push((k, v)); | |
| 124 | } | |
| 125 | } else { | |
| 126 | body.push(l); | |
| 127 | } | |
| 128 | } | |
| 129 | root.content = parse_elements(&body); | |
| 130 | } | |
| 131 | ||
| 132 | // Each heading segment runs from its own line up to (but excluding) the next heading. | |
| 133 | let mut flat: Vec<(u8, Section)> = Vec::new(); | |
| 134 | for (k, &h_idx) in heading_idxs.iter().enumerate() { | |
| 135 | let end = heading_idxs.get(k + 1).copied().unwrap_or(lines.len()); | |
| 136 | let heading = parse_heading(lines[h_idx]); | |
| 137 | let level = heading.level; | |
| 138 | let (heading, content) = parse_section_body(heading, &lines[h_idx + 1..end]); | |
| 139 | flat.push(( | |
| 140 | level, | |
| 141 | Section { | |
| 142 | heading: Some(heading), | |
| 143 | content, | |
| 144 | children: Vec::new(), | |
| 145 | }, | |
| 146 | )); | |
| 147 | } | |
| 148 | ||
| 149 | let mut pos = 0; | |
| 150 | root.children = build_children(&mut flat, &mut pos, 0); | |
| 151 | ||
| 152 | Ok(Document { | |
| 153 | source_path: path.to_owned(), | |
| 154 | content_hash, | |
| 155 | keywords, | |
| 156 | root, | |
| 157 | }) | |
| 158 | } | |
| 159 | ||
| 160 | /// Fold the flat `(level, section)` list into org's nested hierarchy by level. | |
| 161 | fn build_children(flat: &mut [(u8, Section)], pos: &mut usize, parent_level: u8) -> Vec<Section> { | |
| 162 | let mut children = Vec::new(); | |
| 163 | while *pos < flat.len() { | |
| 164 | let level = flat[*pos].0; | |
| 165 | if level <= parent_level { | |
| 166 | break; | |
| 167 | } | |
| 168 | let mut section = std::mem::replace(&mut flat[*pos].1, empty_section()); | |
| 169 | *pos += 1; | |
| 170 | section.children = build_children(flat, pos, level); | |
| 171 | children.push(section); | |
| 172 | } | |
| 173 | children | |
| 174 | } | |
| 175 | ||
| 176 | fn empty_section() -> Section { | |
| 177 | Section { | |
| 178 | heading: None, | |
| 179 | content: Vec::new(), | |
| 180 | children: Vec::new(), | |
| 181 | } | |
| 182 | } | |
| 183 | ||
| 184 | /// Second-tier: scan an element's text into inline objects, applying org's | |
| 185 | /// pre/post-char emphasis rules (spec §3.1, R3 — the highest-divergence area). | |
| 186 | pub fn inline(text: &str) -> Vec<Object> { | |
| 187 | let chars: Vec<char> = text.chars().collect(); | |
| 188 | parse_inline_run(&chars) | |
| 189 | } | |
| 190 | ||
| 191 | // --------------------------------------------------------------------------- | |
| 192 | // Headings | |
| 193 | // --------------------------------------------------------------------------- | |
| 194 | ||
| 195 | /// `*`-prefixed heading depth, or `None` if the line is not a heading. | |
| 196 | fn heading_level(line: &str) -> Option<u8> { | |
| 197 | if !line.starts_with('*') { | |
| 198 | return None; | |
| 199 | } | |
| 200 | let stars = line.chars().take_while(|c| *c == '*').count(); | |
| 201 | let after = &line[stars..]; | |
| 202 | if after.starts_with(' ') || after.is_empty() { | |
| 203 | Some(stars.min(u8::MAX as usize) as u8) | |
| 204 | } else { | |
| 205 | None | |
| 206 | } | |
| 207 | } | |
| 208 | ||
| 209 | fn parse_heading(line: &str) -> Heading { | |
| 210 | let level = heading_level(line).unwrap_or(1); | |
| 211 | let rest = line[level as usize..].trim(); | |
| 212 | let (title_str, tags) = split_tags(rest); | |
| 213 | Heading { | |
| 214 | level, | |
| 215 | todo: None, // TODO keywords: out of scope for v0.1. | |
| 216 | priority: None, // priorities: out of scope for v0.1. | |
| 217 | title: inline(title_str.trim()), | |
| 218 | tags, | |
| 219 | properties: Properties::default(), | |
| 220 | id: None, | |
| 221 | custom_id: None, | |
| 222 | } | |
| 223 | } | |
| 224 | ||
| 225 | /// Split a trailing `:tag1:tag2:` cluster off the heading text. | |
| 226 | fn split_tags(rest: &str) -> (&str, Vec<String>) { | |
| 227 | let trimmed = rest.trim_end(); | |
| 228 | if !trimmed.ends_with(':') { | |
| 229 | return (rest, Vec::new()); | |
| 230 | } | |
| 231 | let start = match trimmed.rfind(char::is_whitespace) { | |
| 232 | Some(i) => i + 1, | |
| 233 | None => 0, | |
| 234 | }; | |
| 235 | let candidate = &trimmed[start..]; | |
| 236 | if is_tag_cluster(candidate) { | |
| 237 | let tags = candidate | |
| 238 | .split(':') | |
| 239 | .filter(|s| !s.is_empty()) | |
| 240 | .map(|s| s.to_string()) | |
| 241 | .collect(); | |
| 242 | (&trimmed[..start], tags) | |
| 243 | } else { | |
| 244 | (rest, Vec::new()) | |
| 245 | } | |
| 246 | } | |
| 247 | ||
| 248 | /// A `:a:b:c:` cluster: colon-delimited, non-empty tag names, colon-bounded. | |
| 249 | fn is_tag_cluster(s: &str) -> bool { | |
| 250 | if !s.starts_with(':') || !s.ends_with(':') || s.len() < 3 { | |
| 251 | return false; | |
| 252 | } | |
| 253 | let inner = &s[1..s.len() - 1]; | |
| 254 | !inner.is_empty() | |
| 255 | && inner.split(':').all(|part| { | |
| 256 | !part.is_empty() | |
| 257 | && part | |
| 258 | .chars() | |
| 259 | .all(|c| c.is_alphanumeric() || matches!(c, '_' | '@' | '#' | '%')) | |
| 260 | }) | |
| 261 | } | |
| 262 | ||
| 263 | // --------------------------------------------------------------------------- | |
| 264 | // Section body: property drawer + block content | |
| 265 | // --------------------------------------------------------------------------- | |
| 266 | ||
| 267 | fn parse_section_body(mut heading: Heading, body: &[&str]) -> (Heading, Vec<Element>) { | |
| 268 | let mut idx = 0; | |
| 269 | while idx < body.len() && body[idx].trim().is_empty() { | |
| 270 | idx += 1; | |
| 271 | } | |
| 272 | if idx < body.len() && body[idx].trim().eq_ignore_ascii_case(":PROPERTIES:") { | |
| 273 | idx += 1; | |
| 274 | while idx < body.len() { | |
| 275 | let t = body[idx].trim(); | |
| 276 | if t.eq_ignore_ascii_case(":END:") { | |
| 277 | idx += 1; | |
| 278 | break; | |
| 279 | } | |
| 280 | if let Some((k, v)) = parse_property(t) { | |
| 281 | if k.eq_ignore_ascii_case("CUSTOM_ID") { | |
| 282 | heading.custom_id = Some(v.clone()); | |
| 283 | } else if k.eq_ignore_ascii_case("ID") { | |
| 284 | heading.id = Some(v.clone()); | |
| 285 | } | |
| 286 | heading.properties.entries.push((k, v)); | |
| 287 | } | |
| 288 | idx += 1; | |
| 289 | } | |
| 290 | } | |
| 291 | let content = parse_elements(&body[idx..]); | |
| 292 | (heading, content) | |
| 293 | } | |
| 294 | ||
| 295 | /// `:KEY: value` inside a drawer. | |
| 296 | fn parse_property(line: &str) -> Option<(String, String)> { | |
| 297 | let line = line.trim(); | |
| 298 | let line = line.strip_prefix(':')?; | |
| 299 | let end = line.find(':')?; | |
| 300 | let key = line[..end].trim().to_string(); | |
| 301 | if key.is_empty() { | |
| 302 | return None; | |
| 303 | } | |
| 304 | let value = line[end + 1..].trim().to_string(); | |
| 305 | Some((key, value)) | |
| 306 | } | |
| 307 | ||
| 308 | // --------------------------------------------------------------------------- | |
| 309 | // Block-level element builder | |
| 310 | // --------------------------------------------------------------------------- | |
| 311 | ||
| 312 | fn parse_elements(lines: &[&str]) -> Vec<Element> { | |
| 313 | let mut out = Vec::new(); | |
| 314 | let mut i = 0; | |
| 315 | while i < lines.len() { | |
| 316 | let line = lines[i]; | |
| 317 | if line.trim().is_empty() { | |
| 318 | i += 1; | |
| 319 | continue; | |
| 320 | } | |
| 321 | if let Some((kind, after)) = block_begin(line) { | |
| 322 | let mut j = i + 1; | |
| 323 | let mut inner = Vec::new(); | |
| 324 | while j < lines.len() && !is_block_end(lines[j]) { | |
| 325 | inner.push(lines[j]); | |
| 326 | j += 1; | |
| 327 | } | |
| 328 | let code = inner.join("\n"); | |
| 329 | if kind.eq_ignore_ascii_case("SRC") { | |
| 330 | let (lang, params) = parse_src_header(&after); | |
| 331 | out.push(Element::SrcBlock { lang, params, code }); | |
| 332 | } else { | |
| 333 | // Non-SRC blocks (quote/example/center/export) are kept verbatim for | |
| 334 | // v0.1 rather than richly modeled — see module scope note. | |
| 335 | out.push(Element::ExampleBlock(code)); | |
| 336 | } | |
| 337 | i = if j < lines.len() { j + 1 } else { j }; | |
| 338 | continue; | |
| 339 | } | |
| 340 | if is_rule(line) { | |
| 341 | out.push(Element::HorizontalRule); | |
| 342 | i += 1; | |
| 343 | continue; | |
| 344 | } | |
| 345 | if let Some((key, value)) = keyword_kv(line) { | |
| 346 | out.push(Element::Keyword { key, value }); | |
| 347 | i += 1; | |
| 348 | continue; | |
| 349 | } | |
| 350 | if line.trim_start().starts_with('|') { | |
| 351 | let (table, next) = parse_table(lines, i); | |
| 352 | out.push(Element::Table(table)); | |
| 353 | i = next; | |
| 354 | continue; | |
| 355 | } | |
| 356 | if let Some((label, first_rest)) = footnote_def_label(line) { | |
| 357 | let (def, next) = parse_footnote_def(lines, i, label, first_rest); | |
| 358 | out.push(def); | |
| 359 | i = next; | |
| 360 | continue; | |
| 361 | } | |
| 362 | if is_list_item(line.trim_start()).is_some() { | |
| 363 | let (list, next) = parse_list(lines, i); | |
| 364 | out.push(Element::List(list)); | |
| 365 | i = next; | |
| 366 | continue; | |
| 367 | } | |
| 368 | // Paragraph: gather consecutive soft-wrapped text lines. | |
| 369 | let mut para = Vec::new(); | |
| 370 | while i < lines.len() { | |
| 371 | let l = lines[i]; | |
| 372 | if l.trim().is_empty() || is_structural(l) { | |
| 373 | break; | |
| 374 | } | |
| 375 | para.push(l.trim()); | |
| 376 | i += 1; | |
| 377 | } | |
| 378 | if !para.is_empty() { | |
| 379 | out.push(Element::Paragraph(inline(¶.join(" ")))); | |
| 380 | } | |
| 381 | } | |
| 382 | out | |
| 383 | } | |
| 384 | ||
| 385 | /// Is this line the start of a non-paragraph construct? | |
| 386 | fn is_structural(line: &str) -> bool { | |
| 387 | let t = line.trim_start(); | |
| 388 | block_begin(line).is_some() | |
| 389 | || is_block_end(line) | |
| 390 | || is_rule(line) | |
| 391 | || keyword_kv(line).is_some() | |
| 392 | || is_list_item(t).is_some() | |
| 393 | || t.starts_with('|') | |
| 394 | || footnote_def_label(line).is_some() | |
| 395 | || heading_level(line).is_some() | |
| 396 | } | |
| 397 | ||
| 398 | // --------------------------------------------------------------------------- | |
| 399 | // Tables (spec §1 IN; `#+TBLFM:` formulas are parse-and-ignored via keyword_kv) | |
| 400 | // --------------------------------------------------------------------------- | |
| 401 | ||
| 402 | /// Consume a run of consecutive `|`-prefixed lines into a [`Table`]. Rule rows | |
| 403 | /// (`|---+---|`) are preserved as [`TableRow::Rule`] so the renderer can locate the | |
| 404 | /// header band. | |
| 405 | fn parse_table(lines: &[&str], start: usize) -> (Table, usize) { | |
| 406 | let mut rows = Vec::new(); | |
| 407 | let mut i = start; | |
| 408 | while i < lines.len() { | |
| 409 | let t = lines[i].trim_start(); | |
| 410 | if !t.starts_with('|') { | |
| 411 | break; | |
| 412 | } | |
| 413 | if is_table_rule(t) { | |
| 414 | rows.push(TableRow::Rule); | |
| 415 | } else { | |
| 416 | rows.push(TableRow::Cells(parse_table_cells(t))); | |
| 417 | } | |
| 418 | i += 1; | |
| 419 | } | |
| 420 | (Table { rows }, i) | |
| 421 | } | |
| 422 | ||
| 423 | /// A rule row: only `|`, `-`, `+`, whitespace, and at least one `-`. | |
| 424 | fn is_table_rule(t: &str) -> bool { | |
| 425 | t.starts_with('|') | |
| 426 | && t.contains('-') | |
| 427 | && t.chars().all(|c| matches!(c, '|' | '-' | '+' | ' ')) | |
| 428 | } | |
| 429 | ||
| 430 | fn parse_table_cells(t: &str) -> Vec<Vec<Object>> { | |
| 431 | let inner = t.trim().trim_start_matches('|').trim_end_matches('|'); | |
| 432 | inner.split('|').map(|cell| inline(cell.trim())).collect() | |
| 433 | } | |
| 434 | ||
| 435 | // --------------------------------------------------------------------------- | |
| 436 | // Footnote definitions (spec §1 IN; inline refs handled in the inline tokenizer) | |
| 437 | // --------------------------------------------------------------------------- | |
| 438 | ||
| 439 | /// A footnote *definition* line: `[fn:LABEL] text...`. Returns the label and the | |
| 440 | /// remainder on the same line. `[fn:LABEL:inline]` (a colon inside the label span) is | |
| 441 | /// an inline reference, not a definition, so it is rejected here. | |
| 442 | fn footnote_def_label(line: &str) -> Option<(String, String)> { | |
| 443 | let t = line.trim_start(); | |
| 444 | let r = t.strip_prefix("[fn:")?; | |
| 445 | let end = r.find(']')?; | |
| 446 | let label = &r[..end]; | |
| 447 | if label.is_empty() || label.contains(':') { | |
| 448 | return None; | |
| 449 | } | |
| 450 | Some((label.to_string(), r[end + 1..].trim_start().to_string())) | |
| 451 | } | |
| 452 | ||
| 453 | /// Gather a footnote definition's content: the remainder of its opening line plus | |
| 454 | /// following continuation lines up to the next blank/structural/definition line. | |
| 455 | fn parse_footnote_def( | |
| 456 | lines: &[&str], | |
| 457 | start: usize, | |
| 458 | label: String, | |
| 459 | first_rest: String, | |
| 460 | ) -> (Element, usize) { | |
| 461 | let mut parts: Vec<String> = Vec::new(); | |
| 462 | if !first_rest.is_empty() { | |
| 463 | parts.push(first_rest); | |
| 464 | } | |
| 465 | let mut i = start + 1; | |
| 466 | while i < lines.len() { | |
| 467 | let l = lines[i]; | |
| 468 | if l.trim().is_empty() || is_structural(l) { | |
| 469 | break; | |
| 470 | } | |
| 471 | parts.push(l.trim().to_string()); | |
| 472 | i += 1; | |
| 473 | } | |
| 474 | let content = if parts.is_empty() { | |
| 475 | Vec::new() | |
| 476 | } else { | |
| 477 | vec![Element::Paragraph(inline(&parts.join(" ")))] | |
| 478 | }; | |
| 479 | (Element::FootnoteDefinition { label, content }, i) | |
| 480 | } | |
| 481 | ||
| 482 | fn parse_list(lines: &[&str], start: usize) -> (List, usize) { | |
| 483 | let kind = match is_list_item(lines[start].trim_start()) { | |
| 484 | Some(Bullet::Ordered(_)) => ListKind::Ordered, | |
| 485 | _ => ListKind::Unordered, | |
| 486 | }; | |
| 487 | let mut items = Vec::new(); | |
| 488 | let mut i = start; | |
| 489 | while i < lines.len() { | |
| 490 | let t = lines[i].trim_start(); | |
| 491 | let bullet = match is_list_item(t) { | |
| 492 | Some(b) => b, | |
| 493 | None => break, | |
| 494 | }; | |
| 495 | let item_kind = match bullet { | |
| 496 | Bullet::Ordered(_) => ListKind::Ordered, | |
| 497 | _ => ListKind::Unordered, | |
| 498 | }; | |
| 499 | if item_kind != kind { | |
| 500 | break; | |
| 501 | } | |
| 502 | let rest = item_body(t, &bullet); | |
| 503 | let (checkbox, text) = split_checkbox(rest); | |
| 504 | items.push(ListItem { | |
| 505 | bullet, | |
| 506 | checkbox, | |
| 507 | term: None, // description lists: out of scope for v0.1. | |
| 508 | content: vec![Element::Paragraph(inline(text.trim()))], | |
| 509 | }); | |
| 510 | i += 1; | |
| 511 | } | |
| 512 | (List { kind, items }, i) | |
| 513 | } | |
| 514 | ||
| 515 | /// Text of a list item after its bullet marker. | |
| 516 | fn item_body<'a>(item: &'a str, bullet: &Bullet) -> &'a str { | |
| 517 | match bullet { | |
| 518 | Bullet::Dash | Bullet::Plus => item[1..].trim_start(), | |
| 519 | Bullet::Ordered(_) => { | |
| 520 | // Skip digits then the `.`/`)` terminator. | |
| 521 | let after_digits = item.trim_start_matches(|c: char| c.is_ascii_digit()); | |
| 522 | after_digits | |
| 523 | .strip_prefix('.') | |
| 524 | .or_else(|| after_digits.strip_prefix(')')) | |
| 525 | .unwrap_or(after_digits) | |
| 526 | .trim_start() | |
| 527 | } | |
| 528 | } | |
| 529 | } | |
| 530 | ||
| 531 | /// Detect a leading `[ ]`/`[X]`/`[-]` checkbox on a list item. | |
| 532 | fn split_checkbox(text: &str) -> (Option<Checkbox>, &str) { | |
| 533 | let bytes = text.as_bytes(); | |
| 534 | if bytes.len() >= 3 && bytes[0] == b'[' && bytes[2] == b']' { | |
| 535 | let cb = match bytes[1] { | |
| 536 | b' ' => Some(Checkbox::Off), | |
| 537 | b'X' | b'x' => Some(Checkbox::On), | |
| 538 | b'-' => Some(Checkbox::Trans), | |
| 539 | _ => None, | |
| 540 | }; | |
| 541 | if let Some(cb) = cb { | |
| 542 | return (Some(cb), text[3..].trim_start()); | |
| 543 | } | |
| 544 | } | |
| 545 | (None, text) | |
| 546 | } | |
| 547 | ||
| 548 | // --------------------------------------------------------------------------- | |
| 549 | // Line predicates / small parsers | |
| 550 | // --------------------------------------------------------------------------- | |
| 551 | ||
| 552 | fn block_begin(line: &str) -> Option<(String, String)> { | |
| 553 | let t = line.trim_start(); | |
| 554 | let upper = t.to_ascii_uppercase(); | |
| 555 | let rest_upper = upper.strip_prefix("#+BEGIN_")?; | |
| 556 | let kind_len = rest_upper | |
| 557 | .find(char::is_whitespace) | |
| 558 | .unwrap_or(rest_upper.len()); | |
| 559 | // Index back into the original-case string past "#+BEGIN_". | |
| 560 | let base = t.len() - rest_upper.len(); | |
| 561 | let kind = t[base..base + kind_len].to_string(); | |
| 562 | let after = t[base + kind_len..].trim().to_string(); | |
| 563 | Some((kind, after)) | |
| 564 | } | |
| 565 | ||
| 566 | fn is_block_end(line: &str) -> bool { | |
| 567 | line.trim_start().to_ascii_uppercase().starts_with("#+END_") | |
| 568 | } | |
| 569 | ||
| 570 | fn parse_src_header(after: &str) -> (Option<String>, BlockParams) { | |
| 571 | let mut parts = after.splitn(2, char::is_whitespace); | |
| 572 | let lang = parts.next().filter(|s| !s.is_empty()).map(|s| s.to_string()); | |
| 573 | let params = BlockParams { | |
| 574 | raw: parts.next().unwrap_or("").trim().to_string(), | |
| 575 | }; | |
| 576 | (lang, params) | |
| 577 | } | |
| 578 | ||
| 579 | /// `#+KEY: value`, excluding `#+BEGIN_`/`#+END_` block delimiters. | |
| 580 | fn keyword_kv(line: &str) -> Option<(String, String)> { | |
| 581 | let t = line.trim_start(); | |
| 582 | let rest = t.strip_prefix("#+")?; | |
| 583 | if rest.to_ascii_uppercase().starts_with("BEGIN_") | |
| 584 | || rest.to_ascii_uppercase().starts_with("END_") | |
| 585 | { | |
| 586 | return None; | |
| 587 | } | |
| 588 | let colon = rest.find(':')?; | |
| 589 | let key = rest[..colon].trim().to_string(); | |
| 590 | if key.is_empty() { | |
| 591 | return None; | |
| 592 | } | |
| 593 | let value = rest[colon + 1..].trim().to_string(); | |
| 594 | Some((key, value)) | |
| 595 | } | |
| 596 | ||
| 597 | fn is_rule(line: &str) -> bool { | |
| 598 | let t = line.trim(); | |
| 599 | t.len() >= 5 && t.chars().all(|c| c == '-') | |
| 600 | } | |
| 601 | ||
| 602 | fn is_drawer_begin(t: &str) -> bool { | |
| 603 | if !t.starts_with(':') || !t.ends_with(':') || t.len() < 3 { | |
| 604 | return false; | |
| 605 | } | |
| 606 | let inner = &t[1..t.len() - 1]; | |
| 607 | !inner.is_empty() | |
| 608 | && inner | |
| 609 | .chars() | |
| 610 | .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_') | |
| 611 | } | |
| 612 | ||
| 613 | /// If `t` (already left-trimmed) begins a list item, return its bullet. | |
| 614 | fn is_list_item(t: &str) -> Option<Bullet> { | |
| 615 | let bytes = t.as_bytes(); | |
| 616 | if bytes.is_empty() { | |
| 617 | return None; | |
| 618 | } | |
| 619 | if (bytes[0] == b'-' || bytes[0] == b'+') | |
| 620 | && (bytes.len() == 1 || bytes[1] == b' ') | |
| 621 | { | |
| 622 | return Some(if bytes[0] == b'-' { | |
| 623 | Bullet::Dash | |
| 624 | } else { | |
| 625 | Bullet::Plus | |
| 626 | }); | |
| 627 | } | |
| 628 | let digits: String = t.chars().take_while(|c| c.is_ascii_digit()).collect(); | |
| 629 | if !digits.is_empty() { | |
| 630 | let after = &t[digits.len()..]; | |
| 631 | if (after.starts_with('.') || after.starts_with(')')) | |
| 632 | && (after.len() == 1 || after.as_bytes()[1] == b' ') | |
| 633 | { | |
| 634 | if let Ok(n) = digits.parse::<u32>() { | |
| 635 | return Some(Bullet::Ordered(n)); | |
| 636 | } | |
| 637 | } | |
| 638 | } | |
| 639 | None | |
| 640 | } | |
| 641 | ||
| 642 | // --------------------------------------------------------------------------- | |
| 643 | // Inline tokenizer (spec §3.1, R3) | |
| 644 | // --------------------------------------------------------------------------- | |
| 645 | ||
| 646 | fn parse_inline_run(chars: &[char]) -> Vec<Object> { | |
| 647 | let mut out = Vec::new(); | |
| 648 | let mut buf = String::new(); | |
| 649 | let mut i = 0; | |
| 650 | let n = chars.len(); | |
| 651 | while i < n { | |
| 652 | let c = chars[i]; | |
| 653 | if c == '[' && starts_with_at(chars, i, "[fn:") { | |
| 654 | if let Some((obj, next)) = try_footnote_ref(chars, i) { | |
| 655 | flush(&mut buf, &mut out); | |
| 656 | out.push(obj); | |
| 657 | i = next; | |
| 658 | continue; | |
| 659 | } | |
| 660 | } | |
| 661 | if c == '[' && i + 1 < n && chars[i + 1] == '[' { | |
| 662 | if let Some((obj, next)) = try_link(chars, i) { | |
| 663 | flush(&mut buf, &mut out); | |
| 664 | out.push(obj); | |
| 665 | i = next; | |
| 666 | continue; | |
| 667 | } | |
| 668 | } | |
| 669 | if is_scheme_start(chars, i) && boundary_before(chars, i) { | |
| 670 | if let Some((obj, next)) = try_bare_url(chars, i) { | |
| 671 | flush(&mut buf, &mut out); | |
| 672 | out.push(obj); | |
| 673 | i = next; | |
| 674 | continue; | |
| 675 | } | |
| 676 | } | |
| 677 | if is_marker(c) { | |
| 678 | if let Some((obj, next)) = try_emphasis(chars, i) { | |
| 679 | flush(&mut buf, &mut out); | |
| 680 | out.push(obj); | |
| 681 | i = next; | |
| 682 | continue; | |
| 683 | } | |
| 684 | } | |
| 685 | buf.push(c); | |
| 686 | i += 1; | |
| 687 | } | |
| 688 | flush(&mut buf, &mut out); | |
| 689 | out | |
| 690 | } | |
| 691 | ||
| 692 | fn flush(buf: &mut String, out: &mut Vec<Object>) { | |
| 693 | if !buf.is_empty() { | |
| 694 | out.push(Object::Text(std::mem::take(buf))); | |
| 695 | } | |
| 696 | } | |
| 697 | ||
| 698 | fn starts_with_at(chars: &[char], i: usize, needle: &str) -> bool { | |
| 699 | let n: Vec<char> = needle.chars().collect(); | |
| 700 | i + n.len() <= chars.len() && chars[i..i + n.len()] == n[..] | |
| 701 | } | |
| 702 | ||
| 703 | /// A footnote reference: `[fn:LABEL]` (referenced) or `[fn:LABEL:text]` (inline | |
| 704 | /// definition). Anonymous inline footnotes `[fn::text]` carry an empty label. | |
| 705 | fn try_footnote_ref(chars: &[char], i: usize) -> Option<(Object, usize)> { | |
| 706 | let n = chars.len(); | |
| 707 | let close = (i + 1..n).find(|&k| chars[k] == ']')?; | |
| 708 | let inner: String = chars[i + 1..close].iter().collect(); | |
| 709 | let rest = inner.strip_prefix("fn:")?; | |
| 710 | let (label, inline_objs) = match rest.split_once(':') { | |
| 711 | Some((l, txt)) => { | |
| 712 | let txt_chars: Vec<char> = txt.chars().collect(); | |
| 713 | (l.to_string(), Some(parse_inline_run(&txt_chars))) | |
| 714 | } | |
| 715 | None => (rest.to_string(), None), | |
| 716 | }; | |
| 717 | if label.is_empty() && inline_objs.is_none() { | |
| 718 | return None; | |
| 719 | } | |
| 720 | Some(( | |
| 721 | Object::FootnoteRef { | |
| 722 | label, | |
| 723 | inline: inline_objs, | |
| 724 | }, | |
| 725 | close + 1, | |
| 726 | )) | |
| 727 | } | |
| 728 | ||
| 729 | /// `[[target]]` or `[[target][description]]`. | |
| 730 | fn try_link(chars: &[char], i: usize) -> Option<(Object, usize)> { | |
| 731 | let n = chars.len(); | |
| 732 | let mut j = i + 2; | |
| 733 | while j + 1 < n { | |
| 734 | if chars[j] == ']' && chars[j + 1] == ']' { | |
| 735 | let inner = &chars[i + 2..j]; | |
| 736 | let (target_str, desc) = split_link_inner(inner); | |
| 737 | let target = parse_target(&target_str); | |
| 738 | let description = desc.map(|d| parse_inline_run(&d)); | |
| 739 | return Some((Object::Link(Link { target, description }), j + 2)); | |
| 740 | } | |
| 741 | j += 1; | |
| 742 | } | |
| 743 | None | |
| 744 | } | |
| 745 | ||
| 746 | /// Split `target][desc` into its two halves at the first `][`. | |
| 747 | fn split_link_inner(inner: &[char]) -> (String, Option<Vec<char>>) { | |
| 748 | for k in 0..inner.len().saturating_sub(1) { | |
| 749 | if inner[k] == ']' && inner[k + 1] == '[' { | |
| 750 | let target: String = inner[..k].iter().collect(); | |
| 751 | let desc: Vec<char> = inner[k + 2..].to_vec(); | |
| 752 | return (target, Some(desc)); | |
| 753 | } | |
| 754 | } | |
| 755 | (inner.iter().collect(), None) | |
| 756 | } | |
| 757 | ||
| 758 | fn parse_target(s: &str) -> LinkTarget { | |
| 759 | if let Some(r) = s.strip_prefix('#') { | |
| 760 | LinkTarget::CustomId(r.to_string()) | |
| 761 | } else if let Some(r) = s.strip_prefix("id:") { | |
| 762 | LinkTarget::Id(r.to_string()) | |
| 763 | } else if let Some(r) = s.strip_prefix('*') { | |
| 764 | LinkTarget::Heading(r.to_string()) | |
| 765 | } else if let Some(r) = s.strip_prefix("file:") { | |
| 766 | LinkTarget::File { | |
| 767 | path: r.into(), | |
| 768 | search: None, | |
| 769 | } | |
| 770 | } else if is_external_scheme(s) { | |
| 771 | LinkTarget::External(s.to_string()) | |
| 772 | } else { | |
| 773 | LinkTarget::File { | |
| 774 | path: s.into(), | |
| 775 | search: None, | |
| 776 | } | |
| 777 | } | |
| 778 | } | |
| 779 | ||
| 780 | fn is_external_scheme(s: &str) -> bool { | |
| 781 | let s = s.to_ascii_lowercase(); | |
| 782 | ["http://", "https://", "mailto:", "ftp://", "news:", "tel:"] | |
| 783 | .iter() | |
| 784 | .any(|p| s.starts_with(p)) | |
| 785 | } | |
| 786 | ||
| 787 | fn is_scheme_start(chars: &[char], i: usize) -> bool { | |
| 788 | let tail: String = chars[i..].iter().take(8).collect(); | |
| 789 | let tail = tail.to_ascii_lowercase(); | |
| 790 | tail.starts_with("http://") || tail.starts_with("https://") || tail.starts_with("mailto:") | |
| 791 | } | |
| 792 | ||
| 793 | /// A bare URL in running text, e.g. `https://example.com`. | |
| 794 | fn try_bare_url(chars: &[char], i: usize) -> Option<(Object, usize)> { | |
| 795 | let n = chars.len(); | |
| 796 | let mut j = i; | |
| 797 | while j < n { | |
| 798 | let c = chars[j]; | |
| 799 | if c.is_whitespace() || matches!(c, '<' | '>' | '[' | ']' | '"' | '{' | '}') { | |
| 800 | break; | |
| 801 | } | |
| 802 | j += 1; | |
| 803 | } | |
| 804 | // Trim trailing sentence punctuation that is unlikely to be part of the URL. | |
| 805 | while j > i && matches!(chars[j - 1], '.' | ',' | ';' | ':' | '!' | '?' | ')') { | |
| 806 | j -= 1; | |
| 807 | } | |
| 808 | if j <= i { | |
| 809 | return None; | |
| 810 | } | |
| 811 | let url: String = chars[i..j].iter().collect(); | |
| 812 | Some(( | |
| 813 | Object::Link(Link { | |
| 814 | target: LinkTarget::External(url), | |
| 815 | description: None, | |
| 816 | }), | |
| 817 | j, | |
| 818 | )) | |
| 819 | } | |
| 820 | ||
| 821 | fn is_marker(c: char) -> bool { | |
| 822 | matches!(c, '*' | '/' | '_' | '+' | '=' | '~') | |
| 823 | } | |
| 824 | ||
| 825 | fn pre_ok(prev: Option<char>) -> bool { | |
| 826 | match prev { | |
| 827 | None => true, | |
| 828 | Some(c) => c.is_whitespace() || matches!(c, '-' | '(' | '{' | '\'' | '"'), | |
| 829 | } | |
| 830 | } | |
| 831 | ||
| 832 | fn post_ok(next: Option<char>) -> bool { | |
| 833 | match next { | |
| 834 | None => true, | |
| 835 | Some(c) => { | |
| 836 | c.is_whitespace() || matches!(c, '-' | '.' | ',' | ';' | ':' | '!' | '?' | ')' | '}' | '[' | '"' | '\'') | |
| 837 | } | |
| 838 | } | |
| 839 | } | |
| 840 | ||
| 841 | /// Org emphasis with pre/post-char boundary rules. `=`/`~` carry literal content. | |
| 842 | fn try_emphasis(chars: &[char], i: usize) -> Option<(Object, usize)> { | |
| 843 | let n = chars.len(); | |
| 844 | let m = chars[i]; | |
| 845 | let prev = if i == 0 { None } else { Some(chars[i - 1]) }; | |
| 846 | if !pre_ok(prev) { | |
| 847 | return None; | |
| 848 | } | |
| 849 | if i + 1 >= n { | |
| 850 | return None; | |
| 851 | } | |
| 852 | let after = chars[i + 1]; | |
| 853 | if after.is_whitespace() || after == m { | |
| 854 | return None; | |
| 855 | } | |
| 856 | let mut j = i + 1; | |
| 857 | while j < n { | |
| 858 | if chars[j] == m && j > i + 1 { | |
| 859 | let before = chars[j - 1]; | |
| 860 | let next = chars.get(j + 1).copied(); | |
| 861 | if !before.is_whitespace() && post_ok(next) { | |
| 862 | let inner = &chars[i + 1..j]; | |
| 863 | let obj = match m { | |
| 864 | '=' => Object::Verbatim(inner.iter().collect()), | |
| 865 | '~' => Object::Code(inner.iter().collect()), | |
| 866 | '*' => Object::Bold(parse_inline_run(inner)), | |
| 867 | '/' => Object::Italic(parse_inline_run(inner)), | |
| 868 | '_' => Object::Underline(parse_inline_run(inner)), | |
| 869 | '+' => Object::StrikeThrough(parse_inline_run(inner)), | |
| 870 | _ => unreachable!(), | |
| 871 | }; | |
| 872 | return Some((obj, j + 1)); | |
| 873 | } | |
| 874 | } | |
| 875 | j += 1; | |
| 876 | } | |
| 877 | None | |
| 878 | } | |
| 879 | ||
| 880 | fn boundary_before(chars: &[char], i: usize) -> bool { | |
| 881 | if i == 0 { | |
| 882 | return true; | |
| 883 | } | |
| 884 | let c = chars[i - 1]; | |
| 885 | c.is_whitespace() || matches!(c, '(' | '[' | '{' | '<' | '"' | '\'') | |
| 886 | } | |
src/render.rs added +340
| @@ -0,0 +1,340 @@ | ||
| 1 | //! RENDER stage (spec §2.1, §2.4): resolved element tree → HTML fragment. | |
| 2 | //! | |
| 3 | //! A tree walk emitting HTML into a buffer. Two sub-concerns get care (spec §2.4): | |
| 4 | //! 1. Footnotes use a two-pass layout — definitions are collected up front, references | |
| 5 | //! are numbered in order of first appearance during the walk, and a back-linked | |
| 6 | //! notes section is emitted at page end. | |
| 7 | //! 2. Syntax highlighting happens HERE, not at parse time — it is an output concern | |
| 8 | //! and its cost must be cache-skippable (spec §4.2). Emit CSS classes, not inline | |
| 9 | //! styles, so themes live in the stylesheet (spec §3.2). | |
| 10 | //! | |
| 11 | //! v0.2 renders: headings (always anchored, with tags), paragraphs, plain lists | |
| 12 | //! (unordered/ordered + checkboxes), source/example blocks, horizontal rules, tables | |
| 13 | //! (with header band from the rule row), footnotes, and inline markup. Real syntect | |
| 14 | //! tokenizing remains a `<pre><code>` passthrough for now (see [`SyntectHighlighter`]). | |
| 15 | ||
| 16 | use std::collections::HashMap; | |
| 17 | ||
| 18 | use crate::model::{Checkbox, Element, LinkTarget, ListKind, Object, Section, TableRow}; | |
| 19 | use crate::resolve::ResolvedDoc; | |
| 20 | use crate::util::{plain_text, slugify}; | |
| 21 | ||
| 22 | /// A rendered HTML fragment (content only — no page chrome; spec §2.4). | |
| 23 | #[derive(Debug, Clone)] | |
| 24 | pub struct Html(pub String); | |
| 25 | ||
| 26 | /// Pluggable highlighter so tree-sitter can replace syntect per-language later | |
| 27 | /// without touching the renderer (spec §3.2, R6). | |
| 28 | pub trait Highlighter { | |
| 29 | fn highlight(&self, code: &str, lang: Option<&str>) -> Html; | |
| 30 | } | |
| 31 | ||
| 32 | /// Default v1 highlighter. For now this is a plain `<pre><code>` passthrough that | |
| 33 | /// escapes the code and tags it with a `language-*` class; real syntect tokenizing | |
| 34 | /// to CSS-class spans is deferred (spec §3.2, §4.2). | |
| 35 | pub struct SyntectHighlighter; | |
| 36 | ||
| 37 | impl Highlighter for SyntectHighlighter { | |
| 38 | fn highlight(&self, code: &str, lang: Option<&str>) -> Html { | |
| 39 | let class = match lang { | |
| 40 | Some(l) => format!(" class=\"language-{}\"", escape_attr(l)), | |
| 41 | None => String::new(), | |
| 42 | }; | |
| 43 | Html(format!( | |
| 44 | "<pre><code{}>{}</code></pre>\n", | |
| 45 | class, | |
| 46 | escape_html(code) | |
| 47 | )) | |
| 48 | } | |
| 49 | } | |
| 50 | ||
| 51 | /// Carries the highlighter plus the footnote collector across the tree walk (spec §2.4). | |
| 52 | struct Renderer<'a> { | |
| 53 | hl: &'a dyn Highlighter, | |
| 54 | /// Block footnote definitions, keyed by label (collected before the walk). | |
| 55 | block_defs: HashMap<String, Vec<Element>>, | |
| 56 | /// Inline footnote definitions discovered at reference sites. | |
| 57 | inline_defs: HashMap<String, Vec<Object>>, | |
| 58 | /// Reference keys in order of first appearance — drives numbering and note order. | |
| 59 | order: Vec<String>, | |
| 60 | } | |
| 61 | ||
| 62 | /// Render a resolved document to an HTML fragment. | |
| 63 | pub fn render(doc: &ResolvedDoc, highlighter: &dyn Highlighter) -> Html { | |
| 64 | let mut r = Renderer { | |
| 65 | hl: highlighter, | |
| 66 | block_defs: HashMap::new(), | |
| 67 | inline_defs: HashMap::new(), | |
| 68 | order: Vec::new(), | |
| 69 | }; | |
| 70 | r.collect_defs(&doc.document.root); | |
| 71 | let mut out = String::new(); | |
| 72 | r.render_section(&doc.document.root, &mut out); | |
| 73 | r.emit_footnotes(&mut out); | |
| 74 | Html(out) | |
| 75 | } | |
| 76 | ||
| 77 | impl Renderer<'_> { | |
| 78 | /// First footnote pass: gather every block definition in the tree by label. | |
| 79 | fn collect_defs(&mut self, section: &Section) { | |
| 80 | collect_defs_in(§ion.content, &mut self.block_defs); | |
| 81 | for child in §ion.children { | |
| 82 | self.collect_defs(child); | |
| 83 | } | |
| 84 | } | |
| 85 | ||
| 86 | fn render_section(&mut self, section: &Section, out: &mut String) { | |
| 87 | if let Some(h) = §ion.heading { | |
| 88 | let level = h.level.clamp(1, 6); | |
| 89 | let anchor = h | |
| 90 | .custom_id | |
| 91 | .clone() | |
| 92 | .or_else(|| h.id.clone()) | |
| 93 | .unwrap_or_else(|| slugify(&plain_text(&h.title))); | |
| 94 | out.push_str(&format!("<h{} id=\"{}\">", level, escape_attr(&anchor))); | |
| 95 | self.render_objects(&h.title, out); | |
| 96 | for tag in &h.tags { | |
| 97 | out.push_str(&format!(" <span class=\"tag\">{}</span>", escape_html(tag))); | |
| 98 | } | |
| 99 | out.push_str(&format!("</h{}>\n", level)); | |
| 100 | } | |
| 101 | for element in §ion.content { | |
| 102 | self.render_element(element, out); | |
| 103 | } | |
| 104 | for child in §ion.children { | |
| 105 | self.render_section(child, out); | |
| 106 | } | |
| 107 | } | |
| 108 | ||
| 109 | fn render_element(&mut self, element: &Element, out: &mut String) { | |
| 110 | match element { | |
| 111 | Element::Paragraph(objs) => { | |
| 112 | out.push_str("<p>"); | |
| 113 | self.render_objects(objs, out); | |
| 114 | out.push_str("</p>\n"); | |
| 115 | } | |
| 116 | Element::List(list) => { | |
| 117 | let tag = match list.kind { | |
| 118 | ListKind::Ordered => "ol", | |
| 119 | _ => "ul", | |
| 120 | }; | |
| 121 | out.push_str(&format!("<{}>\n", tag)); | |
| 122 | for item in &list.items { | |
| 123 | out.push_str("<li>"); | |
| 124 | if let Some(cb) = &item.checkbox { | |
| 125 | let checked = matches!(cb, Checkbox::On); | |
| 126 | out.push_str(&format!( | |
| 127 | "<input type=\"checkbox\" disabled{}> ", | |
| 128 | if checked { " checked" } else { "" } | |
| 129 | )); | |
| 130 | } | |
| 131 | match item.content.as_slice() { | |
| 132 | [Element::Paragraph(objs)] => self.render_objects(objs, out), | |
| 133 | els => { | |
| 134 | for el in els { | |
| 135 | self.render_element(el, out); | |
| 136 | } | |
| 137 | } | |
| 138 | } | |
| 139 | out.push_str("</li>\n"); | |
| 140 | } | |
| 141 | out.push_str(&format!("</{}>\n", tag)); | |
| 142 | } | |
| 143 | Element::Table(table) => self.render_table(table, out), | |
| 144 | Element::SrcBlock { lang, code, .. } => { | |
| 145 | let Html(h) = self.hl.highlight(code, lang.as_deref()); | |
| 146 | out.push_str(&h); | |
| 147 | } | |
| 148 | Element::ExampleBlock(code) => { | |
| 149 | out.push_str(&format!("<pre>{}</pre>\n", escape_html(code))); | |
| 150 | } | |
| 151 | Element::HorizontalRule => out.push_str("<hr>\n"), | |
| 152 | // Definitions are emitted in the footnotes section, not inline. | |
| 153 | Element::FootnoteDefinition { .. } => {} | |
| 154 | // Out of scope (non-HTML export, generic drawers, stray keywords, comments): | |
| 155 | // emitted as nothing rather than crashing. | |
| 156 | _ => {} | |
| 157 | } | |
| 158 | } | |
| 159 | ||
| 160 | /// Rows before the first rule row become the `<thead>`; the rest are the `<tbody>`. | |
| 161 | fn render_table(&mut self, table: &crate::model::Table, out: &mut String) { | |
| 162 | let rule_at = table | |
| 163 | .rows | |
| 164 | .iter() | |
| 165 | .position(|r| matches!(r, TableRow::Rule)); | |
| 166 | out.push_str("<table>\n"); | |
| 167 | let mut wrote_body = false; | |
| 168 | let mut in_body = rule_at.is_none(); | |
| 169 | for (idx, row) in table.rows.iter().enumerate() { | |
| 170 | match row { | |
| 171 | TableRow::Rule => { | |
| 172 | if Some(idx) == rule_at { | |
| 173 | in_body = true; | |
| 174 | } | |
| 175 | continue; | |
| 176 | } | |
| 177 | TableRow::Cells(cells) => { | |
| 178 | let (open, cell_tag) = if in_body { | |
| 179 | if !wrote_body { | |
| 180 | wrote_body = true; | |
| 181 | ("<tbody>\n<tr>", "td") | |
| 182 | } else { | |
| 183 | ("<tr>", "td") | |
| 184 | } | |
| 185 | } else { | |
| 186 | ("<thead>\n<tr>", "th") | |
| 187 | }; | |
| 188 | out.push_str(open); | |
| 189 | for cell in cells { | |
| 190 | out.push_str(&format!("<{}>", cell_tag)); | |
| 191 | self.render_objects(cell, out); | |
| 192 | out.push_str(&format!("</{}>", cell_tag)); | |
| 193 | } | |
| 194 | out.push_str("</tr>\n"); | |
| 195 | // Close the header band right after its last row. | |
| 196 | if !in_body | |
| 197 | && rule_at.map(|r| idx + 1 == r).unwrap_or(false) | |
| 198 | { | |
| 199 | out.push_str("</thead>\n"); | |
| 200 | } | |
| 201 | } | |
| 202 | } | |
| 203 | } | |
| 204 | if wrote_body { | |
| 205 | out.push_str("</tbody>\n"); | |
| 206 | } | |
| 207 | out.push_str("</table>\n"); | |
| 208 | } | |
| 209 | ||
| 210 | fn render_objects(&mut self, objs: &[Object], out: &mut String) { | |
| 211 | for obj in objs { | |
| 212 | self.render_object(obj, out); | |
| 213 | } | |
| 214 | } | |
| 215 | ||
| 216 | fn render_object(&mut self, obj: &Object, out: &mut String) { | |
| 217 | match obj { | |
| 218 | Object::Text(t) => out.push_str(&escape_html(t)), | |
| 219 | Object::Bold(inner) => self.wrap(out, "strong", inner), | |
| 220 | Object::Italic(inner) => self.wrap(out, "em", inner), | |
| 221 | Object::Underline(inner) => self.wrap(out, "u", inner), | |
| 222 | Object::StrikeThrough(inner) => self.wrap(out, "del", inner), | |
| 223 | Object::Verbatim(s) => { | |
| 224 | out.push_str(&format!("<code class=\"verbatim\">{}</code>", escape_html(s))) | |
| 225 | } | |
| 226 | Object::Code(s) => out.push_str(&format!("<code>{}</code>", escape_html(s))), | |
| 227 | Object::Link(link) => { | |
| 228 | let href = link_href(&link.target); | |
| 229 | out.push_str(&format!("<a href=\"{}\">", escape_attr(&href))); | |
| 230 | match &link.description { | |
| 231 | Some(desc) => self.render_objects(desc, out), | |
| 232 | None => out.push_str(&escape_html(&link_text(&link.target))), | |
| 233 | } | |
| 234 | out.push_str("</a>"); | |
| 235 | } | |
| 236 | Object::FootnoteRef { label, inline } => { | |
| 237 | let key = if label.is_empty() { | |
| 238 | format!("__anon{}", self.order.len() + 1) | |
| 239 | } else { | |
| 240 | label.clone() | |
| 241 | }; | |
| 242 | if let Some(objs) = inline { | |
| 243 | self.inline_defs.insert(key.clone(), objs.clone()); | |
| 244 | } | |
| 245 | if !self.order.contains(&key) { | |
| 246 | self.order.push(key.clone()); | |
| 247 | } | |
| 248 | let num = self.order.iter().position(|l| l == &key).unwrap() + 1; | |
| 249 | out.push_str(&format!( | |
| 250 | "<sup class=\"footnote-ref\"><a id=\"fnr-{n}\" href=\"#fn-{n}\">{n}</a></sup>", | |
| 251 | n = num | |
| 252 | )); | |
| 253 | } | |
| 254 | Object::LineBreak => out.push_str("<br>\n"), | |
| 255 | // Timestamps, entities: out of scope for now. | |
| 256 | _ => {} | |
| 257 | } | |
| 258 | } | |
| 259 | ||
| 260 | fn wrap(&mut self, out: &mut String, tag: &str, inner: &[Object]) { | |
| 261 | out.push_str(&format!("<{}>", tag)); | |
| 262 | self.render_objects(inner, out); | |
| 263 | out.push_str(&format!("</{}>", tag)); | |
| 264 | } | |
| 265 | ||
| 266 | /// Second footnote pass: emit the numbered, back-linked notes section (spec §2.4). | |
| 267 | fn emit_footnotes(&mut self, out: &mut String) { | |
| 268 | if self.order.is_empty() { | |
| 269 | return; | |
| 270 | } | |
| 271 | let order = self.order.clone(); | |
| 272 | let inline_defs = self.inline_defs.clone(); | |
| 273 | let block_defs = self.block_defs.clone(); | |
| 274 | out.push_str("<section class=\"footnotes\">\n<hr>\n<ol>\n"); | |
| 275 | for (idx, label) in order.iter().enumerate() { | |
| 276 | let n = idx + 1; | |
| 277 | out.push_str(&format!("<li id=\"fn-{n}\">")); | |
| 278 | if let Some(objs) = inline_defs.get(label) { | |
| 279 | self.render_objects(objs, out); | |
| 280 | } else if let Some(els) = block_defs.get(label) { | |
| 281 | for el in els { | |
| 282 | self.render_element(el, out); | |
| 283 | } | |
| 284 | } | |
| 285 | out.push_str(&format!( | |
| 286 | " <a class=\"footnote-back\" href=\"#fnr-{n}\">↩</a></li>\n" | |
| 287 | )); | |
| 288 | } | |
| 289 | out.push_str("</ol>\n</section>\n"); | |
| 290 | } | |
| 291 | } | |
| 292 | ||
| 293 | fn collect_defs_in(elements: &[Element], defs: &mut HashMap<String, Vec<Element>>) { | |
| 294 | for el in elements { | |
| 295 | if let Element::FootnoteDefinition { label, content } = el { | |
| 296 | defs.entry(label.clone()).or_insert_with(|| content.clone()); | |
| 297 | } | |
| 298 | } | |
| 299 | } | |
| 300 | ||
| 301 | /// Best-effort URL for a link target. After RESOLVE, internal targets have been | |
| 302 | /// rewritten to `External` with their final URL; anything still internal here is an | |
| 303 | /// unresolved link, rendered to a plausible anchor so the page stays self-consistent. | |
| 304 | fn link_href(target: &LinkTarget) -> String { | |
| 305 | match target { | |
| 306 | LinkTarget::External(s) => s.clone(), | |
| 307 | LinkTarget::CustomId(id) => format!("#{}", id), | |
| 308 | LinkTarget::Id(id) => format!("#{}", id), | |
| 309 | LinkTarget::Heading(text) => format!("#{}", slugify(text)), | |
| 310 | LinkTarget::File { path, .. } => path.to_string(), | |
| 311 | } | |
| 312 | } | |
| 313 | ||
| 314 | fn link_text(target: &LinkTarget) -> String { | |
| 315 | match target { | |
| 316 | LinkTarget::External(s) => s.clone(), | |
| 317 | LinkTarget::CustomId(id) | LinkTarget::Id(id) => id.clone(), | |
| 318 | LinkTarget::Heading(text) => text.clone(), | |
| 319 | LinkTarget::File { path, .. } => path.to_string(), | |
| 320 | } | |
| 321 | } | |
| 322 | ||
| 323 | fn escape_html(s: &str) -> String { | |
| 324 | let mut out = String::with_capacity(s.len()); | |
| 325 | for c in s.chars() { | |
| 326 | match c { | |
| 327 | '&' => out.push_str("&"), | |
| 328 | '<' => out.push_str("<"), | |
| 329 | '>' => out.push_str(">"), | |
| 330 | _ => out.push(c), | |
| 331 | } | |
| 332 | } | |
| 333 | out | |
| 334 | } | |
| 335 | ||
| 336 | fn escape_attr(s: &str) -> String { | |
| 337 | let mut out = escape_html(s); | |
| 338 | out = out.replace('"', """); | |
| 339 | out | |
| 340 | } | |
src/resolve.rs added +173
| @@ -0,0 +1,173 @@ | ||
| 1 | //! RESOLVE stage (spec §2.1, §4.3): rewrite `LinkTarget`s to final URLs using the | |
| 2 | //! symbol table, AND record which targets each page consumed. | |
| 3 | //! | |
| 4 | //! Critical invariant (spec §4.3, R2): RESOLVE returns the used-target list as a | |
| 5 | //! first-class side output from v1, even before incrementality consumes it. Throwing | |
| 6 | //! it away would make renamed-heading invalidation impossible to compute later without | |
| 7 | //! re-resolving everything. | |
| 8 | //! | |
| 9 | //! Resolution rewrites each internal link into an [`crate::model::LinkTarget::External`] | |
| 10 | //! carrying its final URL, so the renderer needs no symbol-table knowledge. Unresolved | |
| 11 | //! links are left untouched (the renderer falls back to a best-effort anchor) and | |
| 12 | //! reported as [`BrokenLink`] warnings (spec §4.3.4). | |
| 13 | ||
| 14 | use camino::Utf8Path; | |
| 15 | ||
| 16 | use crate::index::{SymbolTable, TargetId}; | |
| 17 | use crate::model::{Document, Element, Link, LinkTarget, Object, Section, TableRow}; | |
| 18 | use crate::util::{normalize_link_path, output_url}; | |
| 19 | ||
| 20 | /// A document whose links have been rewritten to concrete URLs. | |
| 21 | #[derive(Debug, Clone)] | |
| 22 | pub struct ResolvedDoc { | |
| 23 | pub document: Document, | |
| 24 | } | |
| 25 | ||
| 26 | /// A broken internal link — surfaced as a warning, or an error under `--strict` (spec §4.3.4). | |
| 27 | #[derive(Debug, Clone)] | |
| 28 | pub struct BrokenLink { | |
| 29 | pub target: TargetId, | |
| 30 | } | |
| 31 | ||
| 32 | /// Result of resolving one page. `used_targets` are the "uses" edges (spec §4.3). | |
| 33 | #[derive(Debug)] | |
| 34 | pub struct ResolveOutput { | |
| 35 | pub resolved: ResolvedDoc, | |
| 36 | /// The "uses" edges — MUST be captured from day one (spec §4.3, R2). | |
| 37 | pub used_targets: Vec<TargetId>, | |
| 38 | pub broken: Vec<BrokenLink>, | |
| 39 | } | |
| 40 | ||
| 41 | /// `resolve(doc, &SymbolTable) -> (ResolvedDoc, Vec<TargetId used>)` (spec §4.3). | |
| 42 | pub fn resolve(doc: &Document, symbols: &SymbolTable) -> ResolveOutput { | |
| 43 | let mut document = doc.clone(); | |
| 44 | let from = doc.source_path.clone(); | |
| 45 | let mut used = Vec::new(); | |
| 46 | let mut broken = Vec::new(); | |
| 47 | let mut cx = Cx { | |
| 48 | from: &from, | |
| 49 | symbols, | |
| 50 | used: &mut used, | |
| 51 | broken: &mut broken, | |
| 52 | }; | |
| 53 | cx.section(&mut document.root); | |
| 54 | ResolveOutput { | |
| 55 | resolved: ResolvedDoc { document }, | |
| 56 | used_targets: used, | |
| 57 | broken, | |
| 58 | } | |
| 59 | } | |
| 60 | ||
| 61 | /// The text a description-less internal link should display once its target becomes a URL. | |
| 62 | fn human_text(target: &LinkTarget) -> Option<String> { | |
| 63 | match target { | |
| 64 | LinkTarget::Heading(t) => Some(t.clone()), | |
| 65 | LinkTarget::CustomId(id) | LinkTarget::Id(id) => Some(id.clone()), | |
| 66 | LinkTarget::File { path, .. } => Some(path.to_string()), | |
| 67 | LinkTarget::External(_) => None, | |
| 68 | } | |
| 69 | } | |
| 70 | ||
| 71 | struct Cx<'a> { | |
| 72 | from: &'a Utf8Path, | |
| 73 | symbols: &'a SymbolTable, | |
| 74 | used: &'a mut Vec<TargetId>, | |
| 75 | broken: &'a mut Vec<BrokenLink>, | |
| 76 | } | |
| 77 | ||
| 78 | impl Cx<'_> { | |
| 79 | fn section(&mut self, section: &mut Section) { | |
| 80 | if let Some(h) = &mut section.heading { | |
| 81 | self.objects(&mut h.title); | |
| 82 | } | |
| 83 | for el in &mut section.content { | |
| 84 | self.element(el); | |
| 85 | } | |
| 86 | for child in &mut section.children { | |
| 87 | self.section(child); | |
| 88 | } | |
| 89 | } | |
| 90 | ||
| 91 | fn element(&mut self, el: &mut Element) { | |
| 92 | match el { | |
| 93 | Element::Paragraph(objs) => self.objects(objs), | |
| 94 | Element::List(list) => { | |
| 95 | for item in &mut list.items { | |
| 96 | if let Some(term) = &mut item.term { | |
| 97 | self.objects(term); | |
| 98 | } | |
| 99 | for e in &mut item.content { | |
| 100 | self.element(e); | |
| 101 | } | |
| 102 | } | |
| 103 | } | |
| 104 | Element::Table(table) => { | |
| 105 | for row in &mut table.rows { | |
| 106 | if let TableRow::Cells(cells) = row { | |
| 107 | for cell in cells { | |
| 108 | self.objects(cell); | |
| 109 | } | |
| 110 | } | |
| 111 | } | |
| 112 | } | |
| 113 | Element::QuoteBlock(inner) | |
| 114 | | Element::CenterBlock(inner) | |
| 115 | | Element::Drawer { content: inner, .. } | |
| 116 | | Element::FootnoteDefinition { content: inner, .. } => { | |
| 117 | for e in inner { | |
| 118 | self.element(e); | |
| 119 | } | |
| 120 | } | |
| 121 | _ => {} | |
| 122 | } | |
| 123 | } | |
| 124 | ||
| 125 | fn objects(&mut self, objs: &mut [Object]) { | |
| 126 | for obj in objs { | |
| 127 | match obj { | |
| 128 | Object::Link(link) => self.link(link), | |
| 129 | Object::Bold(i) | |
| 130 | | Object::Italic(i) | |
| 131 | | Object::Underline(i) | |
| 132 | | Object::StrikeThrough(i) => self.objects(i), | |
| 133 | Object::FootnoteRef { | |
| 134 | inline: Some(i), .. | |
| 135 | } => self.objects(i), | |
| 136 | _ => {} | |
| 137 | } | |
| 138 | } | |
| 139 | } | |
| 140 | ||
| 141 | fn link(&mut self, link: &mut Link) { | |
| 142 | if let Some(desc) = &mut link.description { | |
| 143 | self.objects(desc); | |
| 144 | } | |
| 145 | let tid = match &link.target { | |
| 146 | LinkTarget::External(_) => return, | |
| 147 | LinkTarget::CustomId(id) => TargetId::CustomId(id.clone()), | |
| 148 | LinkTarget::Id(id) => TargetId::Id(id.clone()), | |
| 149 | LinkTarget::Heading(t) => TargetId::Heading(t.clone()), | |
| 150 | LinkTarget::File { path, .. } => { | |
| 151 | TargetId::File(normalize_link_path(self.from, path)) | |
| 152 | } | |
| 153 | }; | |
| 154 | match self.symbols.targets.get(&tid) { | |
| 155 | Some(loc) => { | |
| 156 | self.used.push(tid.clone()); | |
| 157 | // Preserve the human-readable text before the target becomes a bare URL, | |
| 158 | // so a description-less `[[*Heading]]` still renders as the heading text. | |
| 159 | if link.description.is_none() { | |
| 160 | if let Some(text) = human_text(&link.target) { | |
| 161 | link.description = Some(vec![Object::Text(text)]); | |
| 162 | } | |
| 163 | } | |
| 164 | let url = output_url(self.from, &loc.source_path, loc.anchor.as_deref()); | |
| 165 | link.target = LinkTarget::External(url); | |
| 166 | } | |
| 167 | None => { | |
| 168 | self.broken.push(BrokenLink { target: tid }); | |
| 169 | // Leave the original target for the renderer's best-effort fallback. | |
| 170 | } | |
| 171 | } | |
| 172 | } | |
| 173 | } | |
src/site.rs added +420
| @@ -0,0 +1,420 @@ | ||
| 1 | //! Site build: walk a source directory, PARSE every `.org` file, INDEX their targets, | |
| 2 | //! then RESOLVE + RENDER + TEMPLATE each page into a linked static site, copying | |
| 3 | //! non-`.org` assets through unchanged (spec §2.1 DISCOVER…EMIT). | |
| 4 | //! | |
| 5 | //! v0.3 wires in the incremental layer (spec §4, [`crate::incremental`]): a persisted | |
| 6 | //! cache manifest lets a rebuild re-render only the pages whose composed `render_key` | |
| 7 | //! changed, plus the pages that *link into* a changed file's targets (the dependency | |
| 8 | //! graph, spec §4.3). Unchanged pages keep their existing on-disk output untouched. | |
| 9 | //! `--no-cache` forces a full rebuild; the cache is never a correctness dependency, so a | |
| 10 | //! full rebuild and an incremental rebuild produce byte-identical output. | |
| 11 | ||
| 12 | use std::collections::HashSet; | |
| 13 | use std::fs; | |
| 14 | ||
| 15 | use anyhow::{Context, Result}; | |
| 16 | use camino::{Utf8Path, Utf8PathBuf}; | |
| 17 | use walkdir::WalkDir; | |
| 18 | ||
| 19 | use crate::incremental::{ | |
| 20 | self, combine, config_hash, render_key, resolved_links_hash, site_structure_hash, | |
| 21 | template_hash, BuildConfig, DepGraph, Hash, Manifest, PageRecord, CACHE_FORMAT_VERSION, | |
| 22 | }; | |
| 23 | use crate::index::{document_targets, SymbolTable, TargetId}; | |
| 24 | use crate::model::{ContentHash, Document}; | |
| 25 | use crate::parser::parse; | |
| 26 | use crate::render::{render, Html, SyntectHighlighter}; | |
| 27 | use crate::resolve::resolve; | |
| 28 | use crate::template::{template_sources, NavItem, Templater}; | |
| 29 | use crate::util::output_url; | |
| 30 | ||
| 31 | /// A fully built page: source and output paths (relative to their roots) and its | |
| 32 | /// final templated HTML. | |
| 33 | #[derive(Debug, Clone)] | |
| 34 | pub struct BuiltPage { | |
| 35 | pub source: Utf8PathBuf, | |
| 36 | pub output: Utf8PathBuf, | |
| 37 | pub title: String, | |
| 38 | pub html: String, | |
| 39 | } | |
| 40 | ||
| 41 | /// Unresolved internal links found during a build: `(page, target)` (spec §4.3.4). | |
| 42 | pub type BrokenLinks = Vec<(Utf8PathBuf, TargetId)>; | |
| 43 | ||
| 44 | /// Options controlling a site build. | |
| 45 | #[derive(Debug, Clone, Default)] | |
| 46 | pub struct BuildOptions { | |
| 47 | /// Bypass the incremental cache and re-render every page (spec §4.5). | |
| 48 | pub no_cache: bool, | |
| 49 | /// Treat broken internal links as a build error rather than a warning (spec §4.3.4). | |
| 50 | pub strict: bool, | |
| 51 | } | |
| 52 | ||
| 53 | /// Summary of a site build. | |
| 54 | #[derive(Debug, Default)] | |
| 55 | pub struct SiteReport { | |
| 56 | /// Every output page (rendered this build or reused from cache). | |
| 57 | pub pages: Vec<Utf8PathBuf>, | |
| 58 | /// Pages actually re-rendered and written this build (the invalidation set). | |
| 59 | pub rendered: Vec<Utf8PathBuf>, | |
| 60 | /// Pages whose existing on-disk output was reused unchanged (spec §4.1 skip rule). | |
| 61 | pub skipped: Vec<Utf8PathBuf>, | |
| 62 | pub assets: Vec<Utf8PathBuf>, | |
| 63 | /// Unresolved internal links: `(page, target)`. Warnings, not failures (spec §4.3.4). | |
| 64 | pub broken: Vec<(Utf8PathBuf, TargetId)>, | |
| 65 | } | |
| 66 | ||
| 67 | /// Everything a build needs about one page *before* the decision to render it: its | |
| 68 | /// hashes, its resolved element tree, and the dependency edges it participates in. | |
| 69 | struct PagePrep { | |
| 70 | source: Utf8PathBuf, | |
| 71 | output: Utf8PathBuf, | |
| 72 | title: String, | |
| 73 | content_hash: ContentHash, | |
| 74 | resolved: crate::resolve::ResolvedDoc, | |
| 75 | used: HashSet<TargetId>, | |
| 76 | defines: HashSet<TargetId>, | |
| 77 | broken: Vec<TargetId>, | |
| 78 | nav: Vec<NavItem>, | |
| 79 | } | |
| 80 | ||
| 81 | /// DISCOVER + PARSE + INDEX + RESOLVE the whole site, returning per-page prep and the | |
| 82 | /// global symbol table. RENDER/TEMPLATE is deferred to the caller so the incremental | |
| 83 | /// build can render only the pages it must. PARSE/INDEX/RESOLVE are cheap and pure, so | |
| 84 | /// they run for every file each build; the incremental win is on RENDER + EMIT (spec §4.4). | |
| 85 | fn prepare_pages(src: &Utf8Path) -> Result<(Vec<PagePrep>, SymbolTable)> { | |
| 86 | let (org_rel, _assets) = discover(src)?; | |
| 87 | ||
| 88 | // PARSE every file (relative paths keep snapshots and links machine-independent). | |
| 89 | let mut docs: Vec<Document> = Vec::new(); | |
| 90 | for rel in &org_rel { | |
| 91 | let abs = src.join(rel); | |
| 92 | let source = fs::read_to_string(&abs).with_context(|| format!("reading {abs}"))?; | |
| 93 | let doc = parse(rel.as_path(), &source).with_context(|| format!("parsing {rel}"))?; | |
| 94 | docs.push(doc); | |
| 95 | } | |
| 96 | ||
| 97 | // INDEX: collect every link target across the corpus. | |
| 98 | let mut symbols = SymbolTable::new(); | |
| 99 | for doc in &docs { | |
| 100 | symbols.index_document(doc); | |
| 101 | } | |
| 102 | ||
| 103 | // Nav is global; titles come from #+TITLE (falling back to the file stem). | |
| 104 | let entries: Vec<(Utf8PathBuf, String)> = docs | |
| 105 | .iter() | |
| 106 | .map(|d| (d.source_path.clone(), page_title(d))) | |
| 107 | .collect(); | |
| 108 | ||
| 109 | let mut pages = Vec::new(); | |
| 110 | for doc in &docs { | |
| 111 | let out = resolve(doc, &symbols); | |
| 112 | let used: HashSet<TargetId> = out.used_targets.iter().cloned().collect(); | |
| 113 | let broken: Vec<TargetId> = out.broken.iter().map(|b| b.target.clone()).collect(); | |
| 114 | let defines: HashSet<TargetId> = document_targets(doc).into_iter().collect(); | |
| 115 | ||
| 116 | // Nav links are relative to *this* page (spec URL scheme, §8 Q3). | |
| 117 | let nav: Vec<NavItem> = entries | |
| 118 | .iter() | |
| 119 | .map(|(path, title)| NavItem { | |
| 120 | title: title.clone(), | |
| 121 | url: output_url(&doc.source_path, path, None), | |
| 122 | }) | |
| 123 | .collect(); | |
| 124 | ||
| 125 | pages.push(PagePrep { | |
| 126 | source: doc.source_path.clone(), | |
| 127 | output: doc.source_path.with_extension("html"), | |
| 128 | title: page_title(doc), | |
| 129 | content_hash: doc.content_hash, | |
| 130 | resolved: out.resolved, | |
| 131 | used, | |
| 132 | defines, | |
| 133 | broken, | |
| 134 | nav, | |
| 135 | }); | |
| 136 | } | |
| 137 | ||
| 138 | Ok((pages, symbols)) | |
| 139 | } | |
| 140 | ||
| 141 | /// Parse + index + resolve + render + template a whole site *in memory*, without | |
| 142 | /// touching the output directory. Shared by the tests (full render, every page). | |
| 143 | pub fn render_site(src: &Utf8Path) -> Result<(Vec<BuiltPage>, BrokenLinks)> { | |
| 144 | let (preps, _symbols) = prepare_pages(src)?; | |
| 145 | let highlighter = SyntectHighlighter; | |
| 146 | let templater = Templater::new(); | |
| 147 | ||
| 148 | let mut pages = Vec::new(); | |
| 149 | let mut broken = Vec::new(); | |
| 150 | for p in &preps { | |
| 151 | for t in &p.broken { | |
| 152 | broken.push((p.source.clone(), t.clone())); | |
| 153 | } | |
| 154 | let html = render_page(&templater, &highlighter, p)?; | |
| 155 | pages.push(BuiltPage { | |
| 156 | source: p.source.clone(), | |
| 157 | output: p.output.clone(), | |
| 158 | title: p.title.clone(), | |
| 159 | html, | |
| 160 | }); | |
| 161 | } | |
| 162 | Ok((pages, broken)) | |
| 163 | } | |
| 164 | ||
| 165 | /// RENDER + TEMPLATE one prepared page into its final HTML string. | |
| 166 | fn render_page( | |
| 167 | templater: &Templater, | |
| 168 | highlighter: &SyntectHighlighter, | |
| 169 | p: &PagePrep, | |
| 170 | ) -> Result<String> { | |
| 171 | let Html(fragment) = render(&p.resolved, highlighter); | |
| 172 | templater | |
| 173 | .render_page(&p.title, &fragment, &p.nav) | |
| 174 | .with_context(|| format!("templating {}", p.source)) | |
| 175 | } | |
| 176 | ||
| 177 | /// Full site build with the incremental layer (spec §4). Renders only the pages whose | |
| 178 | /// `render_key` changed or that link into a changed file's targets; reuses the on-disk | |
| 179 | /// output of everything else; persists an updated cache manifest. | |
| 180 | pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result<SiteReport> { | |
| 181 | let (_org_rel, assets) = discover(src)?; | |
| 182 | let (preps, symbols) = prepare_pages(src)?; | |
| 183 | ||
| 184 | // The global hash classes (spec §4.1): a change in any invalidates the site. The | |
| 185 | // config hash is combined with a site-structure hash because the nav bar — global | |
| 186 | // chrome on every page — is built from every page's (path, title), so a title/path | |
| 187 | // change or a page add/remove must re-render every page (else stale nav on disk). | |
| 188 | let cfg = BuildConfig::default(); | |
| 189 | let nav_entries: Vec<(String, String)> = preps | |
| 190 | .iter() | |
| 191 | .map(|p| (p.source.to_string(), p.title.clone())) | |
| 192 | .collect(); | |
| 193 | let cfg_hash = combine(config_hash(&cfg), site_structure_hash(&nav_entries)); | |
| 194 | let tmpl_hash = template_hash(template_sources()); | |
| 195 | ||
| 196 | // Compose each page's render key and record its dependency edges. | |
| 197 | let mut new_graph = DepGraph::default(); | |
| 198 | let mut new_records: Vec<(Utf8PathBuf, PageRecord, Hash)> = Vec::new(); | |
| 199 | for p in &preps { | |
| 200 | let rlh = resolved_links_hash(&p.source, &p.used, &symbols); | |
| 201 | let key = render_key(p.content_hash, rlh, cfg_hash, tmpl_hash); | |
| 202 | new_graph.defines.insert(p.source.clone(), p.defines.clone()); | |
| 203 | new_graph.uses.insert(p.source.clone(), p.used.clone()); | |
| 204 | new_records.push(( | |
| 205 | p.source.clone(), | |
| 206 | PageRecord { | |
| 207 | content_hash: p.content_hash, | |
| 208 | render_key: key, | |
| 209 | output_path: p.output.clone(), | |
| 210 | }, | |
| 211 | key, | |
| 212 | )); | |
| 213 | } | |
| 214 | ||
| 215 | // Load the prior manifest (unless bypassed). Absent/corrupt/version-mismatch ⇒ None | |
| 216 | // ⇒ full rebuild (spec §4.5). | |
| 217 | let prior = if opts.no_cache { | |
| 218 | None | |
| 219 | } else { | |
| 220 | incremental::load_manifest(out) | |
| 221 | }; | |
| 222 | ||
| 223 | let rebuild: HashSet<Utf8PathBuf> = compute_rebuild_set( | |
| 224 | &preps, | |
| 225 | &new_records, | |
| 226 | &new_graph, | |
| 227 | cfg_hash, | |
| 228 | tmpl_hash, | |
| 229 | out, | |
| 230 | prior.as_ref(), | |
| 231 | ); | |
| 232 | ||
| 233 | // Delete outputs for pages that existed last build but are gone now (spec §4.3 step 1: | |
| 234 | // removed files). Their targets are already in the merged graph, so their linkers were | |
| 235 | // invalidated above. | |
| 236 | if let Some(prior) = &prior { | |
| 237 | let current: HashSet<&Utf8PathBuf> = preps.iter().map(|p| &p.source).collect(); | |
| 238 | for (src_path, rec) in &prior.pages { | |
| 239 | if !current.contains(src_path) { | |
| 240 | let dest = out.join(&rec.output_path); | |
| 241 | let _ = fs::remove_file(&dest); | |
| 242 | } | |
| 243 | } | |
| 244 | } | |
| 245 | ||
| 246 | let highlighter = SyntectHighlighter; | |
| 247 | let templater = Templater::new(); | |
| 248 | let mut report = SiteReport::default(); | |
| 249 | ||
| 250 | for p in &preps { | |
| 251 | for t in &p.broken { | |
| 252 | report.broken.push((p.source.clone(), t.clone())); | |
| 253 | } | |
| 254 | report.pages.push(p.output.clone()); | |
| 255 | ||
| 256 | let dest = out.join(&p.output); | |
| 257 | if rebuild.contains(&p.source) { | |
| 258 | if let Some(parent) = dest.parent() { | |
| 259 | fs::create_dir_all(parent).with_context(|| format!("creating {parent}"))?; | |
| 260 | } | |
| 261 | let html = render_page(&templater, &highlighter, p)?; | |
| 262 | fs::write(&dest, &html).with_context(|| format!("writing {dest}"))?; | |
| 263 | report.rendered.push(p.output.clone()); | |
| 264 | } else { | |
| 265 | // Skip: the on-disk output is already correct (spec §4.1). Leave it untouched. | |
| 266 | report.skipped.push(p.output.clone()); | |
| 267 | } | |
| 268 | } | |
| 269 | ||
| 270 | // Assets are a dumb copy in v0.3 (spec §8 Q11): copy every run. Cheap, and keeps the | |
| 271 | // full-vs-incremental byte equivalence trivially true for non-`.org` files. | |
| 272 | for rel in &assets { | |
| 273 | let from = src.join(rel); | |
| 274 | let dest = out.join(rel); | |
| 275 | if let Some(parent) = dest.parent() { | |
| 276 | fs::create_dir_all(parent).with_context(|| format!("creating {parent}"))?; | |
| 277 | } | |
| 278 | fs::copy(&from, &dest).with_context(|| format!("copying {from} -> {dest}"))?; | |
| 279 | report.assets.push(rel.clone()); | |
| 280 | } | |
| 281 | ||
| 282 | // Persist the manifest for the next build. | |
| 283 | let manifest = Manifest { | |
| 284 | format_version: CACHE_FORMAT_VERSION, | |
| 285 | config_hash: Some(cfg_hash), | |
| 286 | template_hash: Some(tmpl_hash), | |
| 287 | pages: new_records | |
| 288 | .into_iter() | |
| 289 | .map(|(src_path, rec, _)| (src_path, rec)) | |
| 290 | .collect(), | |
| 291 | graph: new_graph, | |
| 292 | }; | |
| 293 | incremental::save_manifest(out, &manifest) | |
| 294 | .with_context(|| format!("writing cache manifest under {out}"))?; | |
| 295 | ||
| 296 | if opts.strict && !report.broken.is_empty() { | |
| 297 | for (page, target) in &report.broken { | |
| 298 | eprintln!("error: unresolved link in {page}: {target:?}"); | |
| 299 | } | |
| 300 | anyhow::bail!( | |
| 301 | "{} unresolved internal link(s) under --strict", | |
| 302 | report.broken.len() | |
| 303 | ); | |
| 304 | } | |
| 305 | for (page, target) in &report.broken { | |
| 306 | eprintln!("warning: unresolved link in {page}: {target:?}"); | |
| 307 | } | |
| 308 | ||
| 309 | Ok(report) | |
| 310 | } | |
| 311 | ||
| 312 | /// The set of source files to (re)render this build (spec §4.3 invalidation algorithm), | |
| 313 | /// as the union of: | |
| 314 | /// - **no prior cache** (absent/corrupt/version-mismatch/`--no-cache`) ⇒ every page; | |
| 315 | /// - a **global** config- or template-hash change ⇒ every page (spec §4.1); | |
| 316 | /// - **content-changed** files ∪ pages that link into a changed file's targets, via the | |
| 317 | /// dependency graph merged with the prior build's `defines` (spec §4.3, so a removed | |
| 318 | /// target still invalidates its linkers); | |
| 319 | /// - any page whose composed **render_key** differs from the cached one (catches URL | |
| 320 | /// changes on linked targets precisely); | |
| 321 | /// - any page whose **output file is missing** on disk. | |
| 322 | fn compute_rebuild_set( | |
| 323 | preps: &[PagePrep], | |
| 324 | new_records: &[(Utf8PathBuf, PageRecord, Hash)], | |
| 325 | new_graph: &DepGraph, | |
| 326 | cfg_hash: Hash, | |
| 327 | tmpl_hash: Hash, | |
| 328 | out: &Utf8Path, | |
| 329 | prior: Option<&Manifest>, | |
| 330 | ) -> HashSet<Utf8PathBuf> { | |
| 331 | let all: HashSet<Utf8PathBuf> = preps.iter().map(|p| p.source.clone()).collect(); | |
| 332 | ||
| 333 | let Some(prior) = prior else { | |
| 334 | return all; // No usable cache ⇒ full rebuild. | |
| 335 | }; | |
| 336 | ||
| 337 | // A global config/template change invalidates every page (spec §4.1). | |
| 338 | if prior.config_hash != Some(cfg_hash) || prior.template_hash != Some(tmpl_hash) { | |
| 339 | return all; | |
| 340 | } | |
| 341 | ||
| 342 | // Content-changed = hash differs from the cached record, or the file is new. | |
| 343 | let mut changed: HashSet<Utf8PathBuf> = HashSet::new(); | |
| 344 | for p in preps { | |
| 345 | match prior.pages.get(&p.source) { | |
| 346 | Some(rec) if rec.content_hash == p.content_hash => {} | |
| 347 | _ => { | |
| 348 | changed.insert(p.source.clone()); | |
| 349 | } | |
| 350 | } | |
| 351 | } | |
| 352 | ||
| 353 | // Graph expansion: changed files ∪ pages that link into a changed file's targets. | |
| 354 | // Merge prior `defines` so a target a changed file removed still pulls its linkers. | |
| 355 | let merged = prior.graph.merged_defines_with(new_graph); | |
| 356 | let mut rebuild = incremental::invalidation_set(&changed, &merged); | |
| 357 | ||
| 358 | // Precise render_key delta (catches a linked target's URL change; also a belt for the | |
| 359 | // graph). A page whose render_key matches the cache and whose output exists is correct. | |
| 360 | for (src_path, _rec, key) in new_records { | |
| 361 | let unchanged = prior | |
| 362 | .pages | |
| 363 | .get(src_path) | |
| 364 | .map(|old| old.render_key == *key) | |
| 365 | .unwrap_or(false); | |
| 366 | if !unchanged { | |
| 367 | rebuild.insert(src_path.clone()); | |
| 368 | } | |
| 369 | } | |
| 370 | ||
| 371 | // Any page whose output file is missing must be re-emitted regardless. | |
| 372 | for p in preps { | |
| 373 | if !out.join(&p.output).exists() { | |
| 374 | rebuild.insert(p.source.clone()); | |
| 375 | } | |
| 376 | } | |
| 377 | ||
| 378 | rebuild | |
| 379 | } | |
| 380 | ||
| 381 | /// Walk `src`, returning `.org` source paths and non-`.org` asset paths, both relative | |
| 382 | /// to `src` and sorted for deterministic output. The cache manifest is not an asset. | |
| 383 | fn discover(src: &Utf8Path) -> Result<(Vec<Utf8PathBuf>, Vec<Utf8PathBuf>)> { | |
| 384 | let mut org = Vec::new(); | |
| 385 | let mut assets = Vec::new(); | |
| 386 | for entry in WalkDir::new(src).sort_by_file_name() { | |
| 387 | let entry = entry.with_context(|| format!("walking {src}"))?; | |
| 388 | if !entry.file_type().is_file() { | |
| 389 | continue; | |
| 390 | } | |
| 391 | let abs = Utf8PathBuf::from_path_buf(entry.into_path()) | |
| 392 | .map_err(|p| anyhow::anyhow!("non-UTF-8 path: {}", p.display()))?; | |
| 393 | let rel = abs | |
| 394 | .strip_prefix(src) | |
| 395 | .map(|p| p.to_owned()) | |
| 396 | .unwrap_or_else(|_| abs.clone()); | |
| 397 | if rel.extension() == Some("org") { | |
| 398 | org.push(rel); | |
| 399 | } else { | |
| 400 | assets.push(rel); | |
| 401 | } | |
| 402 | } | |
| 403 | org.sort(); | |
| 404 | assets.sort(); | |
| 405 | Ok((org, assets)) | |
| 406 | } | |
| 407 | ||
| 408 | fn page_title(doc: &Document) -> String { | |
| 409 | doc.keywords | |
| 410 | .entries | |
| 411 | .iter() | |
| 412 | .find(|(k, _)| k.eq_ignore_ascii_case("TITLE")) | |
| 413 | .map(|(_, v)| v.clone()) | |
| 414 | .unwrap_or_else(|| { | |
| 415 | doc.source_path | |
| 416 | .file_stem() | |
| 417 | .unwrap_or("untitled") | |
| 418 | .to_string() | |
| 419 | }) | |
| 420 | } | |
src/template.rs added +85
| @@ -0,0 +1,85 @@ | ||
| 1 | //! TEMPLATE stage (spec §2.1, §2.4, §3.3): rendered fragment + page metadata → full HTML. | |
| 2 | //! | |
| 3 | //! minijinja (Jinja2 semantics, runtime templates: edit-and-rebuild, no recompile). | |
| 4 | //! Templates are a hashing input for incrementality (spec §4.1): a base-layout edit | |
| 5 | //! invalidates every page that transitively uses it. Keep the fragment/template | |
| 6 | //! boundary sharp so content HTML can be snapshot-tested independently of chrome. | |
| 7 | ||
| 8 | use minijinja::{context, Environment}; | |
| 9 | use serde::Serialize; | |
| 10 | ||
| 11 | /// A navigation entry: a page title and the URL to reach it from the current page. | |
| 12 | #[derive(Debug, Clone, Serialize)] | |
| 13 | pub struct NavItem { | |
| 14 | pub title: String, | |
| 15 | pub url: String, | |
| 16 | } | |
| 17 | ||
| 18 | /// The base layout applied to every page: `<title>`, a nav bar, and the body. | |
| 19 | /// Minimal but real — a single `base` template, no partials yet. | |
| 20 | const BASE_TEMPLATE: &str = r#"<!DOCTYPE html> | |
| 21 | <html lang="en"> | |
| 22 | <head> | |
| 23 | <meta charset="utf-8"> | |
| 24 | <title>{{ title }}</title> | |
| 25 | </head> | |
| 26 | <body> | |
| 27 | <nav> | |
| 28 | {%- for item in nav %} | |
| 29 | <a href="{{ item.url }}">{{ item.title }}</a> | |
| 30 | {%- endfor %} | |
| 31 | </nav> | |
| 32 | <main> | |
| 33 | {{ body | safe }}</main> | |
| 34 | </body> | |
| 35 | </html> | |
| 36 | "#; | |
| 37 | ||
| 38 | /// The source text of every template that participates in the page layout. Hashed by | |
| 39 | /// the incremental layer (spec §4.1): a base-layout edit invalidates every page that | |
| 40 | /// uses it. There is a single `base` template today; when partials arrive this returns | |
| 41 | /// the transitive closure so a single-partial edit invalidates only its users. | |
| 42 | pub fn template_sources() -> &'static [(&'static str, &'static str)] { | |
| 43 | &[("base", BASE_TEMPLATE)] | |
| 44 | } | |
| 45 | ||
| 46 | #[derive(Debug, thiserror::Error)] | |
| 47 | pub enum TemplateError { | |
| 48 | #[error("template error: {0}")] | |
| 49 | Render(String), | |
| 50 | } | |
| 51 | ||
| 52 | /// Wraps a rendered fragment in its page template. | |
| 53 | pub struct Templater { | |
| 54 | env: Environment<'static>, | |
| 55 | } | |
| 56 | ||
| 57 | impl Templater { | |
| 58 | pub fn new() -> Self { | |
| 59 | let mut env = Environment::new(); | |
| 60 | env.add_template("base", BASE_TEMPLATE) | |
| 61 | .expect("base template compiles"); | |
| 62 | Templater { env } | |
| 63 | } | |
| 64 | ||
| 65 | /// fragment + page metadata → full HTML page. | |
| 66 | pub fn render_page( | |
| 67 | &self, | |
| 68 | title: &str, | |
| 69 | body: &str, | |
| 70 | nav: &[NavItem], | |
| 71 | ) -> Result<String, TemplateError> { | |
| 72 | let tmpl = self | |
| 73 | .env | |
| 74 | .get_template("base") | |
| 75 | .map_err(|e| TemplateError::Render(e.to_string()))?; | |
| 76 | tmpl.render(context! { title => title, body => body, nav => nav }) | |
| 77 | .map_err(|e| TemplateError::Render(e.to_string())) | |
| 78 | } | |
| 79 | } | |
| 80 | ||
| 81 | impl Default for Templater { | |
| 82 | fn default() -> Self { | |
| 83 | Self::new() | |
| 84 | } | |
| 85 | } | |
src/util.rs added +110
| @@ -0,0 +1,110 @@ | ||
| 1 | //! Small shared helpers used across INDEX, RESOLVE, RENDER and the site build: | |
| 2 | //! flattening inline objects to plain text, slugifying heading text into anchors, | |
| 3 | //! and computing relative output URLs between pages. | |
| 4 | ||
| 5 | use camino::{Utf8Path, Utf8PathBuf}; | |
| 6 | ||
| 7 | use crate::model::Object; | |
| 8 | ||
| 9 | /// Flatten inline objects to their plain-text content (markup stripped). Used to | |
| 10 | /// derive heading anchors and `[[*Heading]]` link identities (spec §4.3). | |
| 11 | pub fn plain_text(objs: &[Object]) -> String { | |
| 12 | let mut out = String::new(); | |
| 13 | plain_text_into(objs, &mut out); | |
| 14 | out | |
| 15 | } | |
| 16 | ||
| 17 | fn plain_text_into(objs: &[Object], out: &mut String) { | |
| 18 | for obj in objs { | |
| 19 | match obj { | |
| 20 | Object::Text(t) => out.push_str(t), | |
| 21 | Object::Bold(i) | |
| 22 | | Object::Italic(i) | |
| 23 | | Object::Underline(i) | |
| 24 | | Object::StrikeThrough(i) => plain_text_into(i, out), | |
| 25 | Object::Verbatim(s) | Object::Code(s) | Object::Entity(s) => out.push_str(s), | |
| 26 | Object::Link(l) => { | |
| 27 | if let Some(desc) = &l.description { | |
| 28 | plain_text_into(desc, out); | |
| 29 | } | |
| 30 | } | |
| 31 | Object::FootnoteRef { .. } | Object::Timestamp(_) | Object::LineBreak => {} | |
| 32 | } | |
| 33 | } | |
| 34 | } | |
| 35 | ||
| 36 | /// Turn heading text into a URL-safe anchor slug. | |
| 37 | pub fn slugify(text: &str) -> String { | |
| 38 | let mut out = String::new(); | |
| 39 | let mut prev_dash = false; | |
| 40 | for c in text.chars() { | |
| 41 | if c.is_alphanumeric() { | |
| 42 | out.extend(c.to_lowercase()); | |
| 43 | prev_dash = false; | |
| 44 | } else if !prev_dash { | |
| 45 | out.push('-'); | |
| 46 | prev_dash = true; | |
| 47 | } | |
| 48 | } | |
| 49 | out.trim_matches('-').to_string() | |
| 50 | } | |
| 51 | ||
| 52 | /// The output URL to reach `to_rel` (a source `.org` path relative to the site root) | |
| 53 | /// from the page at `from_rel`, honoring an optional `anchor`. Same-file links reduce | |
| 54 | /// to a bare `#anchor` fragment; cross-file links become a relative `.html` path. | |
| 55 | pub fn output_url(from_rel: &Utf8Path, to_rel: &Utf8Path, anchor: Option<&str>) -> String { | |
| 56 | let path = if from_rel == to_rel { | |
| 57 | String::new() | |
| 58 | } else { | |
| 59 | let to_html = to_rel.with_extension("html"); | |
| 60 | let from_dir = from_rel.parent().unwrap_or_else(|| Utf8Path::new("")); | |
| 61 | relative_path(from_dir, &to_html) | |
| 62 | }; | |
| 63 | match anchor { | |
| 64 | Some(a) if !a.is_empty() => { | |
| 65 | if path.is_empty() { | |
| 66 | format!("#{a}") | |
| 67 | } else { | |
| 68 | format!("{path}#{a}") | |
| 69 | } | |
| 70 | } | |
| 71 | _ => { | |
| 72 | if path.is_empty() { | |
| 73 | "#".to_string() | |
| 74 | } else { | |
| 75 | path | |
| 76 | } | |
| 77 | } | |
| 78 | } | |
| 79 | } | |
| 80 | ||
| 81 | /// Relative path from `from_dir` to `to`, using `../` where needed. `/`-joined for URLs. | |
| 82 | fn relative_path(from_dir: &Utf8Path, to: &Utf8Path) -> String { | |
| 83 | let from_c: Vec<&str> = from_dir.components().map(|c| c.as_str()).collect(); | |
| 84 | let to_c: Vec<&str> = to.components().map(|c| c.as_str()).collect(); | |
| 85 | let mut i = 0; | |
| 86 | while i < from_c.len() && i < to_c.len() && from_c[i] == to_c[i] { | |
| 87 | i += 1; | |
| 88 | } | |
| 89 | let mut parts: Vec<&str> = vec![".."; from_c.len() - i]; | |
| 90 | parts.extend(&to_c[i..]); | |
| 91 | parts.join("/") | |
| 92 | } | |
| 93 | ||
| 94 | /// Resolve a `file:` link path (as written) against the linking page's directory, | |
| 95 | /// normalizing `.`/`..` so it can be matched against indexed file targets. | |
| 96 | pub fn normalize_link_path(from_rel: &Utf8Path, path: &Utf8Path) -> Utf8PathBuf { | |
| 97 | let base = from_rel.parent().unwrap_or_else(|| Utf8Path::new("")); | |
| 98 | let joined = base.join(path); | |
| 99 | let mut stack: Vec<&str> = Vec::new(); | |
| 100 | for comp in joined.components() { | |
| 101 | match comp.as_str() { | |
| 102 | "." => {} | |
| 103 | ".." => { | |
| 104 | stack.pop(); | |
| 105 | } | |
| 106 | other => stack.push(other), | |
| 107 | } | |
| 108 | } | |
| 109 | Utf8PathBuf::from(stack.join("/")) | |
| 110 | } | |
tests/harness.rs added +34
| @@ -0,0 +1,34 @@ | ||
| 1 | //! Proves the test harness works end to end while the pipeline is still stubbed. | |
| 2 | //! | |
| 3 | //! Two layers, matching the spec's testing philosophy (§5): | |
| 4 | //! - a plain unit test on the one real function (`content_hash`); | |
| 5 | //! - an `insta` JSON snapshot of a hand-built element-tree value, standing in for the | |
| 6 | //! element-tree snapshots the parser will produce from Phase 1 onward. | |
| 7 | ||
| 8 | use org_ssg::model::{Link, LinkTarget, Object}; | |
| 9 | use org_ssg::parser::content_hash; | |
| 10 | ||
| 11 | #[test] | |
| 12 | fn content_hash_is_deterministic_blake3() { | |
| 13 | let a = content_hash(b"hello\n"); | |
| 14 | let b = content_hash(b"hello\n"); | |
| 15 | let c = content_hash(b"goodbye\n"); | |
| 16 | assert_eq!(a, b, "same bytes must hash identically"); | |
| 17 | assert_ne!(a, c, "different bytes must hash differently"); | |
| 18 | // blake3 is 32 bytes. | |
| 19 | assert_eq!(a.0.len(), 32); | |
| 20 | } | |
| 21 | ||
| 22 | #[test] | |
| 23 | fn element_tree_json_snapshot() { | |
| 24 | // A small inline-object tree built by hand — the shape the parser will emit. | |
| 25 | let tree = vec![ | |
| 26 | Object::Text("see ".into()), | |
| 27 | Object::Bold(vec![Object::Text("bold".into())]), | |
| 28 | Object::Link(Link { | |
| 29 | target: LinkTarget::CustomId("first".into()), | |
| 30 | description: Some(vec![Object::Text("the first heading".into())]), | |
| 31 | }), | |
| 32 | ]; | |
| 33 | insta::assert_json_snapshot!(tree); | |
| 34 | } | |
tests/incremental.rs added +296
| @@ -0,0 +1,296 @@ | ||
| 1 | //! Incremental build layer gates (spec §4, Phase 6). These are the hard correctness | |
| 2 | //! tests the incremental design exists to satisfy: | |
| 3 | //! | |
| 4 | //! - **Byte-equivalence**: a full (`--no-cache`) build and an incremental rebuild of an | |
| 5 | //! unchanged site produce byte-identical output, and the second build re-renders ZERO | |
| 6 | //! pages (spec §4.5, R5). | |
| 7 | //! - **Edit-one-file**: editing a page re-renders exactly that page plus the pages that | |
| 8 | //! link into it — no more, no less (spec §4.3). | |
| 9 | //! - **Renamed-heading**: renaming a heading a cross-page link points at invalidates the | |
| 10 | //! linking page and updates its emitted anchor (spec §4.3, R2 — the load-bearing case). | |
| 11 | //! - **Cache fallback**: a version bump, a missing cache, or a corrupt cache all fall | |
| 12 | //! back to a full rebuild (spec §4.5). | |
| 13 | ||
| 14 | use std::collections::BTreeMap; | |
| 15 | use std::sync::atomic::{AtomicU32, Ordering}; | |
| 16 | ||
| 17 | use camino::Utf8PathBuf; | |
| 18 | ||
| 19 | use org_ssg::incremental::{manifest_path, Manifest, CACHE_FORMAT_VERSION}; | |
| 20 | use org_ssg::site::{build_site, BuildOptions}; | |
| 21 | ||
| 22 | /// A fresh, empty temp directory unique to this process + call. | |
| 23 | fn tmpdir(tag: &str) -> Utf8PathBuf { | |
| 24 | static N: AtomicU32 = AtomicU32::new(0); | |
| 25 | let n = N.fetch_add(1, Ordering::Relaxed); | |
| 26 | let base = Utf8PathBuf::from_path_buf(std::env::temp_dir()) | |
| 27 | .expect("utf-8 temp dir") | |
| 28 | .join(format!("org-ssg-it-{}-{tag}-{n}", std::process::id())); | |
| 29 | if base.exists() { | |
| 30 | std::fs::remove_dir_all(&base).unwrap(); | |
| 31 | } | |
| 32 | std::fs::create_dir_all(&base).unwrap(); | |
| 33 | base | |
| 34 | } | |
| 35 | ||
| 36 | fn write(dir: &Utf8PathBuf, name: &str, content: &str) { | |
| 37 | std::fs::write(dir.join(name), content).unwrap(); | |
| 38 | } | |
| 39 | ||
| 40 | /// Every output file (relative path → bytes) except the cache manifest, which is an | |
| 41 | /// internal artifact with non-deterministic map ordering. | |
| 42 | fn output_files(out: &Utf8PathBuf) -> BTreeMap<String, Vec<u8>> { | |
| 43 | let mut map = BTreeMap::new(); | |
| 44 | for entry in walkdir::WalkDir::new(out).sort_by_file_name() { | |
| 45 | let entry = entry.unwrap(); | |
| 46 | if !entry.file_type().is_file() { | |
| 47 | continue; | |
| 48 | } | |
| 49 | let path = Utf8PathBuf::from_path_buf(entry.path().to_owned()).unwrap(); | |
| 50 | if path.file_name() == Some(".org-ssg-cache.json") { | |
| 51 | continue; | |
| 52 | } | |
| 53 | let rel = path.strip_prefix(out).unwrap().to_string(); | |
| 54 | map.insert(rel, std::fs::read(&path).unwrap()); | |
| 55 | } | |
| 56 | map | |
| 57 | } | |
| 58 | ||
| 59 | fn out(p: &str) -> Utf8PathBuf { | |
| 60 | Utf8PathBuf::from(p) | |
| 61 | } | |
| 62 | ||
| 63 | /// Two linked pages: `b.org` links to a `:CUSTOM_ID:` heading in `a.org`, plus a css asset. | |
| 64 | fn write_linked_site(src: &Utf8PathBuf) { | |
| 65 | write( | |
| 66 | src, | |
| 67 | "a.org", | |
| 68 | "#+TITLE: A\n\n* Setup\n:PROPERTIES:\n:CUSTOM_ID: setup\n:END:\nOriginal body.\n", | |
| 69 | ); | |
| 70 | write(src, "b.org", "#+TITLE: B\n\nSee [[#setup][the setup]].\n"); | |
| 71 | write(src, "style.css", "body { color: black; }\n"); | |
| 72 | } | |
| 73 | ||
| 74 | #[test] | |
| 75 | fn full_and_incremental_are_byte_identical_and_second_build_renders_nothing() { | |
| 76 | let root = tmpdir("byteeq"); | |
| 77 | let src = root.join("src"); | |
| 78 | std::fs::create_dir_all(&src).unwrap(); | |
| 79 | write_linked_site(&src); | |
| 80 | ||
| 81 | // Full build (cache bypassed) to a reference directory. | |
| 82 | let full = root.join("full"); | |
| 83 | let rfull = build_site( | |
| 84 | &src, | |
| 85 | &full, | |
| 86 | &BuildOptions { | |
| 87 | no_cache: true, | |
| 88 | strict: false, | |
| 89 | }, | |
| 90 | ) | |
| 91 | .unwrap(); | |
| 92 | assert_eq!(rfull.rendered.len(), 2, "full build renders every page"); | |
| 93 | ||
| 94 | // Incremental directory: first build populates the cache and renders everything. | |
| 95 | let inc = root.join("inc"); | |
| 96 | let r1 = build_site(&src, &inc, &BuildOptions::default()).unwrap(); | |
| 97 | assert_eq!(r1.rendered.len(), 2, "first incremental build renders all"); | |
| 98 | ||
| 99 | // Second incremental build of the UNCHANGED site must re-render ZERO pages. | |
| 100 | let r2 = build_site(&src, &inc, &BuildOptions::default()).unwrap(); | |
| 101 | assert!( | |
| 102 | r2.rendered.is_empty(), | |
| 103 | "unchanged rebuild must render nothing, rendered: {:?}", | |
| 104 | r2.rendered | |
| 105 | ); | |
| 106 | assert_eq!(r2.skipped.len(), 2, "both pages reused from cache"); | |
| 107 | ||
| 108 | // Full output == incremental output, byte for byte. | |
| 109 | assert_eq!( | |
| 110 | output_files(&full), | |
| 111 | output_files(&inc), | |
| 112 | "incremental output must be byte-identical to a full build" | |
| 113 | ); | |
| 114 | } | |
| 115 | ||
| 116 | #[test] | |
| 117 | fn editing_a_page_rebuilds_it_and_its_linkers_exactly() { | |
| 118 | let root = tmpdir("editone"); | |
| 119 | let src = root.join("src"); | |
| 120 | std::fs::create_dir_all(&src).unwrap(); | |
| 121 | write_linked_site(&src); | |
| 122 | let out_dir = root.join("out"); | |
| 123 | ||
| 124 | // Prime the cache. | |
| 125 | build_site(&src, &out_dir, &BuildOptions::default()).unwrap(); | |
| 126 | ||
| 127 | // Edit a.org's body (not its heading/custom-id): b.org links into a.org, so the | |
| 128 | // invalidation set is exactly {a, b} — b is re-rendered because it links to a. | |
| 129 | write( | |
| 130 | &src, | |
| 131 | "a.org", | |
| 132 | "#+TITLE: A\n\n* Setup\n:PROPERTIES:\n:CUSTOM_ID: setup\n:END:\nEdited body.\n", | |
| 133 | ); | |
| 134 | let r = build_site(&src, &out_dir, &BuildOptions::default()).unwrap(); | |
| 135 | ||
| 136 | let mut rendered = r.rendered.clone(); | |
| 137 | rendered.sort(); | |
| 138 | assert_eq!( | |
| 139 | rendered, | |
| 140 | vec![out("a.html"), out("b.html")], | |
| 141 | "editing a.org re-renders exactly a.html and its linker b.html" | |
| 142 | ); | |
| 143 | assert_eq!(r.skipped, Vec::<Utf8PathBuf>::new(), "nothing else exists to skip"); | |
| 144 | } | |
| 145 | ||
| 146 | #[test] | |
| 147 | fn editing_a_leaf_page_rebuilds_only_itself() { | |
| 148 | let root = tmpdir("editleaf"); | |
| 149 | let src = root.join("src"); | |
| 150 | std::fs::create_dir_all(&src).unwrap(); | |
| 151 | write_linked_site(&src); | |
| 152 | let out_dir = root.join("out"); | |
| 153 | ||
| 154 | build_site(&src, &out_dir, &BuildOptions::default()).unwrap(); | |
| 155 | ||
| 156 | // b.org has NO inbound links, so editing it invalidates only itself. | |
| 157 | write(&src, "b.org", "#+TITLE: B\n\nSee [[#setup][the setup]]. Edited.\n"); | |
| 158 | let r = build_site(&src, &out_dir, &BuildOptions::default()).unwrap(); | |
| 159 | ||
| 160 | assert_eq!(r.rendered, vec![out("b.html")], "only the edited leaf re-renders"); | |
| 161 | assert!( | |
| 162 | r.skipped.contains(&out("a.html")), | |
| 163 | "the unlinked page a.html is reused" | |
| 164 | ); | |
| 165 | } | |
| 166 | ||
| 167 | #[test] | |
| 168 | fn renaming_a_linked_heading_invalidates_the_linking_page() { | |
| 169 | let root = tmpdir("rename"); | |
| 170 | let src = root.join("src"); | |
| 171 | std::fs::create_dir_all(&src).unwrap(); | |
| 172 | // b.org links to a.org's heading BY TEXT (the fragile `[[*Heading]]` case, spec §4.3). | |
| 173 | write(&src, "a.org", "#+TITLE: A\n\n* Target Heading\nBody.\n"); | |
| 174 | write(&src, "b.org", "#+TITLE: B\n\nJump to [[*Target Heading][there]].\n"); | |
| 175 | let out_dir = root.join("out"); | |
| 176 | ||
| 177 | build_site(&src, &out_dir, &BuildOptions::default()).unwrap(); | |
| 178 | let b_before = std::fs::read_to_string(out_dir.join("b.html")).unwrap(); | |
| 179 | assert!( | |
| 180 | b_before.contains("a.html#target-heading"), | |
| 181 | "b.html should link to the target heading anchor initially:\n{b_before}" | |
| 182 | ); | |
| 183 | ||
| 184 | // Rename the heading a.org owns. b.org's [[*Target Heading]] now dangles. | |
| 185 | write(&src, "a.org", "#+TITLE: A\n\n* Renamed Heading\nBody.\n"); | |
| 186 | let r = build_site(&src, &out_dir, &BuildOptions::default()).unwrap(); | |
| 187 | ||
| 188 | assert!( | |
| 189 | r.rendered.contains(&out("b.html")), | |
| 190 | "the linking page must be invalidated by the rename, rendered: {:?}", | |
| 191 | r.rendered | |
| 192 | ); | |
| 193 | assert!( | |
| 194 | r.rendered.contains(&out("a.html")), | |
| 195 | "the renamed page itself is re-rendered" | |
| 196 | ); | |
| 197 | ||
| 198 | let b_after = std::fs::read_to_string(out_dir.join("b.html")).unwrap(); | |
| 199 | assert_ne!(b_before, b_after, "b.html's emitted link must change"); | |
| 200 | assert!( | |
| 201 | !b_after.contains("a.html#target-heading"), | |
| 202 | "the stale cross-file anchor must be gone:\n{b_after}" | |
| 203 | ); | |
| 204 | assert!( | |
| 205 | !r.broken.is_empty(), | |
| 206 | "the now-dangling link should be reported as broken" | |
| 207 | ); | |
| 208 | } | |
| 209 | ||
| 210 | #[test] | |
| 211 | fn changing_a_title_rebuilds_every_page_for_the_shared_nav() { | |
| 212 | let root = tmpdir("navtitle"); | |
| 213 | let src = root.join("src"); | |
| 214 | std::fs::create_dir_all(&src).unwrap(); | |
| 215 | write_linked_site(&src); | |
| 216 | let out_dir = root.join("out"); | |
| 217 | ||
| 218 | build_site(&src, &out_dir, &BuildOptions::default()).unwrap(); | |
| 219 | let b_before = std::fs::read_to_string(out_dir.join("b.html")).unwrap(); | |
| 220 | ||
| 221 | // a.org's #+TITLE feeds the nav bar on every page, so changing it must re-render all. | |
| 222 | write( | |
| 223 | &src, | |
| 224 | "a.org", | |
| 225 | "#+TITLE: A Renamed\n\n* Setup\n:PROPERTIES:\n:CUSTOM_ID: setup\n:END:\nOriginal body.\n", | |
| 226 | ); | |
| 227 | let r = build_site(&src, &out_dir, &BuildOptions::default()).unwrap(); | |
| 228 | ||
| 229 | assert_eq!(r.rendered.len(), 2, "a title change re-renders every page"); | |
| 230 | let b_after = std::fs::read_to_string(out_dir.join("b.html")).unwrap(); | |
| 231 | assert_ne!(b_before, b_after, "b.html's nav must reflect a.org's new title"); | |
| 232 | assert!(b_after.contains("A Renamed"), "b.html nav shows the updated title"); | |
| 233 | } | |
| 234 | ||
| 235 | #[test] | |
| 236 | fn missing_cache_falls_back_to_full_rebuild() { | |
| 237 | let root = tmpdir("nocache"); | |
| 238 | let src = root.join("src"); | |
| 239 | std::fs::create_dir_all(&src).unwrap(); | |
| 240 | write_linked_site(&src); | |
| 241 | let out_dir = root.join("out"); | |
| 242 | ||
| 243 | build_site(&src, &out_dir, &BuildOptions::default()).unwrap(); | |
| 244 | // Delete the cache manifest → next build has nothing to skip against. | |
| 245 | std::fs::remove_file(manifest_path(&out_dir)).unwrap(); | |
| 246 | ||
| 247 | let r = build_site(&src, &out_dir, &BuildOptions::default()).unwrap(); | |
| 248 | assert_eq!(r.rendered.len(), 2, "a missing cache forces a full rebuild"); | |
| 249 | assert!(r.skipped.is_empty()); | |
| 250 | } | |
| 251 | ||
| 252 | #[test] | |
| 253 | fn cache_version_mismatch_falls_back_to_full_rebuild() { | |
| 254 | let root = tmpdir("versionbump"); | |
| 255 | let src = root.join("src"); | |
| 256 | std::fs::create_dir_all(&src).unwrap(); | |
| 257 | write_linked_site(&src); | |
| 258 | let out_dir = root.join("out"); | |
| 259 | ||
| 260 | build_site(&src, &out_dir, &BuildOptions::default()).unwrap(); | |
| 261 | ||
| 262 | // Rewrite the manifest with a future cache-format version. On mismatch the loader | |
| 263 | // discards it (spec §4.5), so the next build re-renders everything. | |
| 264 | let stale = Manifest { | |
| 265 | format_version: CACHE_FORMAT_VERSION + 1, | |
| 266 | ..Default::default() | |
| 267 | }; | |
| 268 | std::fs::write( | |
| 269 | manifest_path(&out_dir), | |
| 270 | serde_json::to_vec(&stale).unwrap(), | |
| 271 | ) | |
| 272 | .unwrap(); | |
| 273 | ||
| 274 | let r = build_site(&src, &out_dir, &BuildOptions::default()).unwrap(); | |
| 275 | assert_eq!( | |
| 276 | r.rendered.len(), | |
| 277 | 2, | |
| 278 | "a cache-format version bump forces a full rebuild" | |
| 279 | ); | |
| 280 | assert!(r.skipped.is_empty()); | |
| 281 | } | |
| 282 | ||
| 283 | #[test] | |
| 284 | fn corrupt_cache_falls_back_without_crashing() { | |
| 285 | let root = tmpdir("corrupt"); | |
| 286 | let src = root.join("src"); | |
| 287 | std::fs::create_dir_all(&src).unwrap(); | |
| 288 | write_linked_site(&src); | |
| 289 | let out_dir = root.join("out"); | |
| 290 | ||
| 291 | build_site(&src, &out_dir, &BuildOptions::default()).unwrap(); | |
| 292 | std::fs::write(manifest_path(&out_dir), b"this is not json{{{").unwrap(); | |
| 293 | ||
| 294 | let r = build_site(&src, &out_dir, &BuildOptions::default()).unwrap(); | |
| 295 | assert_eq!(r.rendered.len(), 2, "a corrupt cache is never a correctness dependency"); | |
| 296 | } | |
tests/pipeline.rs added +48
| @@ -0,0 +1,48 @@ | ||
| 1 | //! End-to-end PARSE → RENDER snapshots for the v0.1 core org subset (spec §5). | |
| 2 | //! | |
| 3 | //! Two layers, per the spec's testing philosophy: an element-tree JSON snapshot | |
| 4 | //! (parser correctness) and a rendered-HTML snapshot (renderer correctness), for | |
| 5 | //! each fixture. | |
| 6 | ||
| 7 | use camino::Utf8PathBuf; | |
| 8 | ||
| 9 | use org_ssg::model::Document; | |
| 10 | use org_ssg::parser::parse; | |
| 11 | use org_ssg::render::{render, Html, SyntectHighlighter}; | |
| 12 | use org_ssg::resolve::ResolvedDoc; | |
| 13 | ||
| 14 | fn parse_fixture(name: &str) -> Document { | |
| 15 | let path = Utf8PathBuf::from(env!("CARGO_MANIFEST_DIR")) | |
| 16 | .join("fixtures") | |
| 17 | .join(name); | |
| 18 | let source = std::fs::read_to_string(&path).expect("read fixture"); | |
| 19 | // Use a stable relative path so snapshots don't embed an absolute machine path. | |
| 20 | parse(Utf8PathBuf::from("fixtures").join(name).as_path(), &source).expect("parse fixture") | |
| 21 | } | |
| 22 | ||
| 23 | fn render_fixture(name: &str) -> String { | |
| 24 | let document = parse_fixture(name); | |
| 25 | let resolved = ResolvedDoc { document }; | |
| 26 | let Html(html) = render(&resolved, &SyntectHighlighter); | |
| 27 | html | |
| 28 | } | |
| 29 | ||
| 30 | #[test] | |
| 31 | fn minimal_element_tree() { | |
| 32 | insta::assert_json_snapshot!(parse_fixture("minimal.org").root); | |
| 33 | } | |
| 34 | ||
| 35 | #[test] | |
| 36 | fn minimal_html() { | |
| 37 | insta::assert_snapshot!(render_fixture("minimal.org")); | |
| 38 | } | |
| 39 | ||
| 40 | #[test] | |
| 41 | fn core_element_tree() { | |
| 42 | insta::assert_json_snapshot!(parse_fixture("core.org").root); | |
| 43 | } | |
| 44 | ||
| 45 | #[test] | |
| 46 | fn core_html() { | |
| 47 | insta::assert_snapshot!(render_fixture("core.org")); | |
| 48 | } | |
tests/site.rs added +124
| @@ -0,0 +1,124 @@ | ||
| 1 | //! Multi-file site build + new-construct snapshots for v0.2 (spec §5, Phase 4/5). | |
| 2 | //! | |
| 3 | //! Layers, per the spec's testing philosophy: rendered-HTML snapshots (resolved links, | |
| 4 | //! templated pages, tables, footnotes) plus explicit assertions that a cross-file link | |
| 5 | //! resolves to the right URL and that unresolved links are reported, not fatal. | |
| 6 | ||
| 7 | use camino::Utf8PathBuf; | |
| 8 | ||
| 9 | use org_ssg::index::{SymbolTable, TargetId}; | |
| 10 | use org_ssg::parser::parse; | |
| 11 | use org_ssg::render::{render, Html, SyntectHighlighter}; | |
| 12 | use org_ssg::resolve::{resolve, ResolvedDoc}; | |
| 13 | use org_ssg::site::{render_site, BuiltPage}; | |
| 14 | ||
| 15 | fn fixtures() -> Utf8PathBuf { | |
| 16 | Utf8PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("fixtures") | |
| 17 | } | |
| 18 | ||
| 19 | fn build_fixture_site() -> Vec<BuiltPage> { | |
| 20 | let (pages, broken) = render_site(&fixtures().join("site")).expect("build site"); | |
| 21 | assert!(broken.is_empty(), "fixture site has no broken links: {broken:?}"); | |
| 22 | pages | |
| 23 | } | |
| 24 | ||
| 25 | fn page<'a>(pages: &'a [BuiltPage], source: &str) -> &'a BuiltPage { | |
| 26 | pages | |
| 27 | .iter() | |
| 28 | .find(|p| p.source == source) | |
| 29 | .unwrap_or_else(|| panic!("no page for {source}")) | |
| 30 | } | |
| 31 | ||
| 32 | fn render_fragment(name: &str) -> String { | |
| 33 | let path = fixtures().join(name); | |
| 34 | let source = std::fs::read_to_string(&path).expect("read fixture"); | |
| 35 | let document = parse(Utf8PathBuf::from(name).as_path(), &source).expect("parse"); | |
| 36 | let Html(html) = render(&ResolvedDoc { document }, &SyntectHighlighter); | |
| 37 | html | |
| 38 | } | |
| 39 | ||
| 40 | #[test] | |
| 41 | fn site_index_html() { | |
| 42 | let pages = build_fixture_site(); | |
| 43 | insta::assert_snapshot!(page(&pages, "index.org").html); | |
| 44 | } | |
| 45 | ||
| 46 | #[test] | |
| 47 | fn site_guide_html() { | |
| 48 | let pages = build_fixture_site(); | |
| 49 | insta::assert_snapshot!(page(&pages, "guide.org").html); | |
| 50 | } | |
| 51 | ||
| 52 | /// The invariant the whole RESOLVE stage exists for: a cross-file `[[#setup]]` link on | |
| 53 | /// the home page must resolve to the guide page's URL plus the target anchor. | |
| 54 | #[test] | |
| 55 | fn cross_file_link_resolves() { | |
| 56 | let pages = build_fixture_site(); | |
| 57 | let index = &page(&pages, "index.org").html; | |
| 58 | assert!( | |
| 59 | index.contains("href=\"guide.html#setup\""), | |
| 60 | "cross-file custom-id link should resolve to guide.html#setup, got:\n{index}" | |
| 61 | ); | |
| 62 | // The `file:` link resolves to the bare output path. | |
| 63 | assert!( | |
| 64 | index.contains("href=\"guide.html\""), | |
| 65 | "file: link should resolve to guide.html" | |
| 66 | ); | |
| 67 | // A same-page `[[*Overview]]` link stays a local fragment. | |
| 68 | assert!( | |
| 69 | index.contains("href=\"#overview\""), | |
| 70 | "same-page heading link should be a local fragment" | |
| 71 | ); | |
| 72 | } | |
| 73 | ||
| 74 | /// Unresolved internal links are reported as warnings, never a crash (spec §4.3.4). | |
| 75 | #[test] | |
| 76 | fn unresolved_link_is_reported_not_fatal() { | |
| 77 | let source = "Broken [[#does-not-exist][link]] here.\n"; | |
| 78 | let doc = parse(Utf8PathBuf::from("orphan.org").as_path(), source).expect("parse"); | |
| 79 | let mut symbols = SymbolTable::new(); | |
| 80 | symbols.index_document(&doc); | |
| 81 | let out = resolve(&doc, &symbols); | |
| 82 | assert_eq!(out.broken.len(), 1, "one unresolved link expected"); | |
| 83 | assert_eq!( | |
| 84 | out.broken[0].target, | |
| 85 | TargetId::CustomId("does-not-exist".into()) | |
| 86 | ); | |
| 87 | assert!(out.used_targets.is_empty(), "nothing resolved, nothing used"); | |
| 88 | } | |
| 89 | ||
| 90 | /// RESOLVE records the `uses` edges (spec §4.3, R2) even though incrementality does | |
| 91 | /// not consume them yet. | |
| 92 | #[test] | |
| 93 | fn resolve_records_used_targets() { | |
| 94 | let pages = build_fixture_site(); | |
| 95 | // index.org uses: guide's #setup, guide.org (file), and its own *Overview → 3. | |
| 96 | let _ = pages; // pages already assert no broken links; check the edge count directly. | |
| 97 | let src = fixtures().join("site").join("index.org"); | |
| 98 | let source = std::fs::read_to_string(&src).unwrap(); | |
| 99 | let doc = parse(Utf8PathBuf::from("index.org").as_path(), &source).unwrap(); | |
| 100 | ||
| 101 | let guide_src = fixtures().join("site").join("guide.org"); | |
| 102 | let guide = parse( | |
| 103 | Utf8PathBuf::from("guide.org").as_path(), | |
| 104 | &std::fs::read_to_string(&guide_src).unwrap(), | |
| 105 | ) | |
| 106 | .unwrap(); | |
| 107 | ||
| 108 | let mut symbols = SymbolTable::new(); | |
| 109 | symbols.index_document(&doc); | |
| 110 | symbols.index_document(&guide); | |
| 111 | let out = resolve(&doc, &symbols); | |
| 112 | assert_eq!(out.used_targets.len(), 3, "three internal links resolved"); | |
| 113 | assert!(out.broken.is_empty()); | |
| 114 | } | |
| 115 | ||
| 116 | #[test] | |
| 117 | fn table_render() { | |
| 118 | insta::assert_snapshot!(render_fragment("table.org")); | |
| 119 | } | |
| 120 | ||
| 121 | #[test] | |
| 122 | fn footnote_render() { | |
| 123 | insta::assert_snapshot!(render_fragment("footnote.org")); | |
| 124 | } | |
tests/snapshots/harness__element_tree_json_snapshot.snap added +28
| @@ -0,0 +1,28 @@ | ||
| 1 | --- | |
| 2 | source: tests/harness.rs | |
| 3 | expression: tree | |
| 4 | --- | |
| 5 | [ | |
| 6 | { | |
| 7 | "Text": "see " | |
| 8 | }, | |
| 9 | { | |
| 10 | "Bold": [ | |
| 11 | { | |
| 12 | "Text": "bold" | |
| 13 | } | |
| 14 | ] | |
| 15 | }, | |
| 16 | { | |
| 17 | "Link": { | |
| 18 | "target": { | |
| 19 | "CustomId": "first" | |
| 20 | }, | |
| 21 | "description": [ | |
| 22 | { | |
| 23 | "Text": "the first heading" | |
| 24 | } | |
| 25 | ] | |
| 26 | } | |
| 27 | } | |
| 28 | ] | |
tests/snapshots/pipeline__core_element_tree.snap added +200
| @@ -0,0 +1,200 @@ | ||
| 1 | --- | |
| 2 | source: tests/pipeline.rs | |
| 3 | expression: "parse_fixture(\"core.org\").root" | |
| 4 | --- | |
| 5 | { | |
| 6 | "heading": null, | |
| 7 | "content": [ | |
| 8 | { | |
| 9 | "Paragraph": [ | |
| 10 | { | |
| 11 | "Text": "Intro paragraph with a bare URL " | |
| 12 | }, | |
| 13 | { | |
| 14 | "Link": { | |
| 15 | "target": { | |
| 16 | "External": "https://example.com" | |
| 17 | }, | |
| 18 | "description": null | |
| 19 | } | |
| 20 | }, | |
| 21 | { | |
| 22 | "Text": " and some " | |
| 23 | }, | |
| 24 | { | |
| 25 | "Code": "inline code" | |
| 26 | }, | |
| 27 | { | |
| 28 | "Text": "." | |
| 29 | } | |
| 30 | ] | |
| 31 | } | |
| 32 | ], | |
| 33 | "children": [ | |
| 34 | { | |
| 35 | "heading": { | |
| 36 | "level": 1, | |
| 37 | "todo": null, | |
| 38 | "priority": null, | |
| 39 | "title": [ | |
| 40 | { | |
| 41 | "Text": "Ordered and checked" | |
| 42 | } | |
| 43 | ], | |
| 44 | "tags": [], | |
| 45 | "properties": { | |
| 46 | "entries": [] | |
| 47 | }, | |
| 48 | "id": null, | |
| 49 | "custom_id": null | |
| 50 | }, | |
| 51 | "content": [ | |
| 52 | { | |
| 53 | "List": { | |
| 54 | "kind": "Ordered", | |
| 55 | "items": [ | |
| 56 | { | |
| 57 | "bullet": { | |
| 58 | "Ordered": 1 | |
| 59 | }, | |
| 60 | "checkbox": null, | |
| 61 | "term": null, | |
| 62 | "content": [ | |
| 63 | { | |
| 64 | "Paragraph": [ | |
| 65 | { | |
| 66 | "Text": "first item" | |
| 67 | } | |
| 68 | ] | |
| 69 | } | |
| 70 | ] | |
| 71 | }, | |
| 72 | { | |
| 73 | "bullet": { | |
| 74 | "Ordered": 2 | |
| 75 | }, | |
| 76 | "checkbox": null, | |
| 77 | "term": null, | |
| 78 | "content": [ | |
| 79 | { | |
| 80 | "Paragraph": [ | |
| 81 | { | |
| 82 | "Text": "second item with " | |
| 83 | }, | |
| 84 | { | |
| 85 | "Italic": [ | |
| 86 | { | |
| 87 | "Text": "emphasis" | |
| 88 | } | |
| 89 | ] | |
| 90 | } | |
| 91 | ] | |
| 92 | } | |
| 93 | ] | |
| 94 | } | |
| 95 | ] | |
| 96 | } | |
| 97 | }, | |
| 98 | { | |
| 99 | "List": { | |
| 100 | "kind": "Unordered", | |
| 101 | "items": [ | |
| 102 | { | |
| 103 | "bullet": "Dash", | |
| 104 | "checkbox": "Off", | |
| 105 | "term": null, | |
| 106 | "content": [ | |
| 107 | { | |
| 108 | "Paragraph": [ | |
| 109 | { | |
| 110 | "Text": "todo item" | |
| 111 | } | |
| 112 | ] | |
| 113 | } | |
| 114 | ] | |
| 115 | }, | |
| 116 | { | |
| 117 | "bullet": "Dash", | |
| 118 | "checkbox": "On", | |
| 119 | "term": null, | |
| 120 | "content": [ | |
| 121 | { | |
| 122 | "Paragraph": [ | |
| 123 | { | |
| 124 | "Text": "done item" | |
| 125 | } | |
| 126 | ] | |
| 127 | } | |
| 128 | ] | |
| 129 | } | |
| 130 | ] | |
| 131 | } | |
| 132 | } | |
| 133 | ], | |
| 134 | "children": [] | |
| 135 | }, | |
| 136 | { | |
| 137 | "heading": { | |
| 138 | "level": 1, | |
| 139 | "todo": null, | |
| 140 | "priority": null, | |
| 141 | "title": [ | |
| 142 | { | |
| 143 | "Text": "Links and code" | |
| 144 | } | |
| 145 | ], | |
| 146 | "tags": [], | |
| 147 | "properties": { | |
| 148 | "entries": [] | |
| 149 | }, | |
| 150 | "id": null, | |
| 151 | "custom_id": null | |
| 152 | }, | |
| 153 | "content": [ | |
| 154 | { | |
| 155 | "Paragraph": [ | |
| 156 | { | |
| 157 | "Text": "An external " | |
| 158 | }, | |
| 159 | { | |
| 160 | "Link": { | |
| 161 | "target": { | |
| 162 | "External": "https://example.org" | |
| 163 | }, | |
| 164 | "description": [ | |
| 165 | { | |
| 166 | "Text": "site" | |
| 167 | } | |
| 168 | ] | |
| 169 | } | |
| 170 | }, | |
| 171 | { | |
| 172 | "Text": " and a bare " | |
| 173 | }, | |
| 174 | { | |
| 175 | "Link": { | |
| 176 | "target": { | |
| 177 | "External": "https://bare.example" | |
| 178 | }, | |
| 179 | "description": null | |
| 180 | } | |
| 181 | }, | |
| 182 | { | |
| 183 | "Text": "." | |
| 184 | } | |
| 185 | ] | |
| 186 | }, | |
| 187 | { | |
| 188 | "SrcBlock": { | |
| 189 | "lang": "rust", | |
| 190 | "params": { | |
| 191 | "raw": "" | |
| 192 | }, | |
| 193 | "code": "fn main() {\n println!(\"hello\");\n}" | |
| 194 | } | |
| 195 | } | |
| 196 | ], | |
| 197 | "children": [] | |
| 198 | } | |
| 199 | ] | |
| 200 | } | |
tests/snapshots/pipeline__core_html.snap added +19
| @@ -0,0 +1,19 @@ | ||
| 1 | --- | |
| 2 | source: tests/pipeline.rs | |
| 3 | expression: "render_fixture(\"core.org\")" | |
| 4 | --- | |
| 5 | <p>Intro paragraph with a bare URL <a href="https://example.com">https://example.com</a> and some <code>inline code</code>.</p> | |
| 6 | <h1 id="ordered-and-checked">Ordered and checked</h1> | |
| 7 | <ol> | |
| 8 | <li>first item</li> | |
| 9 | <li>second item with <em>emphasis</em></li> | |
| 10 | </ol> | |
| 11 | <ul> | |
| 12 | <li><input type="checkbox" disabled> todo item</li> | |
| 13 | <li><input type="checkbox" disabled checked> done item</li> | |
| 14 | </ul> | |
| 15 | <h1 id="links-and-code">Links and code</h1> | |
| 16 | <p>An external <a href="https://example.org">site</a> and a bare <a href="https://bare.example">https://bare.example</a>.</p> | |
| 17 | <pre><code class="language-rust">fn main() { | |
| 18 | println!("hello"); | |
| 19 | }</code></pre> | |
tests/snapshots/pipeline__minimal_element_tree.snap added +180
| @@ -0,0 +1,180 @@ | ||
| 1 | --- | |
| 2 | source: tests/pipeline.rs | |
| 3 | expression: "parse_fixture(\"minimal.org\").root" | |
| 4 | --- | |
| 5 | { | |
| 6 | "heading": null, | |
| 7 | "content": [ | |
| 8 | { | |
| 9 | "Paragraph": [ | |
| 10 | { | |
| 11 | "Text": "A single paragraph of preamble text before any heading." | |
| 12 | } | |
| 13 | ] | |
| 14 | } | |
| 15 | ], | |
| 16 | "children": [ | |
| 17 | { | |
| 18 | "heading": { | |
| 19 | "level": 1, | |
| 20 | "todo": null, | |
| 21 | "priority": null, | |
| 22 | "title": [ | |
| 23 | { | |
| 24 | "Text": "First Heading" | |
| 25 | } | |
| 26 | ], | |
| 27 | "tags": [], | |
| 28 | "properties": { | |
| 29 | "entries": [ | |
| 30 | [ | |
| 31 | "CUSTOM_ID", | |
| 32 | "first" | |
| 33 | ] | |
| 34 | ] | |
| 35 | }, | |
| 36 | "id": null, | |
| 37 | "custom_id": "first" | |
| 38 | }, | |
| 39 | "content": [ | |
| 40 | { | |
| 41 | "Paragraph": [ | |
| 42 | { | |
| 43 | "Text": "Some body text with " | |
| 44 | }, | |
| 45 | { | |
| 46 | "Bold": [ | |
| 47 | { | |
| 48 | "Text": "bold" | |
| 49 | } | |
| 50 | ] | |
| 51 | }, | |
| 52 | { | |
| 53 | "Text": ", " | |
| 54 | }, | |
| 55 | { | |
| 56 | "Italic": [ | |
| 57 | { | |
| 58 | "Text": "italic" | |
| 59 | } | |
| 60 | ] | |
| 61 | }, | |
| 62 | { | |
| 63 | "Text": ", and " | |
| 64 | }, | |
| 65 | { | |
| 66 | "Verbatim": "verbatim" | |
| 67 | }, | |
| 68 | { | |
| 69 | "Text": "." | |
| 70 | } | |
| 71 | ] | |
| 72 | } | |
| 73 | ], | |
| 74 | "children": [ | |
| 75 | { | |
| 76 | "heading": { | |
| 77 | "level": 2, | |
| 78 | "todo": null, | |
| 79 | "priority": null, | |
| 80 | "title": [ | |
| 81 | { | |
| 82 | "Text": "A Subheading" | |
| 83 | } | |
| 84 | ], | |
| 85 | "tags": [ | |
| 86 | "tag1", | |
| 87 | "tag2" | |
| 88 | ], | |
| 89 | "properties": { | |
| 90 | "entries": [] | |
| 91 | }, | |
| 92 | "id": null, | |
| 93 | "custom_id": null | |
| 94 | }, | |
| 95 | "content": [ | |
| 96 | { | |
| 97 | "List": { | |
| 98 | "kind": "Unordered", | |
| 99 | "items": [ | |
| 100 | { | |
| 101 | "bullet": "Dash", | |
| 102 | "checkbox": null, | |
| 103 | "term": null, | |
| 104 | "content": [ | |
| 105 | { | |
| 106 | "Paragraph": [ | |
| 107 | { | |
| 108 | "Text": "an unordered item" | |
| 109 | } | |
| 110 | ] | |
| 111 | } | |
| 112 | ] | |
| 113 | }, | |
| 114 | { | |
| 115 | "bullet": "Dash", | |
| 116 | "checkbox": null, | |
| 117 | "term": null, | |
| 118 | "content": [ | |
| 119 | { | |
| 120 | "Paragraph": [ | |
| 121 | { | |
| 122 | "Text": "another with a checkbox [ ]" | |
| 123 | } | |
| 124 | ] | |
| 125 | } | |
| 126 | ] | |
| 127 | } | |
| 128 | ] | |
| 129 | } | |
| 130 | } | |
| 131 | ], | |
| 132 | "children": [] | |
| 133 | } | |
| 134 | ] | |
| 135 | }, | |
| 136 | { | |
| 137 | "heading": { | |
| 138 | "level": 1, | |
| 139 | "todo": null, | |
| 140 | "priority": null, | |
| 141 | "title": [ | |
| 142 | { | |
| 143 | "Text": "Second Heading" | |
| 144 | } | |
| 145 | ], | |
| 146 | "tags": [], | |
| 147 | "properties": { | |
| 148 | "entries": [] | |
| 149 | }, | |
| 150 | "id": null, | |
| 151 | "custom_id": null | |
| 152 | }, | |
| 153 | "content": [ | |
| 154 | { | |
| 155 | "Paragraph": [ | |
| 156 | { | |
| 157 | "Text": "See " | |
| 158 | }, | |
| 159 | { | |
| 160 | "Link": { | |
| 161 | "target": { | |
| 162 | "CustomId": "first" | |
| 163 | }, | |
| 164 | "description": [ | |
| 165 | { | |
| 166 | "Text": "the first heading" | |
| 167 | } | |
| 168 | ] | |
| 169 | } | |
| 170 | }, | |
| 171 | { | |
| 172 | "Text": "." | |
| 173 | } | |
| 174 | ] | |
| 175 | } | |
| 176 | ], | |
| 177 | "children": [] | |
| 178 | } | |
| 179 | ] | |
| 180 | } | |
tests/snapshots/pipeline__minimal_html.snap added +14
| @@ -0,0 +1,14 @@ | ||
| 1 | --- | |
| 2 | source: tests/pipeline.rs | |
| 3 | expression: "render_fixture(\"minimal.org\")" | |
| 4 | --- | |
| 5 | <p>A single paragraph of preamble text before any heading.</p> | |
| 6 | <h1 id="first">First Heading</h1> | |
| 7 | <p>Some body text with <strong>bold</strong>, <em>italic</em>, and <code class="verbatim">verbatim</code>.</p> | |
| 8 | <h2 id="a-subheading">A Subheading <span class="tag">tag1</span> <span class="tag">tag2</span></h2> | |
| 9 | <ul> | |
| 10 | <li>an unordered item</li> | |
| 11 | <li>another with a checkbox [ ]</li> | |
| 12 | </ul> | |
| 13 | <h1 id="second-heading">Second Heading</h1> | |
| 14 | <p>See <a href="#first">the first heading</a>.</p> | |
tests/snapshots/site__footnote_render.snap added +16
| @@ -0,0 +1,16 @@ | ||
| 1 | --- | |
| 2 | source: tests/site.rs | |
| 3 | expression: "render_fragment(\"footnote.org\")" | |
| 4 | --- | |
| 5 | <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> | |
| 6 | <p>An inline footnote.<sup class="footnote-ref"><a id="fnr-3" href="#fn-3">3</a></sup></p> | |
| 7 | <section class="footnotes"> | |
| 8 | <hr> | |
| 9 | <ol> | |
| 10 | <li id="fn-1"><p>The first definition.</p> | |
| 11 | <a class="footnote-back" href="#fnr-1">↩</a></li> | |
| 12 | <li id="fn-2"><p>The second definition, with <em>emphasis</em>.</p> | |
| 13 | <a class="footnote-back" href="#fnr-2">↩</a></li> | |
| 14 | <li id="fn-3">defined right here <a class="footnote-back" href="#fnr-3">↩</a></li> | |
| 15 | </ol> | |
| 16 | </section> | |
tests/snapshots/site__site_guide_html.snap added +39
| @@ -0,0 +1,39 @@ | ||
| 1 | --- | |
| 2 | source: tests/site.rs | |
| 3 | expression: "page(&pages, \"guide.org\").html" | |
| 4 | --- | |
| 5 | <!DOCTYPE html> | |
| 6 | <html lang="en"> | |
| 7 | <head> | |
| 8 | <meta charset="utf-8"> | |
| 9 | <title>Guide</title> | |
| 10 | </head> | |
| 11 | <body> | |
| 12 | <nav> | |
| 13 | <a href="about.html">About</a> | |
| 14 | <a href="#">Guide</a> | |
| 15 | <a href="index.html">Home</a> | |
| 16 | </nav> | |
| 17 | <main> | |
| 18 | <h1 id="setup">Setup</h1> | |
| 19 | <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> | |
| 20 | <h1 id="data">Data</h1> | |
| 21 | <table> | |
| 22 | <thead> | |
| 23 | <tr><th>Name</th><th>Score</th></tr> | |
| 24 | </thead> | |
| 25 | <tbody> | |
| 26 | <tr><td>alpha</td><td>10</td></tr> | |
| 27 | <tr><td>beta</td><td>20</td></tr> | |
| 28 | </tbody> | |
| 29 | </table> | |
| 30 | <section class="footnotes"> | |
| 31 | <hr> | |
| 32 | <ol> | |
| 33 | <li id="fn-1"><p>Read the manual before you begin.</p> | |
| 34 | <a class="footnote-back" href="#fnr-1">↩</a></li> | |
| 35 | </ol> | |
| 36 | </section> | |
| 37 | </main> | |
| 38 | </body> | |
| 39 | </html> | |
tests/snapshots/site__site_index_html.snap added +24
| @@ -0,0 +1,24 @@ | ||
| 1 | --- | |
| 2 | source: tests/site.rs | |
| 3 | expression: "page(&pages, \"index.org\").html" | |
| 4 | --- | |
| 5 | <!DOCTYPE html> | |
| 6 | <html lang="en"> | |
| 7 | <head> | |
| 8 | <meta charset="utf-8"> | |
| 9 | <title>Home</title> | |
| 10 | </head> | |
| 11 | <body> | |
| 12 | <nav> | |
| 13 | <a href="about.html">About</a> | |
| 14 | <a href="guide.html">Guide</a> | |
| 15 | <a href="#">Home</a> | |
| 16 | </nav> | |
| 17 | <main> | |
| 18 | <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> | |
| 19 | <p>Also see <a href="#overview">Overview</a> further down this page.</p> | |
| 20 | <h1 id="overview">Overview</h1> | |
| 21 | <p>The overview lives on the home page.</p> | |
| 22 | </main> | |
| 23 | </body> | |
| 24 | </html> | |
tests/snapshots/site__table_render.snap added +13
| @@ -0,0 +1,13 @@ | ||
| 1 | --- | |
| 2 | source: tests/site.rs | |
| 3 | expression: "render_fragment(\"table.org\")" | |
| 4 | --- | |
| 5 | <table> | |
| 6 | <thead> | |
| 7 | <tr><th>Name</th><th>Score</th></tr> | |
| 8 | </thead> | |
| 9 | <tbody> | |
| 10 | <tr><td>alpha</td><td>10</td></tr> | |
| 11 | <tr><td>beta</td><td>20</td></tr> | |
| 12 | </tbody> | |
| 13 | </table> | |