Commit a34f015d78
Verified · cmc
Layout: unified · split
Cargo.lock +177 −8
| @@ -53,7 +53,7 @@ version = "1.1.5" | |||
| 53 | source = "registry+https://github.com/rust-lang/crates.io-index" | 53 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 54 | checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" | 54 | checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" |
| 55 | dependencies = [ | 55 | dependencies = [ |
| 56 | "windows-sys", | 56 | "windows-sys 0.61.2", |
| 57 | ] | 57 | ] |
| 58 | 58 | ||
| 59 | [[package]] | 59 | [[package]] |
| @@ -64,7 +64,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" | |||
| 64 | dependencies = [ | 64 | dependencies = [ |
| 65 | "anstyle", | 65 | "anstyle", |
| 66 | "once_cell_polyfill", | 66 | "once_cell_polyfill", |
| 67 | "windows-sys", | 67 | "windows-sys 0.61.2", |
| 68 | ] | 68 | ] |
| 69 | 69 | ||
| 70 | [[package]] | 70 | [[package]] |
| @@ -225,7 +225,7 @@ checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" | |||
| 225 | dependencies = [ | 225 | dependencies = [ |
| 226 | "encode_unicode", | 226 | "encode_unicode", |
| 227 | "libc", | 227 | "libc", |
| 228 | "windows-sys", | 228 | "windows-sys 0.61.2", |
| 229 | ] | 229 | ] |
| 230 | 230 | ||
| 231 | [[package]] | 231 | [[package]] |
| @@ -314,7 +314,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 314 | checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" | 314 | checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" |
| 315 | dependencies = [ | 315 | dependencies = [ |
| 316 | "libc", | 316 | "libc", |
| 317 | "windows-sys", | 317 | "windows-sys 0.61.2", |
| 318 | ] | 318 | ] |
| 319 | 319 | ||
| 320 | [[package]] | 320 | [[package]] |
| @@ -345,6 +345,15 @@ version = "1.0.7" | |||
| 345 | source = "registry+https://github.com/rust-lang/crates.io-index" | 345 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 346 | checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" | 346 | checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" |
| 347 | 347 | ||
| 348 | [[package]] | ||
| 349 | name = "fsevent-sys" | ||
| 350 | version = "4.1.0" | ||
| 351 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 352 | checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" | ||
| 353 | dependencies = [ | ||
| 354 | "libc", | ||
| 355 | ] | ||
| 356 | |||
| 348 | [[package]] | 357 | [[package]] |
| 349 | name = "futures-core" | 358 | name = "futures-core" |
| 350 | version = "0.3.33" | 359 | version = "0.3.33" |
| @@ -426,6 +435,26 @@ dependencies = [ | |||
| 426 | "hashbrown", | 435 | "hashbrown", |
| 427 | ] | 436 | ] |
| 428 | 437 | ||
| 438 | [[package]] | ||
| 439 | name = "inotify" | ||
| 440 | version = "0.11.4" | ||
| 441 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 442 | checksum = "153be1941a183ec9ccd095ddbe17a8b8d435ef6c76e9e02451b933c3999af2c8" | ||
| 443 | dependencies = [ | ||
| 444 | "bitflags", | ||
| 445 | "inotify-sys", | ||
| 446 | "libc", | ||
| 447 | ] | ||
| 448 | |||
| 449 | [[package]] | ||
| 450 | name = "inotify-sys" | ||
| 451 | version = "0.1.8" | ||
| 452 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 453 | checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" | ||
| 454 | dependencies = [ | ||
| 455 | "libc", | ||
| 456 | ] | ||
| 457 | |||
| 429 | [[package]] | 458 | [[package]] |
| 430 | name = "insta" | 459 | name = "insta" |
| 431 | version = "1.48.0" | 460 | version = "1.48.0" |
| @@ -462,6 +491,26 @@ dependencies = [ | |||
| 462 | "wasm-bindgen", | 491 | "wasm-bindgen", |
| 463 | ] | 492 | ] |
| 464 | 493 | ||
| 494 | [[package]] | ||
| 495 | name = "kqueue" | ||
| 496 | version = "1.2.1" | ||
| 497 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 498 | checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" | ||
| 499 | dependencies = [ | ||
| 500 | "kqueue-sys", | ||
| 501 | "libc", | ||
| 502 | ] | ||
| 503 | |||
| 504 | [[package]] | ||
| 505 | name = "kqueue-sys" | ||
| 506 | version = "1.1.2" | ||
| 507 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 508 | checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" | ||
| 509 | dependencies = [ | ||
| 510 | "bitflags", | ||
| 511 | "libc", | ||
| 512 | ] | ||
| 513 | |||
| 465 | [[package]] | 514 | [[package]] |
| 466 | name = "libc" | 515 | name = "libc" |
| 467 | version = "0.2.189" | 516 | version = "0.2.189" |
| @@ -518,6 +567,45 @@ dependencies = [ | |||
| 518 | "simd-adler32", | 567 | "simd-adler32", |
| 519 | ] | 568 | ] |
| 520 | 569 | ||
| 570 | [[package]] | ||
| 571 | name = "mio" | ||
| 572 | version = "1.2.2" | ||
| 573 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 574 | checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" | ||
| 575 | dependencies = [ | ||
| 576 | "libc", | ||
| 577 | "log", | ||
| 578 | "wasi", | ||
| 579 | "windows-sys 0.61.2", | ||
| 580 | ] | ||
| 581 | |||
| 582 | [[package]] | ||
| 583 | name = "notify" | ||
| 584 | version = "8.2.0" | ||
| 585 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 586 | checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" | ||
| 587 | dependencies = [ | ||
| 588 | "bitflags", | ||
| 589 | "fsevent-sys", | ||
| 590 | "inotify", | ||
| 591 | "kqueue", | ||
| 592 | "libc", | ||
| 593 | "log", | ||
| 594 | "mio", | ||
| 595 | "notify-types", | ||
| 596 | "walkdir", | ||
| 597 | "windows-sys 0.60.2", | ||
| 598 | ] | ||
| 599 | |||
| 600 | [[package]] | ||
| 601 | name = "notify-types" | ||
| 602 | version = "2.1.0" | ||
| 603 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 604 | checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" | ||
| 605 | dependencies = [ | ||
| 606 | "bitflags", | ||
| 607 | ] | ||
| 608 | |||
| 521 | [[package]] | 609 | [[package]] |
| 522 | name = "num-conv" | 610 | name = "num-conv" |
| 523 | version = "0.2.2" | 611 | version = "0.2.2" |
| @@ -569,7 +657,7 @@ dependencies = [ | |||
| 569 | 657 | ||
| 570 | [[package]] | 658 | [[package]] |
| 571 | name = "org-ssg" | 659 | name = "org-ssg" |
| 572 | version = "0.10.0" | 660 | version = "0.11.0" |
| 573 | dependencies = [ | 661 | dependencies = [ |
| 574 | "anyhow", | 662 | "anyhow", |
| 575 | "blake3", | 663 | "blake3", |
| @@ -578,6 +666,7 @@ dependencies = [ | |||
| 578 | "clap", | 666 | "clap", |
| 579 | "insta", | 667 | "insta", |
| 580 | "minijinja", | 668 | "minijinja", |
| 669 | "notify", | ||
| 581 | "rayon", | 670 | "rayon", |
| 582 | "serde", | 671 | "serde", |
| 583 | "serde_json", | 672 | "serde_json", |
| @@ -687,7 +776,7 @@ dependencies = [ | |||
| 687 | "errno", | 776 | "errno", |
| 688 | "libc", | 777 | "libc", |
| 689 | "linux-raw-sys", | 778 | "linux-raw-sys", |
| 690 | "windows-sys", | 779 | "windows-sys 0.61.2", |
| 691 | ] | 780 | ] |
| 692 | 781 | ||
| 693 | [[package]] | 782 | [[package]] |
| @@ -840,7 +929,7 @@ dependencies = [ | |||
| 840 | "getrandom", | 929 | "getrandom", |
| 841 | "once_cell", | 930 | "once_cell", |
| 842 | "rustix", | 931 | "rustix", |
| 843 | "windows-sys", | 932 | "windows-sys 0.61.2", |
| 844 | ] | 933 | ] |
| 845 | 934 | ||
| 846 | [[package]] | 935 | [[package]] |
| @@ -954,6 +1043,12 @@ dependencies = [ | |||
| 954 | "winapi-util", | 1043 | "winapi-util", |
| 955 | ] | 1044 | ] |
| 956 | 1045 | ||
| 1046 | [[package]] | ||
| 1047 | name = "wasi" | ||
| 1048 | version = "0.11.1+wasi-snapshot-preview1" | ||
| 1049 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1050 | checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" | ||
| 1051 | |||
| 957 | [[package]] | 1052 | [[package]] |
| 958 | name = "wasm-bindgen" | 1053 | name = "wasm-bindgen" |
| 959 | version = "0.2.127" | 1054 | version = "0.2.127" |
| @@ -1005,7 +1100,7 @@ version = "0.1.11" | |||
| 1005 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1100 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1006 | checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" | 1101 | checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" |
| 1007 | dependencies = [ | 1102 | dependencies = [ |
| 1008 | "windows-sys", | 1103 | "windows-sys 0.61.2", |
| 1009 | ] | 1104 | ] |
| 1010 | 1105 | ||
| 1011 | [[package]] | 1106 | [[package]] |
| @@ -1067,6 +1162,15 @@ dependencies = [ | |||
| 1067 | "windows-link", | 1162 | "windows-link", |
| 1068 | ] | 1163 | ] |
| 1069 | 1164 | ||
| 1165 | [[package]] | ||
| 1166 | name = "windows-sys" | ||
| 1167 | version = "0.60.2" | ||
| 1168 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1169 | checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" | ||
| 1170 | dependencies = [ | ||
| 1171 | "windows-targets", | ||
| 1172 | ] | ||
| 1173 | |||
| 1070 | [[package]] | 1174 | [[package]] |
| 1071 | name = "windows-sys" | 1175 | name = "windows-sys" |
| 1072 | version = "0.61.2" | 1176 | version = "0.61.2" |
| @@ -1076,6 +1180,71 @@ dependencies = [ | |||
| 1076 | "windows-link", | 1180 | "windows-link", |
| 1077 | ] | 1181 | ] |
| 1078 | 1182 | ||
| 1183 | [[package]] | ||
| 1184 | name = "windows-targets" | ||
| 1185 | version = "0.53.5" | ||
| 1186 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1187 | checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" | ||
| 1188 | dependencies = [ | ||
| 1189 | "windows-link", | ||
| 1190 | "windows_aarch64_gnullvm", | ||
| 1191 | "windows_aarch64_msvc", | ||
| 1192 | "windows_i686_gnu", | ||
| 1193 | "windows_i686_gnullvm", | ||
| 1194 | "windows_i686_msvc", | ||
| 1195 | "windows_x86_64_gnu", | ||
| 1196 | "windows_x86_64_gnullvm", | ||
| 1197 | "windows_x86_64_msvc", | ||
| 1198 | ] | ||
| 1199 | |||
| 1200 | [[package]] | ||
| 1201 | name = "windows_aarch64_gnullvm" | ||
| 1202 | version = "0.53.1" | ||
| 1203 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1204 | checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" | ||
| 1205 | |||
| 1206 | [[package]] | ||
| 1207 | name = "windows_aarch64_msvc" | ||
| 1208 | version = "0.53.1" | ||
| 1209 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1210 | checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" | ||
| 1211 | |||
| 1212 | [[package]] | ||
| 1213 | name = "windows_i686_gnu" | ||
| 1214 | version = "0.53.1" | ||
| 1215 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1216 | checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" | ||
| 1217 | |||
| 1218 | [[package]] | ||
| 1219 | name = "windows_i686_gnullvm" | ||
| 1220 | version = "0.53.1" | ||
| 1221 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1222 | checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" | ||
| 1223 | |||
| 1224 | [[package]] | ||
| 1225 | name = "windows_i686_msvc" | ||
| 1226 | version = "0.53.1" | ||
| 1227 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1228 | checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" | ||
| 1229 | |||
| 1230 | [[package]] | ||
| 1231 | name = "windows_x86_64_gnu" | ||
| 1232 | version = "0.53.1" | ||
| 1233 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1234 | checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" | ||
| 1235 | |||
| 1236 | [[package]] | ||
| 1237 | name = "windows_x86_64_gnullvm" | ||
| 1238 | version = "0.53.1" | ||
| 1239 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1240 | checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" | ||
| 1241 | |||
| 1242 | [[package]] | ||
| 1243 | name = "windows_x86_64_msvc" | ||
| 1244 | version = "0.53.1" | ||
| 1245 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1246 | checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" | ||
| 1247 | |||
| 1079 | [[package]] | 1248 | [[package]] |
| 1080 | name = "winnow" | 1249 | name = "winnow" |
| 1081 | version = "1.0.4" | 1250 | version = "1.0.4" |
Cargo.toml +2 −1
| @@ -1,6 +1,6 @@ | |||
| 1 | [package] | 1 | [package] |
| 2 | name = "org-ssg" | 2 | name = "org-ssg" |
| 3 | version = "0.10.0" | 3 | version = "0.11.0" |
| 4 | edition = "2021" | 4 | edition = "2021" |
| 5 | description = "Org-mode static site generator that renders the org element tree straight to HTML" | 5 | description = "Org-mode static site generator that renders the org element tree straight to HTML" |
| 6 | license = "MIT" | 6 | license = "MIT" |
| @@ -32,6 +32,7 @@ anyhow = "1" | |||
| 32 | thiserror = "2" | 32 | thiserror = "2" |
| 33 | rayon = "1.12.0" | 33 | rayon = "1.12.0" |
| 34 | toml = "1.1.4" | 34 | toml = "1.1.4" |
| 35 | notify = "8.2.0" | ||
| 35 | 36 | ||
| 36 | [dev-dependencies] | 37 | [dev-dependencies] |
| 37 | insta = { version = "1", features = ["json"] } | 38 | insta = { version = "1", features = ["json"] } |
README.md +31 −6
| @@ -295,13 +295,14 @@ all-of-org. Phase 0 checked this line against a real 179-file corpus and found i | |||
| 295 | | 3 | Inline objects — emphasis, links, bare URLs, footnote refs, timestamps | done | | 295 | | 3 | Inline objects — emphasis, links, bare URLs, footnote refs, timestamps | done | |
| 296 | | 4 | Rendering to HTML — tree walk, tables, footnote two-pass, minijinja templating, syntect highlighting | done | | 296 | | 4 | Rendering to HTML — tree walk, tables, footnote two-pass, minijinja templating, syntect highlighting | done | |
| 297 | | 5 | Link resolution + symbol table (INDEX + RESOLVE, used-target list, broken-link reporting) | done | | 297 | | 5 | Link resolution + symbol table (INDEX + RESOLVE, used-target list, broken-link reporting) | done | |
| 298 | | 6 | Incremental build layer (hashing, dep graph, invalidation) done; `watch` is a simple poll loop | done | | 298 | | 6 | Incremental build layer (hashing, dep graph, invalidation); `watch` on OS filesystem events | done | |
| 299 | | **7** | **Hardening: rayon parallelism, error locations in parse diagnostics** | **done** | | 299 | | **7** | **Hardening: rayon parallelism, error locations in parse diagnostics** | **done** | |
| 300 | | **8** | **General use: config file, user templates, nav modes, `init` scaffold, safe discovery** | **done** | | 300 | | **8** | **General use: config file, user templates, nav modes, `init` scaffold, safe discovery** | **done** | |
| 301 | | **9** | **Generated listing pages: `[[collections]]`, sorted indexes, feeds via XML templates** | **done** | | 301 | | **9** | **Generated listing pages: `[[collections]]`, sorted indexes, feeds via XML templates** | **done** | |
| 302 | | **10** | **Grouped collections: one page per tag plus a tag index — full parity with the incumbent** | **done** | | 302 | | **10** | **Grouped collections: one page per tag plus a tag index — full parity with the incumbent** | **done** | |
| 303 | | **11** | **Pagination: numbered pages with a `paginator` context, composing with grouping** | **done** | | 303 | | **11** | **Pagination: numbered pages with a `paginator` context, composing with grouping** | **done** | |
| 304 | | **12** | **`base_url`: `absolute`/`rfc822` filters, a valid RSS feed in the scaffold, canonical links** | **done** | | 304 | | **12** | **`base_url`: `absolute`/`rfc822` filters, a valid RSS feed in the scaffold, canonical links** | **done** | |
| 305 | | **13** | **`watch` on OS filesystem events, debounced, with the feedback loop closed** | **done** | | ||
| 305 | 306 | ||
| 306 | ### v0.2 in / out | 307 | ### v0.2 in / out |
| 307 | 308 | ||
| @@ -395,8 +396,32 @@ as literal text; drawers other than PROPERTIES are captured and dropped; unmodel | |||
| 395 | types keep their content verbatim. | 396 | types keep their content verbatim. |
| 396 | 397 | ||
| 397 | **Still out:** `#+TODO:` per-file keyword sequences; planning lines | 398 | **Still out:** `#+TODO:` per-file keyword sequences; planning lines |
| 398 | (`SCHEDULED:`/`DEADLINE:`), which render as ordinary paragraphs; fixed-width `: ` lines; | 399 | (`SCHEDULED:`/`DEADLINE:`), which render as ordinary paragraphs; and fixed-width `: ` |
| 399 | and the `watch` fs-notify integration. | 400 | lines. |
| 401 | |||
| 402 | ## Watching | ||
| 403 | |||
| 404 | ```bash | ||
| 405 | cargo run -- watch my-site -o _site | ||
| 406 | ``` | ||
| 407 | |||
| 408 | Rebuilds on OS filesystem events rather than polling, so it costs nothing while nothing | ||
| 409 | happens. Write bursts are debounced — an editor saving a file writes a temp file, renames | ||
| 410 | it over the original and touches the directory, which is one edit and several events. | ||
| 411 | |||
| 412 | Two rules decide what counts as a change, and they are not the same rules the build uses | ||
| 413 | to find content: | ||
| 414 | |||
| 415 | - **A build input is a change.** Editing `org-ssg.toml` or a template rebuilds, even | ||
| 416 | though discovery skips both as non-content. The question is "would this change the | ||
| 417 | site?", not "is this a page?". | ||
| 418 | - **Our own output is not.** `watch . -o _site` puts the output inside the source, so a | ||
| 419 | rebuild's writes raise events that would trigger a rebuild, forever. Dot-directories go | ||
| 420 | the same way — `.git` churns on every command — as do editor scratch files, including | ||
| 421 | Emacs' `file.org~` backups, which do not start with a dot. | ||
| 422 | |||
| 423 | Where native watching is unavailable (some network and container filesystems), it falls | ||
| 424 | back to polling and says so, rather than failing. | ||
| 400 | 425 | ||
| 401 | ## Phase 0: the corpus audit and the Emacs oracle | 426 | ## Phase 0: the corpus audit and the Emacs oracle |
| 402 | 427 | ||
| @@ -558,20 +583,20 @@ Parser is hand-written recursive descent (not `nom`/`chumsky`/`pest` — org is | |||
| 558 | line-oriented and context-sensitive, not clean CFG). Key crates: `syntect` (syntax | 583 | line-oriented and context-sensitive, not clean CFG). Key crates: `syntect` (syntax |
| 559 | highlighting, behind a `Highlighter` trait so tree-sitter can be swapped in later), | 584 | highlighting, behind a `Highlighter` trait so tree-sitter can be swapped in later), |
| 560 | `minijinja` (runtime templates), `blake3` (content/cache hashing), `rayon` (parallel | 585 | `minijinja` (runtime templates), `blake3` (content/cache hashing), `rayon` (parallel |
| 561 | PARSE/RESOLVE/RENDER), `chrono`, `camino`, `walkdir`, `clap`, `anyhow`/`thiserror`. | 586 | PARSE/RESOLVE/RENDER), `notify` (filesystem events for `watch`), `toml` (config), `chrono`, `camino`, `walkdir`, `clap`, `anyhow`/`thiserror`. |
| 562 | `insta` for snapshot tests, and `emacs --batch` — optional, and only for the oracle. | 587 | `insta` for snapshot tests, and `emacs --batch` — optional, and only for the oracle. |
| 563 | 588 | ||
| 564 | ## Build & test | 589 | ## Build & test |
| 565 | 590 | ||
| 566 | ``` | 591 | ``` |
| 567 | cargo build | 592 | cargo build |
| 568 | cargo test # 122 tests | 593 | cargo test # 128 tests |
| 569 | cargo run -- init my-site # scaffold a new site | 594 | cargo run -- init my-site # scaffold a new site |
| 570 | cargo run -- build fixtures/minimal.org -o minimal.html # single file | 595 | cargo run -- build fixtures/minimal.org -o minimal.html # single file |
| 571 | cargo run -- build fixtures/site -o _site # whole site (incremental) | 596 | cargo run -- build fixtures/site -o _site # whole site (incremental) |
| 572 | cargo run -- audit fixtures/site # corpus audit (Phase 0) | 597 | cargo run -- audit fixtures/site # corpus audit (Phase 0) |
| 573 | cargo run -- build fixtures/site -o _site --no-cache # force a full rebuild | 598 | cargo run -- build fixtures/site -o _site --no-cache # force a full rebuild |
| 574 | cargo run -- watch fixtures/site -o _site # poll + rebuild on change | 599 | cargo run -- watch fixtures/site -o _site # rebuild on filesystem events |
| 575 | cargo run -- clean _site # remove output + cache | 600 | cargo run -- clean _site # remove output + cache |
| 576 | ``` | 601 | ``` |
| 577 | 602 | ||
src/lib.rs +1
| @@ -19,3 +19,4 @@ pub mod resolve; | |||
| 19 | pub mod site; | 19 | pub mod site; |
| 20 | pub mod template; | 20 | pub mod template; |
| 21 | pub mod util; | 21 | pub mod util; |
| 22 | pub mod watch; | ||
src/main.rs +26 −57
| @@ -40,13 +40,23 @@ enum Command { | |||
| 40 | #[arg(long, value_name = "FILE")] | 40 | #[arg(long, value_name = "FILE")] |
| 41 | config: Option<Utf8PathBuf>, | 41 | config: Option<Utf8PathBuf>, |
| 42 | }, | 42 | }, |
| 43 | /// Watch a source directory and rebuild incrementally on change (simple poll loop). | 43 | /// Watch a source directory and rebuild incrementally on change, driven by OS |
| 44 | /// filesystem events. | ||
| 44 | Watch { | 45 | Watch { |
| 45 | /// Source directory to watch. | 46 | /// Source directory to watch. |
| 46 | input: Utf8PathBuf, | 47 | input: Utf8PathBuf, |
| 47 | /// Output directory. | 48 | /// Output directory. |
| 48 | #[arg(short, long)] | 49 | #[arg(short, long)] |
| 49 | output: Utf8PathBuf, | 50 | output: Utf8PathBuf, |
| 51 | /// Bypass the incremental cache on every rebuild. | ||
| 52 | #[arg(long)] | ||
| 53 | no_cache: bool, | ||
| 54 | /// Treat broken links and parse diagnostics as errors. | ||
| 55 | #[arg(long)] | ||
| 56 | strict: bool, | ||
| 57 | /// Config file to use, overriding `org-ssg.toml` in the source directory. | ||
| 58 | #[arg(long, value_name = "FILE")] | ||
| 59 | config: Option<Utf8PathBuf>, | ||
| 50 | }, | 60 | }, |
| 51 | /// Remove the build output directory (which holds the cache manifest). | 61 | /// Remove the build output directory (which holds the cache manifest). |
| 52 | Clean { | 62 | Clean { |
| @@ -105,10 +115,21 @@ fn main() -> Result<()> { | |||
| 105 | } | 115 | } |
| 106 | Ok(()) | 116 | Ok(()) |
| 107 | } | 117 | } |
| 108 | // Watch is intentionally a minimal poll loop, not an OS file-watch (spec §5 Phase | 118 | Command::Watch { |
| 109 | // 6 lists `watch`; the real fs-notify integration is deferred). It rebuilds | 119 | input, |
| 110 | // incrementally whenever any source file's mtime advances. | 120 | output, |
| 111 | Command::Watch { input, output } => watch(&input, &output), | 121 | no_cache, |
| 122 | strict, | ||
| 123 | config, | ||
| 124 | } => org_ssg::watch::run( | ||
| 125 | &input, | ||
| 126 | &output, | ||
| 127 | &BuildOptions { | ||
| 128 | no_cache, | ||
| 129 | strict, | ||
| 130 | config_path: config, | ||
| 131 | }, | ||
| 132 | ), | ||
| 112 | Command::Audit { input } => { | 133 | Command::Audit { input } => { |
| 113 | let result = org_ssg::audit::audit(&input)?; | 134 | let result = org_ssg::audit::audit(&input)?; |
| 114 | print!("{}", org_ssg::audit::report(&result)); | 135 | print!("{}", org_ssg::audit::report(&result)); |
| @@ -194,58 +215,6 @@ fn init(dir: &Utf8Path) -> Result<()> { | |||
| 194 | Ok(()) | 215 | Ok(()) |
| 195 | } | 216 | } |
| 196 | 217 | ||
| 197 | /// Minimal poll-based watch loop: rebuild incrementally whenever a source file changes. | ||
| 198 | /// Not an OS file-watcher (deferred); it snapshots source mtimes every 500ms. | ||
| 199 | fn watch(input: &Utf8Path, output: &Utf8Path) -> Result<()> { | ||
| 200 | use std::time::{Duration, SystemTime}; | ||
| 201 | |||
| 202 | if !input.is_dir() { | ||
| 203 | anyhow::bail!("watch requires a source directory: watch <src-dir> -o <out-dir>"); | ||
| 204 | } | ||
| 205 | let opts = BuildOptions::default(); | ||
| 206 | |||
| 207 | let snapshot = |root: &Utf8Path| -> Vec<(Utf8PathBuf, SystemTime)> { | ||
| 208 | let mut v = Vec::new(); | ||
| 209 | for entry in walkdir::WalkDir::new(root).sort_by_file_name() { | ||
| 210 | let Ok(entry) = entry else { continue }; | ||
| 211 | if !entry.file_type().is_file() { | ||
| 212 | continue; | ||
| 213 | } | ||
| 214 | if let (Ok(path), Ok(meta)) = ( | ||
| 215 | Utf8PathBuf::from_path_buf(entry.path().to_owned()), | ||
| 216 | entry.metadata(), | ||
| 217 | ) { | ||
| 218 | let mtime = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH); | ||
| 219 | v.push((path, mtime)); | ||
| 220 | } | ||
| 221 | } | ||
| 222 | v | ||
| 223 | }; | ||
| 224 | |||
| 225 | let report = build_site(input, output, &opts)?; | ||
| 226 | println!( | ||
| 227 | "watching {input} -> {output}: built {} page(s) ({} rendered). Ctrl-C to stop.", | ||
| 228 | report.pages.len(), | ||
| 229 | report.rendered.len() | ||
| 230 | ); | ||
| 231 | let mut last = snapshot(input); | ||
| 232 | loop { | ||
| 233 | std::thread::sleep(Duration::from_millis(500)); | ||
| 234 | let now = snapshot(input); | ||
| 235 | if now != last { | ||
| 236 | match build_site(input, output, &opts) { | ||
| 237 | Ok(report) => println!( | ||
| 238 | "rebuilt: {} rendered, {} cached", | ||
| 239 | report.rendered.len(), | ||
| 240 | report.skipped.len() | ||
| 241 | ), | ||
| 242 | Err(e) => eprintln!("build error: {e:#}"), | ||
| 243 | } | ||
| 244 | last = now; | ||
| 245 | } | ||
| 246 | } | ||
| 247 | } | ||
| 248 | |||
| 249 | /// Single-file build: read → PARSE → RENDER → TEMPLATE → write. No cross-file link | 218 | /// Single-file build: read → PARSE → RENDER → TEMPLATE → write. No cross-file link |
| 250 | /// resolution (there is no corpus to resolve against); links keep their best-effort | 219 | /// resolution (there is no corpus to resolve against); links keep their best-effort |
| 251 | /// URLs. Whole-site link resolution lives in [`build_site`]. The syntax stylesheet is | 220 | /// URLs. Whole-site link resolution lives in [`build_site`]. The syntax stylesheet is |
src/watch.rs added +238
| @@ -0,0 +1,238 @@ | |||
| 1 | //! `watch`: rebuild when the source changes, driven by OS filesystem events. | ||
| 2 | //! | ||
| 3 | //! This replaced a 500ms poll loop that re-walked the whole tree twice a second to | ||
| 4 | //! compare mtimes. Native events cost nothing while nothing happens, and arrive in | ||
| 5 | //! milliseconds when something does. | ||
| 6 | //! | ||
| 7 | //! Two things matter more than the watching itself: | ||
| 8 | //! | ||
| 9 | //! 1. **Not watching our own output.** `org-ssg watch . -o _site` puts the output inside | ||
| 10 | //! the source. Rebuilding writes files, writing files raises events, and events | ||
| 11 | //! trigger a rebuild — a loop that never stops and never idles. [`ChangeFilter`] is | ||
| 12 | //! what prevents it, and it is a pure function precisely so it can be tested without | ||
| 13 | //! a filesystem. | ||
| 14 | //! 2. **Debouncing.** Saving a file in an editor is rarely one event: editors write a | ||
| 15 | //! temp file, rename it over the original, and touch the directory. Rebuilding per | ||
| 16 | //! event would rebuild several times per save. | ||
| 17 | |||
| 18 | use std::sync::mpsc; | ||
| 19 | use std::time::Duration; | ||
| 20 | |||
| 21 | use anyhow::{Context, Result}; | ||
| 22 | use camino::{Utf8Path, Utf8PathBuf}; | ||
| 23 | use notify::{Config as NotifyConfig, RecursiveMode, Watcher}; | ||
| 24 | |||
| 25 | use crate::site::{build_site, BuildOptions}; | ||
| 26 | |||
| 27 | /// How long the tree must be quiet before a rebuild starts. Long enough to coalesce an | ||
| 28 | /// editor's write burst, short enough to feel immediate. | ||
| 29 | pub const DEBOUNCE: Duration = Duration::from_millis(120); | ||
| 30 | |||
| 31 | /// Poll interval for the fallback watcher, used where native events are unavailable | ||
| 32 | /// (some network and container filesystems). Slower than the old poll loop on purpose: | ||
| 33 | /// it is a fallback, not the primary path. | ||
| 34 | const POLL_INTERVAL: Duration = Duration::from_secs(2); | ||
| 35 | |||
| 36 | /// Decides whether a changed path should trigger a rebuild. | ||
| 37 | /// | ||
| 38 | /// Deliberately *not* the same rule as build-time discovery. Discovery skips the config | ||
| 39 | /// file and the templates directory because they are not site content — but a change to | ||
| 40 | /// either must rebuild, because both change the output. The rule here is "would this | ||
| 41 | /// change the site?", not "is this a page?". | ||
| 42 | #[derive(Debug, Clone)] | ||
| 43 | pub struct ChangeFilter { | ||
| 44 | /// Output directory, relative to the source root, when it lives inside it. | ||
| 45 | output_inside: Option<Utf8PathBuf>, | ||
| 46 | /// Every spelling of the source root an event path might carry, longest first. | ||
| 47 | /// | ||
| 48 | /// One entry is not enough. On macOS the temp directory is `/var/…`, a symlink to | ||
| 49 | /// `/private/var/…`, and FSEvents reports the resolved path — so stripping event | ||
| 50 | /// paths with the root *as the user typed it* silently fails, every event keeps its | ||
| 51 | /// absolute path, and every absolute path looks like a source change. That includes | ||
| 52 | /// the build's own writes, so `watch` rebuilds in a loop forever. | ||
| 53 | roots: Vec<Utf8PathBuf>, | ||
| 54 | } | ||
| 55 | |||
| 56 | impl ChangeFilter { | ||
| 57 | /// Build a filter for a source and output directory. Paths are canonicalized so | ||
| 58 | /// `.`, `./src`, an absolute path and a symlinked one all compare equal. | ||
| 59 | pub fn new(src: &Utf8Path, out: &Utf8Path) -> Self { | ||
| 60 | let canon = |p: &Utf8Path| -> Option<Utf8PathBuf> { | ||
| 61 | std::fs::canonicalize(p) | ||
| 62 | .ok() | ||
| 63 | .and_then(|p| Utf8PathBuf::from_path_buf(p).ok()) | ||
| 64 | }; | ||
| 65 | let src_canon = canon(src); | ||
| 66 | let output_inside = match (&src_canon, canon(out)) { | ||
| 67 | (Some(src), Some(out)) => out | ||
| 68 | .strip_prefix(src) | ||
| 69 | .ok() | ||
| 70 | .filter(|rel| !rel.as_str().is_empty()) | ||
| 71 | .map(|rel| rel.to_owned()), | ||
| 72 | _ => out | ||
| 73 | .strip_prefix(src) | ||
| 74 | .ok() | ||
| 75 | .filter(|rel| !rel.as_str().is_empty()) | ||
| 76 | .map(|rel| rel.to_owned()), | ||
| 77 | }; | ||
| 78 | |||
| 79 | let mut roots: Vec<Utf8PathBuf> = src_canon.into_iter().chain([src.to_owned()]).collect(); | ||
| 80 | roots.dedup(); | ||
| 81 | // Longest first, so the most specific spelling wins. | ||
| 82 | roots.sort_by_key(|r| std::cmp::Reverse(r.as_str().len())); | ||
| 83 | ChangeFilter { | ||
| 84 | output_inside, | ||
| 85 | roots, | ||
| 86 | } | ||
| 87 | } | ||
| 88 | |||
| 89 | /// Should a change to `rel` (relative to the source root) cause a rebuild? | ||
| 90 | pub fn is_relevant(&self, rel: &Utf8Path) -> bool { | ||
| 91 | if let Some(out) = &self.output_inside { | ||
| 92 | if rel.starts_with(out) { | ||
| 93 | return false; | ||
| 94 | } | ||
| 95 | } | ||
| 96 | // Dot-entries: `.git` churns on every command, and the cache manifest lives in | ||
| 97 | // the output anyway. Emacs' `.#lock` files land here too. | ||
| 98 | if rel | ||
| 99 | .components() | ||
| 100 | .any(|c| c.as_str().starts_with('.') && c.as_str().len() > 1) | ||
| 101 | { | ||
| 102 | return false; | ||
| 103 | } | ||
| 104 | let Some(name) = rel.file_name() else { | ||
| 105 | return false; | ||
| 106 | }; | ||
| 107 | !is_editor_scratch(name) | ||
| 108 | } | ||
| 109 | |||
| 110 | /// Filter absolute event paths down to the relevant ones, as source-relative paths. | ||
| 111 | /// | ||
| 112 | /// A path that cannot be made relative to the source root is discarded rather than | ||
| 113 | /// kept: an event from outside the watched tree cannot be a source change, and | ||
| 114 | /// treating unrecognized paths as changes is what turns a path-spelling mismatch | ||
| 115 | /// into an endless rebuild. | ||
| 116 | pub fn relevant(&self, paths: impl IntoIterator<Item = Utf8PathBuf>) -> Vec<Utf8PathBuf> { | ||
| 117 | let mut out: Vec<Utf8PathBuf> = paths | ||
| 118 | .into_iter() | ||
| 119 | .filter_map(|p| self.to_relative(&p)) | ||
| 120 | .filter(|rel| self.is_relevant(rel)) | ||
| 121 | .collect(); | ||
| 122 | out.sort(); | ||
| 123 | out.dedup(); | ||
| 124 | out | ||
| 125 | } | ||
| 126 | |||
| 127 | /// An event path as a source-relative path, under whichever spelling of the root it | ||
| 128 | /// arrived with. Already-relative paths pass through. | ||
| 129 | fn to_relative(&self, path: &Utf8Path) -> Option<Utf8PathBuf> { | ||
| 130 | if path.is_relative() { | ||
| 131 | return Some(path.to_owned()); | ||
| 132 | } | ||
| 133 | self.roots | ||
| 134 | .iter() | ||
| 135 | .find_map(|root| path.strip_prefix(root).ok()) | ||
| 136 | .map(Utf8Path::to_owned) | ||
| 137 | } | ||
| 138 | } | ||
| 139 | |||
| 140 | /// Files an editor writes beside the real one. Emacs is the relevant case: it leaves | ||
| 141 | /// `file.org~` backups, which do not start with a dot and would otherwise look like a | ||
| 142 | /// content change to a tool aimed squarely at Emacs users. | ||
| 143 | fn is_editor_scratch(name: &str) -> bool { | ||
| 144 | name.ends_with('~') | ||
| 145 | || name.ends_with(".swp") | ||
| 146 | || name.ends_with(".swx") | ||
| 147 | || name.ends_with(".tmp") | ||
| 148 | || (name.starts_with('#') && name.ends_with('#')) | ||
| 149 | } | ||
| 150 | |||
| 151 | /// Build once, then rebuild whenever the source changes. Runs until interrupted. | ||
| 152 | pub fn run(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result<()> { | ||
| 153 | if !src.is_dir() { | ||
| 154 | anyhow::bail!("watch requires a source directory: watch <src-dir> -o <out-dir>"); | ||
| 155 | } | ||
| 156 | |||
| 157 | let report = build_site(src, out, opts)?; | ||
| 158 | println!( | ||
| 159 | "watching {src} -> {out}: built {} page(s) ({} rendered). Ctrl-C to stop.", | ||
| 160 | report.pages.len(), | ||
| 161 | report.rendered.len() | ||
| 162 | ); | ||
| 163 | |||
| 164 | let filter = ChangeFilter::new(src, out); | ||
| 165 | let (tx, rx) = mpsc::channel(); | ||
| 166 | let mut watcher = make_watcher(tx)?; | ||
| 167 | watcher | ||
| 168 | .watch(src.as_std_path(), RecursiveMode::Recursive) | ||
| 169 | .with_context(|| format!("watching {src}"))?; | ||
| 170 | |||
| 171 | loop { | ||
| 172 | // Block until something happens, then keep draining while events keep arriving | ||
| 173 | // inside the debounce window — one save produces several events, and they should | ||
| 174 | // produce one rebuild. | ||
| 175 | let Ok(first) = rx.recv() else { | ||
| 176 | return Ok(()); // watcher dropped | ||
| 177 | }; | ||
| 178 | let mut batch = vec![first]; | ||
| 179 | while let Ok(next) = rx.recv_timeout(DEBOUNCE) { | ||
| 180 | batch.push(next); | ||
| 181 | } | ||
| 182 | |||
| 183 | let changed = filter.relevant(batch.into_iter().flatten()); | ||
| 184 | if changed.is_empty() { | ||
| 185 | continue; | ||
| 186 | } | ||
| 187 | |||
| 188 | let summary = summarize(&changed); | ||
| 189 | match build_site(src, out, opts) { | ||
| 190 | Ok(report) => println!( | ||
| 191 | "{summary}: {} rendered, {} cached", | ||
| 192 | report.rendered.len(), | ||
| 193 | report.skipped.len() | ||
| 194 | ), | ||
| 195 | // A rebuild that fails must not end the session — the usual cause is a | ||
| 196 | // half-saved file, and the next keystroke fixes it. | ||
| 197 | Err(e) => eprintln!("{summary}: build failed: {e:#}"), | ||
| 198 | } | ||
| 199 | } | ||
| 200 | } | ||
| 201 | |||
| 202 | fn summarize(changed: &[Utf8PathBuf]) -> String { | ||
| 203 | match changed { | ||
| 204 | [one] => format!("{one} changed"), | ||
| 205 | [first, rest @ ..] => format!("{first} and {} more changed", rest.len()), | ||
| 206 | [] => "changed".to_string(), | ||
| 207 | } | ||
| 208 | } | ||
| 209 | |||
| 210 | /// The platform's native watcher, falling back to polling where that is unavailable — | ||
| 211 | /// some network and container filesystems have no event API, and `watch` failing outright | ||
| 212 | /// there would be worse than being slow. | ||
| 213 | fn make_watcher(tx: mpsc::Sender<Vec<Utf8PathBuf>>) -> Result<Box<dyn Watcher>> { | ||
| 214 | let handler = move |result: notify::Result<notify::Event>| { | ||
| 215 | if let Ok(event) = result { | ||
| 216 | let paths: Vec<Utf8PathBuf> = event | ||
| 217 | .paths | ||
| 218 | .into_iter() | ||
| 219 | .filter_map(|p| Utf8PathBuf::from_path_buf(p).ok()) | ||
| 220 | .collect(); | ||
| 221 | if !paths.is_empty() { | ||
| 222 | // The receiver going away just means the loop ended. | ||
| 223 | let _ = tx.send(paths); | ||
| 224 | } | ||
| 225 | } | ||
| 226 | }; | ||
| 227 | |||
| 228 | match notify::RecommendedWatcher::new(handler.clone(), NotifyConfig::default()) { | ||
| 229 | Ok(watcher) => Ok(Box::new(watcher)), | ||
| 230 | Err(e) => { | ||
| 231 | eprintln!("note: native file watching unavailable ({e}); polling every {POLL_INTERVAL:?}"); | ||
| 232 | let config = NotifyConfig::default().with_poll_interval(POLL_INTERVAL); | ||
| 233 | let watcher = notify::PollWatcher::new(handler, config) | ||
| 234 | .context("starting the fallback poll watcher")?; | ||
| 235 | Ok(Box::new(watcher)) | ||
| 236 | } | ||
| 237 | } | ||
| 238 | } | ||
tests/watch.rs added +193
| @@ -0,0 +1,193 @@ | |||
| 1 | //! `watch`: the change filter, and one end-to-end run against real filesystem events. | ||
| 2 | //! | ||
| 3 | //! The filter carries the weight here. `org-ssg watch . -o _site` puts the output inside | ||
| 4 | //! the source, so a rebuild writes files, writing files raises events, and events trigger | ||
| 5 | //! a rebuild — a loop that never stops. That it is a pure function is what makes the | ||
| 6 | //! guarantee testable without waiting on a filesystem. | ||
| 7 | |||
| 8 | use std::sync::atomic::{AtomicU32, Ordering}; | ||
| 9 | use std::time::{Duration, Instant}; | ||
| 10 | |||
| 11 | use camino::{Utf8Path, Utf8PathBuf}; | ||
| 12 | |||
| 13 | use org_ssg::site::{build_site, BuildOptions}; | ||
| 14 | use org_ssg::watch::ChangeFilter; | ||
| 15 | |||
| 16 | fn tmpdir(tag: &str) -> Utf8PathBuf { | ||
| 17 | static N: AtomicU32 = AtomicU32::new(0); | ||
| 18 | let n = N.fetch_add(1, Ordering::Relaxed); | ||
| 19 | let base = Utf8PathBuf::from_path_buf(std::env::temp_dir()) | ||
| 20 | .expect("utf-8 temp dir") | ||
| 21 | .join(format!("org-ssg-watch-{}-{tag}-{n}", std::process::id())); | ||
| 22 | let _ = std::fs::remove_dir_all(&base); | ||
| 23 | std::fs::create_dir_all(&base).unwrap(); | ||
| 24 | base | ||
| 25 | } | ||
| 26 | |||
| 27 | // --------------------------------------------------------------------------- | ||
| 28 | // The change filter | ||
| 29 | // --------------------------------------------------------------------------- | ||
| 30 | |||
| 31 | /// The one that matters: without it, `watch . -o _site` rebuilds forever. | ||
| 32 | #[test] | ||
| 33 | fn changes_under_the_output_directory_are_ignored() { | ||
| 34 | let root = tmpdir("filterout"); | ||
| 35 | let src = root.join("src"); | ||
| 36 | let out = src.join("_site"); | ||
| 37 | std::fs::create_dir_all(&out).unwrap(); | ||
| 38 | |||
| 39 | let filter = ChangeFilter::new(&src, &out); | ||
| 40 | assert!(!filter.is_relevant(Utf8Path::new("_site/index.html"))); | ||
| 41 | assert!(!filter.is_relevant(Utf8Path::new("_site/blog/post.html"))); | ||
| 42 | assert!(!filter.is_relevant(Utf8Path::new("_site/.org-ssg-cache.json"))); | ||
| 43 | assert!(filter.is_relevant(Utf8Path::new("index.org")), "real sources still count"); | ||
| 44 | } | ||
| 45 | |||
| 46 | /// An output directory outside the source cannot cause a loop, and must not accidentally | ||
| 47 | /// suppress a similarly-named source directory. | ||
| 48 | #[test] | ||
| 49 | fn an_external_output_directory_suppresses_nothing() { | ||
| 50 | let root = tmpdir("filterext"); | ||
| 51 | let src = root.join("src"); | ||
| 52 | let out = root.join("out"); | ||
| 53 | std::fs::create_dir_all(&src).unwrap(); | ||
| 54 | std::fs::create_dir_all(&out).unwrap(); | ||
| 55 | |||
| 56 | let filter = ChangeFilter::new(&src, &out); | ||
| 57 | assert!(filter.is_relevant(Utf8Path::new("index.org"))); | ||
| 58 | assert!(filter.is_relevant(Utf8Path::new("out/notes.org")), "a source dir named `out`"); | ||
| 59 | } | ||
| 60 | |||
| 61 | /// A change to the config or a template changes the output, so both must rebuild — even | ||
| 62 | /// though build-time *discovery* skips them as non-content. The watch rule is "would this | ||
| 63 | /// change the site?", not "is this a page?". | ||
| 64 | #[test] | ||
| 65 | fn build_inputs_trigger_a_rebuild_even_though_discovery_skips_them() { | ||
| 66 | let root = tmpdir("filterinputs"); | ||
| 67 | let src = root.join("src"); | ||
| 68 | std::fs::create_dir_all(&src).unwrap(); | ||
| 69 | let filter = ChangeFilter::new(&src, &root.join("out")); | ||
| 70 | |||
| 71 | assert!(filter.is_relevant(Utf8Path::new("org-ssg.toml"))); | ||
| 72 | assert!(filter.is_relevant(Utf8Path::new("templates/base.html"))); | ||
| 73 | assert!(filter.is_relevant(Utf8Path::new("templates/feed.xml"))); | ||
| 74 | assert!(filter.is_relevant(Utf8Path::new("style.css")), "assets are copied through"); | ||
| 75 | } | ||
| 76 | |||
| 77 | /// `.git` churns on every command, and rebuilding the site because git wrote an index | ||
| 78 | /// lock would make watch useless in any repository. | ||
| 79 | #[test] | ||
| 80 | fn dot_directories_and_editor_scratch_files_are_ignored() { | ||
| 81 | let root = tmpdir("filterdots"); | ||
| 82 | let src = root.join("src"); | ||
| 83 | std::fs::create_dir_all(&src).unwrap(); | ||
| 84 | let filter = ChangeFilter::new(&src, &root.join("out")); | ||
| 85 | |||
| 86 | for ignored in [ | ||
| 87 | ".git/index", | ||
| 88 | ".git/objects/ab/cdef", | ||
| 89 | ".DS_Store", | ||
| 90 | "blog/.#post.org", // Emacs lock | ||
| 91 | "post.org~", // Emacs backup | ||
| 92 | ".post.org.swp", // vim | ||
| 93 | "#post.org#", // Emacs auto-save | ||
| 94 | "build.tmp", | ||
| 95 | ] { | ||
| 96 | assert!( | ||
| 97 | !filter.is_relevant(Utf8Path::new(ignored)), | ||
| 98 | "{ignored} should not trigger a rebuild" | ||
| 99 | ); | ||
| 100 | } | ||
| 101 | for relevant in ["post.org", "blog/post.org", "a-file~with-tilde.org"] { | ||
| 102 | assert!( | ||
| 103 | filter.is_relevant(Utf8Path::new(relevant)), | ||
| 104 | "{relevant} should trigger a rebuild" | ||
| 105 | ); | ||
| 106 | } | ||
| 107 | } | ||
| 108 | |||
| 109 | /// Events arrive as absolute paths and in bursts, often naming one file several times. | ||
| 110 | #[test] | ||
| 111 | fn absolute_event_paths_are_reduced_to_a_sorted_unique_set() { | ||
| 112 | let root = tmpdir("filterrel"); | ||
| 113 | let src = root.join("src"); | ||
| 114 | let out = src.join("_site"); | ||
| 115 | std::fs::create_dir_all(&out).unwrap(); | ||
| 116 | |||
| 117 | let filter = ChangeFilter::new(&src, &out); | ||
| 118 | let events = vec![ | ||
| 119 | src.join("b.org"), | ||
| 120 | src.join("a.org"), | ||
| 121 | src.join("b.org"), | ||
| 122 | src.join("_site/a.html"), | ||
| 123 | src.join("a.org~"), | ||
| 124 | ]; | ||
| 125 | assert_eq!( | ||
| 126 | filter.relevant(events), | ||
| 127 | vec![Utf8PathBuf::from("a.org"), Utf8PathBuf::from("b.org")] | ||
| 128 | ); | ||
| 129 | } | ||
| 130 | |||
| 131 | // --------------------------------------------------------------------------- | ||
| 132 | // End to end | ||
| 133 | // --------------------------------------------------------------------------- | ||
| 134 | |||
| 135 | /// Drive the real watcher against a real edit. Timing-dependent by nature, so it polls | ||
| 136 | /// for the expected result with a generous ceiling rather than sleeping a fixed amount. | ||
| 137 | #[test] | ||
| 138 | fn watching_rebuilds_the_site_when_a_source_file_changes() { | ||
| 139 | let root = tmpdir("watchrun"); | ||
| 140 | let src = root.join("src"); | ||
| 141 | std::fs::create_dir_all(&src).unwrap(); | ||
| 142 | std::fs::write(src.join("index.org"), "#+TITLE: Home\n\nFirst version.\n").unwrap(); | ||
| 143 | let out = src.join("_site"); // deliberately inside the source: the loop case | ||
| 144 | |||
| 145 | build_site(&src, &out, &BuildOptions::default()).unwrap(); | ||
| 146 | assert!(std::fs::read_to_string(out.join("index.html")) | ||
| 147 | .unwrap() | ||
| 148 | .contains("First version.")); | ||
| 149 | |||
| 150 | let (src_t, out_t) = (src.clone(), out.clone()); | ||
| 151 | let handle = std::thread::spawn(move || { | ||
| 152 | let _ = org_ssg::watch::run(&src_t, &out_t, &BuildOptions::default()); | ||
| 153 | }); | ||
| 154 | |||
| 155 | // Give the watcher a moment to register before making the change it should see. | ||
| 156 | std::thread::sleep(Duration::from_millis(300)); | ||
| 157 | std::fs::write(src.join("index.org"), "#+TITLE: Home\n\nSecond version.\n").unwrap(); | ||
| 158 | |||
| 159 | let deadline = Instant::now() + Duration::from_secs(20); | ||
| 160 | let mut rebuilt = false; | ||
| 161 | while Instant::now() < deadline { | ||
| 162 | if std::fs::read_to_string(out.join("index.html")) | ||
| 163 | .map(|h| h.contains("Second version.")) | ||
| 164 | .unwrap_or(false) | ||
| 165 | { | ||
| 166 | rebuilt = true; | ||
| 167 | break; | ||
| 168 | } | ||
| 169 | std::thread::sleep(Duration::from_millis(50)); | ||
| 170 | } | ||
| 171 | assert!(rebuilt, "an edit should trigger a rebuild within 20s"); | ||
| 172 | |||
| 173 | // The output lives inside the source, so the rebuild's own writes raised events. If | ||
| 174 | // those are not filtered out, watch spins forever. | ||
| 175 | // | ||
| 176 | // The file to watch for that is `syntax.css`, not `index.html`. The incremental | ||
| 177 | // build leaves an unchanged page alone, so `index.html` holds still even *during* a | ||
| 178 | // runaway loop — an assertion on it passes whether or not the filter works, which is | ||
| 179 | // exactly what it did before this comment existed. `syntax.css` is rewritten on | ||
| 180 | // every build, so its mtime is a direct record of how many builds have run. | ||
| 181 | let stylesheet = out.join("syntax.css"); | ||
| 182 | std::thread::sleep(Duration::from_millis(700)); | ||
| 183 | let first = std::fs::metadata(&stylesheet).unwrap().modified().unwrap(); | ||
| 184 | std::thread::sleep(Duration::from_millis(1200)); | ||
| 185 | let second = std::fs::metadata(&stylesheet).unwrap().modified().unwrap(); | ||
| 186 | assert_eq!( | ||
| 187 | first, second, | ||
| 188 | "the build's own writes must not feed back in as changes — watch is rebuilding \ | ||
| 189 | in a loop" | ||
| 190 | ); | ||
| 191 | |||
| 192 | drop(handle); // the watcher thread ends with the process | ||
| 193 | } | ||