Rust library to generate static websites

Compare changes

Choose any two refs to compare.

+5667 -902
+1 -1
.github/workflows/benchmark.yaml
··· 41 uses: actions/setup-node@v4 42 with: 43 node-version: latest 44 - cache: 'pnpm' 45 46 - name: Install dependencies 47 run: pnpm install
··· 41 uses: actions/setup-node@v4 42 with: 43 node-version: latest 44 + cache: "pnpm" 45 46 - name: Install dependencies 47 run: pnpm install
+4 -4
.github/workflows/ci.yaml
··· 38 uses: actions/setup-node@v4 39 with: 40 node-version: latest 41 - cache: 'pnpm' 42 43 - name: Install dependencies 44 run: pnpm install ··· 66 uses: actions/setup-node@v4 67 with: 68 node-version: latest 69 - cache: 'pnpm' 70 71 - name: Install dependencies 72 run: pnpm install ··· 94 uses: actions/setup-node@v4 95 with: 96 node-version: latest 97 - cache: 'pnpm' 98 99 - name: Install dependencies 100 run: pnpm install ··· 126 uses: actions/setup-node@v4 127 with: 128 node-version: latest 129 - cache: 'pnpm' 130 131 - name: Install dependencies 132 run: pnpm install
··· 38 uses: actions/setup-node@v4 39 with: 40 node-version: latest 41 + cache: "pnpm" 42 43 - name: Install dependencies 44 run: pnpm install ··· 66 uses: actions/setup-node@v4 67 with: 68 node-version: latest 69 + cache: "pnpm" 70 71 - name: Install dependencies 72 run: pnpm install ··· 94 uses: actions/setup-node@v4 95 with: 96 node-version: latest 97 + cache: "pnpm" 98 99 - name: Install dependencies 100 run: pnpm install ··· 126 uses: actions/setup-node@v4 127 with: 128 node-version: latest 129 + cache: "pnpm" 130 131 - name: Install dependencies 132 run: pnpm install
+1 -1
.github/workflows/release.yml
··· 30 uses: actions/setup-node@v4 31 with: 32 node-version: latest 33 - cache: 'pnpm' 34 35 - name: Install dependencies 36 run: pnpm install
··· 30 uses: actions/setup-node@v4 31 with: 32 node-version: latest 33 + cache: "pnpm" 34 35 - name: Install dependencies 36 run: pnpm install
+5
.sampo/changesets/valorous-earl-louhi.md
···
··· 1 + --- 2 + cargo/maudit: patch 3 + --- 4 + 5 + Make placeholders function return Result so that errors can be handled, if need to
+1 -1
.sampo/config.toml
··· 1 [packages] 2 linked = [["maudit", "maudit-macros"]] 3 - ignore = ["benchmarks/*", "examples/*", "maudit-website", "root"]
··· 1 [packages] 2 linked = [["maudit", "maudit-macros"]] 3 + ignore = ["benchmarks/*", "examples/*", "maudit-website", "root", "e2e/fixtures/*"]
+2 -6
.vscode/extensions.json
··· 1 { 2 - "recommendations": [ 3 - "oxc.oxc-vscode", 4 - "TypeScriptTeam.native-preview", 5 - "rust-lang.rust-analyzer" 6 - ] 7 - }
··· 1 { 2 + "recommendations": ["oxc.oxc-vscode", "TypeScriptTeam.native-preview", "rust-lang.rust-analyzer"] 3 + }
+14 -14
.vscode/settings.json
··· 1 { 2 - "typescript.experimental.useTsgo": true, 3 - "editor.defaultFormatter": "oxc.oxc-vscode", 4 - "oxc.typeAware": true, 5 - "oxc.fixKind": "safe_fix", 6 - "oxc.unusedDisableDirectives": "deny", 7 - "[rust]": { 8 - "editor.defaultFormatter": "rust-lang.rust-analyzer" 9 - }, 10 - "editor.codeActionsOnSave": { 11 - "source.fixAll.oxc": "explicit" 12 - }, 13 - "biome.enabled": false, 14 - "css.lint.unknownAtRules": "ignore", 15 - }
··· 1 { 2 + "typescript.experimental.useTsgo": true, 3 + "editor.defaultFormatter": "oxc.oxc-vscode", 4 + "oxc.typeAware": true, 5 + "oxc.fixKind": "safe_fix", 6 + "oxc.unusedDisableDirectives": "deny", 7 + "[rust]": { 8 + "editor.defaultFormatter": "rust-lang.rust-analyzer" 9 + }, 10 + "editor.codeActionsOnSave": { 11 + "source.fixAll.oxc": "explicit" 12 + }, 13 + "biome.enabled": false, 14 + "css.lint.unknownAtRules": "ignore" 15 + }
+411 -314
Cargo.lock
··· 409 ] 410 411 [[package]] 412 - name = "brk-file-id" 413 - version = "0.2.3" 414 - source = "registry+https://github.com/rust-lang/crates.io-index" 415 - checksum = "4c1950d399f52e1f5094028ce390381e75649cf4409fca898047005a6dad3afd" 416 - dependencies = [ 417 - "windows-sys 0.60.2", 418 - ] 419 - 420 - [[package]] 421 - name = "brk-notify" 422 - version = "8.2.1" 423 - source = "registry+https://github.com/rust-lang/crates.io-index" 424 - checksum = "ca1c0159b584e64e5dda5bcd1a773435514aeabbcf5b1afc194381b465d8fa01" 425 - dependencies = [ 426 - "bitflags 2.10.0", 427 - "brk-notify-types", 428 - "fsevent-sys", 429 - "inotify", 430 - "kqueue", 431 - "libc", 432 - "log", 433 - "mio 1.1.1", 434 - "walkdir", 435 - "windows-sys 0.60.2", 436 - ] 437 - 438 - [[package]] 439 - name = "brk-notify-debouncer-full" 440 - version = "0.6.1" 441 - source = "registry+https://github.com/rust-lang/crates.io-index" 442 - checksum = "36157ad4fe408d3958da182e0d56a2928eddb098649a241efcc9e1fe9076dc96" 443 - dependencies = [ 444 - "brk-file-id", 445 - "brk-notify", 446 - "brk-notify-types", 447 - "log", 448 - "walkdir", 449 - ] 450 - 451 - [[package]] 452 - name = "brk-notify-types" 453 - version = "2.0.1" 454 - source = "registry+https://github.com/rust-lang/crates.io-index" 455 - checksum = "91ff3e445e42475fba5e0cfaed51345f491e479b9f2069f29875f434a5327913" 456 - 457 - [[package]] 458 name = "brk_rolldown" 459 - version = "0.2.3" 460 source = "registry+https://github.com/rust-lang/crates.io-index" 461 - checksum = "76d5237104ef0c275a1c50354392a7edb45fc736998479530c231d811a0a38e0" 462 dependencies = [ 463 "anyhow", 464 "append-only-vec", 465 "arcstr", 466 "bitflags 2.10.0", 467 - "brk-notify", 468 "brk_rolldown_common", 469 - "brk_rolldown_debug", 470 "brk_rolldown_ecmascript", 471 "brk_rolldown_ecmascript_utils", 472 "brk_rolldown_error", ··· 475 "brk_rolldown_plugin_chunk_import_map", 476 "brk_rolldown_plugin_data_uri", 477 "brk_rolldown_plugin_hmr", 478 "brk_rolldown_plugin_oxc_runtime", 479 "brk_rolldown_resolver", 480 "brk_rolldown_sourcemap", 481 "brk_rolldown_std_utils", 482 "brk_rolldown_tracing", 483 "brk_rolldown_utils", 484 - "brk_rolldown_watcher", 485 "brk_string_wizard", 486 "commondir", 487 "css-module-lexer", 488 - "derive_more", 489 - "dunce", 490 "futures", 491 "indexmap", 492 "itertools", 493 "itoa", 494 "memchr", 495 "oxc", 496 "oxc_allocator", ··· 499 "oxc_traverse", 500 "petgraph", 501 "rayon", 502 "rustc-hash", 503 "serde", 504 "serde_json", ··· 511 512 [[package]] 513 name = "brk_rolldown_common" 514 - version = "0.2.3" 515 source = "registry+https://github.com/rust-lang/crates.io-index" 516 - checksum = "80d4461576c24766fdea280ce888e6d8dc36f50161e2615fd6a698511d623f36" 517 dependencies = [ 518 "anyhow", 519 "arcstr", ··· 542 ] 543 544 [[package]] 545 - name = "brk_rolldown_debug" 546 - version = "0.2.3" 547 source = "registry+https://github.com/rust-lang/crates.io-index" 548 - checksum = "e6d03984d81260c4d9d068431eda178b59a3eaf539d67d1cc6cd2b0dc28140d5" 549 dependencies = [ 550 "blake3", 551 - "brk_rolldown_debug_action", 552 "dashmap", 553 "rustc-hash", 554 "serde", ··· 558 ] 559 560 [[package]] 561 - name = "brk_rolldown_debug_action" 562 - version = "0.2.3" 563 source = "registry+https://github.com/rust-lang/crates.io-index" 564 - checksum = "e03dc81df86ed78c962fb675bc4d3ecf5988ec8813dddc41701b3c496e29514b" 565 dependencies = [ 566 "serde", 567 "ts-rs", ··· 569 570 [[package]] 571 name = "brk_rolldown_ecmascript" 572 - version = "0.2.3" 573 source = "registry+https://github.com/rust-lang/crates.io-index" 574 - checksum = "4c5f433cfd8f7d5b9a054422770e7477a24c398493e8cd42e15f712dbda9d280" 575 dependencies = [ 576 "arcstr", 577 "brk_rolldown_error", ··· 582 583 [[package]] 584 name = "brk_rolldown_ecmascript_utils" 585 - version = "0.2.3" 586 source = "registry+https://github.com/rust-lang/crates.io-index" 587 - checksum = "4358a13b70a7a647f61ba95caae66e6c747b10d5cba784e6c38636b2011267a0" 588 dependencies = [ 589 "brk_rolldown_common", 590 "oxc", 591 "smallvec", 592 ] 593 594 [[package]] 595 name = "brk_rolldown_error" 596 - version = "0.2.3" 597 source = "registry+https://github.com/rust-lang/crates.io-index" 598 - checksum = "fd9a2e23e1e5dfbfacfaa01fd6adbb05511233ca7c128dde3f4b38d74de0a3b9" 599 dependencies = [ 600 "anyhow", 601 "arcstr", 602 "bitflags 2.10.0", 603 - "brk_rolldown_utils", 604 "derive_more", 605 "heck", 606 "oxc", ··· 613 614 [[package]] 615 name = "brk_rolldown_fs" 616 - version = "0.2.3" 617 source = "registry+https://github.com/rust-lang/crates.io-index" 618 - checksum = "c9ef43d8e0f263b04febb3972ddbb95792abf510c74b4df9a7849786bd557acb" 619 dependencies = [ 620 "oxc_resolver", 621 "vfs", ··· 623 624 [[package]] 625 name = "brk_rolldown_plugin" 626 - version = "0.2.3" 627 source = "registry+https://github.com/rust-lang/crates.io-index" 628 - checksum = "79b87d794281878edb320543a514a6d25b47d4f78a2744b674a19db01e10c0d2" 629 dependencies = [ 630 "anyhow", 631 "arcstr", 632 "async-trait", 633 "bitflags 2.10.0", 634 "brk_rolldown_common", 635 - "brk_rolldown_debug", 636 "brk_rolldown_ecmascript", 637 "brk_rolldown_error", 638 "brk_rolldown_resolver", ··· 641 "brk_string_wizard", 642 "dashmap", 643 "derive_more", 644 "oxc_index", 645 "rustc-hash", 646 "serde", ··· 653 654 [[package]] 655 name = "brk_rolldown_plugin_chunk_import_map" 656 - version = "0.2.3" 657 source = "registry+https://github.com/rust-lang/crates.io-index" 658 - checksum = "17b70821f39dc678e05d16e75e7549b90acdd6f87d408b42120e586850ee6014" 659 dependencies = [ 660 "arcstr", 661 "brk_rolldown_common", ··· 668 669 [[package]] 670 name = "brk_rolldown_plugin_data_uri" 671 - version = "0.2.3" 672 source = "registry+https://github.com/rust-lang/crates.io-index" 673 - checksum = "d1012b5c731c4c1e21169997a115d6273f6cc10d01726eb58509fa58dec39aa7" 674 dependencies = [ 675 "arcstr", 676 "base64-simd", ··· 683 684 [[package]] 685 name = "brk_rolldown_plugin_hmr" 686 - version = "0.2.3" 687 source = "registry+https://github.com/rust-lang/crates.io-index" 688 - checksum = "94bf2c1f5735ad763df8805d47fbb04af7bd622be89abac7339e04415b3a69a9" 689 dependencies = [ 690 "arcstr", 691 "brk_rolldown_common", 692 "brk_rolldown_plugin", 693 "oxc", 694 ] 695 696 [[package]] 697 name = "brk_rolldown_plugin_oxc_runtime" 698 - version = "0.2.3" 699 source = "registry+https://github.com/rust-lang/crates.io-index" 700 - checksum = "7631bc3c34d8c95ed2fdcc10fd53e761fec6509b2f7804cbde800ca813e8c32d" 701 dependencies = [ 702 "arcstr", 703 "brk_rolldown_plugin", ··· 706 ] 707 708 [[package]] 709 name = "brk_rolldown_resolver" 710 - version = "0.2.3" 711 source = "registry+https://github.com/rust-lang/crates.io-index" 712 - checksum = "7c08ca0eac64956b1b81af47f360a7c2107292475efe4e04605a20257753fde5" 713 dependencies = [ 714 "arcstr", 715 "brk_rolldown_common", 716 "brk_rolldown_fs", ··· 723 724 [[package]] 725 name = "brk_rolldown_sourcemap" 726 - version = "0.2.3" 727 source = "registry+https://github.com/rust-lang/crates.io-index" 728 - checksum = "63859586276da70f2f8239ca558a0fc6f047fdea2c6856cca9c5056cd6a8963a" 729 dependencies = [ 730 "brk_rolldown_utils", 731 "memchr", ··· 736 737 [[package]] 738 name = "brk_rolldown_std_utils" 739 - version = "0.2.3" 740 source = "registry+https://github.com/rust-lang/crates.io-index" 741 - checksum = "754a91681b732fdc7e0118f8f356e1f7428b66ce00339688a1c2e8591b98edb2" 742 dependencies = [ 743 "regex", 744 ] 745 746 [[package]] 747 name = "brk_rolldown_tracing" 748 - version = "0.2.3" 749 source = "registry+https://github.com/rust-lang/crates.io-index" 750 - checksum = "da33c2b40ddb7390f299fa7ff7bd74d2ecd80dd57ad2a4cdef131412605090c4" 751 dependencies = [ 752 "tracing", 753 "tracing-chrome", ··· 756 757 [[package]] 758 name = "brk_rolldown_utils" 759 - version = "0.2.3" 760 source = "registry+https://github.com/rust-lang/crates.io-index" 761 - checksum = "aeb807d38d854c3f71c7640b31961da59de3bff3f9498b79e31570debaf74a4f" 762 dependencies = [ 763 "anyhow", 764 "arcstr", 765 "async-scoped", 766 "base-encode", 767 "base64-simd", 768 "brk_rolldown_std_utils", 769 "cow-utils", 770 "dashmap", ··· 793 ] 794 795 [[package]] 796 - name = "brk_rolldown_watcher" 797 - version = "0.2.3" 798 - source = "registry+https://github.com/rust-lang/crates.io-index" 799 - checksum = "cac059d6804c44336f882a87d568bc866cb6fbf8ecd5a083aaf0a310230a2909" 800 - dependencies = [ 801 - "brk-notify", 802 - "brk-notify-debouncer-full", 803 - "brk_rolldown_error", 804 - ] 805 - 806 - [[package]] 807 name = "brk_string_wizard" 808 - version = "0.2.3" 809 source = "registry+https://github.com/rust-lang/crates.io-index" 810 - checksum = "b7117e64fd4da49fe64dcebbbd8f4e490b1c07fdf99e4c22e2cefa0e130da480" 811 dependencies = [ 812 "memchr", 813 "oxc_index", 814 "oxc_sourcemap", 815 "rustc-hash", 816 "serde", 817 ] ··· 824 825 [[package]] 826 name = "bumpalo" 827 - version = "3.19.0" 828 source = "registry+https://github.com/rust-lang/crates.io-index" 829 - checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" 830 dependencies = [ 831 "allocator-api2", 832 ] ··· 1004 dependencies = [ 1005 "anyhow", 1006 "bincode", 1007 - "colored", 1008 "glob", 1009 "libc", 1010 "nix", ··· 1077 ] 1078 1079 [[package]] 1080 name = "commondir" 1081 version = "1.0.0" 1082 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1205 1206 [[package]] 1207 name = "crossterm" 1208 - version = "0.25.0" 1209 source = "registry+https://github.com/rust-lang/crates.io-index" 1210 - checksum = "e64e6c0fbe2c17357405f7c758c1ef960fce08bdfb2c03d88d2a18d7e09c4b67" 1211 dependencies = [ 1212 - "bitflags 1.3.2", 1213 "crossterm_winapi", 1214 - "libc", 1215 - "mio 0.8.11", 1216 "parking_lot", 1217 "signal-hook", 1218 "signal-hook-mio", 1219 "winapi", ··· 1332 checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" 1333 1334 [[package]] 1335 name = "deranged" 1336 version = "0.5.5" 1337 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1433 ] 1434 1435 [[package]] 1436 name = "dragonbox_ecma" 1437 version = "0.0.5" 1438 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1452 dependencies = [ 1453 "dtoa", 1454 ] 1455 - 1456 - [[package]] 1457 - name = "dunce" 1458 - version = "1.0.5" 1459 - source = "registry+https://github.com/rust-lang/crates.io-index" 1460 - checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" 1461 1462 [[package]] 1463 name = "dyn-clone" ··· 1659 version = "0.5.7" 1660 source = "registry+https://github.com/rust-lang/crates.io-index" 1661 checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" 1662 1663 [[package]] 1664 name = "flate2" ··· 1672 ] 1673 1674 [[package]] 1675 name = "fnv" 1676 version = "1.0.7" 1677 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1812 ] 1813 1814 [[package]] 1815 - name = "fxhash" 1816 - version = "0.2.1" 1817 - source = "registry+https://github.com/rust-lang/crates.io-index" 1818 - checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" 1819 - dependencies = [ 1820 - "byteorder", 1821 - ] 1822 - 1823 - [[package]] 1824 name = "generic-array" 1825 version = "0.14.7" 1826 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1836 source = "registry+https://github.com/rust-lang/crates.io-index" 1837 checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" 1838 dependencies = [ 1839 - "unicode-width 0.2.2", 1840 ] 1841 1842 [[package]] ··· 1899 "cfg-if", 1900 "crunchy", 1901 "zerocopy", 1902 ] 1903 1904 [[package]] ··· 2237 2238 [[package]] 2239 name = "inquire" 2240 - version = "0.7.5" 2241 source = "registry+https://github.com/rust-lang/crates.io-index" 2242 - checksum = "0fddf93031af70e75410a2511ec04d49e758ed2f26dad3404a934e0fb45cc12a" 2243 dependencies = [ 2244 "bitflags 2.10.0", 2245 "crossterm", 2246 "dyn-clone", 2247 "fuzzy-matcher", 2248 - "fxhash", 2249 - "newline-converter", 2250 - "once_cell", 2251 "unicode-segmentation", 2252 - "unicode-width 0.1.14", 2253 ] 2254 2255 [[package]] ··· 2330 2331 [[package]] 2332 name = "json-escape-simd" 2333 - version = "1.1.0" 2334 source = "registry+https://github.com/rust-lang/crates.io-index" 2335 - checksum = "2a1f7d5786a4cb0f4e0f862b562a0e085b5bfa23a4f0dc05e7b823ed4e4d791f" 2336 - dependencies = [ 2337 - "anyhow", 2338 - ] 2339 2340 [[package]] 2341 name = "json-strip-comments" ··· 2452 checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" 2453 2454 [[package]] 2455 name = "local-ip-address" 2456 version = "0.6.9" 2457 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2548 dependencies = [ 2549 "base64", 2550 "brk_rolldown", 2551 "chrono", 2552 - "colored", 2553 "env_logger", 2554 "glob", 2555 "image", ··· 2565 "rayon", 2566 "rustc-hash", 2567 "serde", 2568 "serde_yaml", 2569 "slug", 2570 "syntect", ··· 2583 "cargo_metadata", 2584 "chrono", 2585 "clap", 2586 - "colored", 2587 "flate2", 2588 "futures", 2589 "inquire", ··· 2595 "serde_json", 2596 "spinach", 2597 "tar", 2598 "tokio", 2599 "tokio-util", 2600 - "toml_edit 0.22.27", 2601 "tower-http", 2602 "tracing", 2603 "tracing-subscriber", ··· 2747 2748 [[package]] 2749 name = "mio" 2750 - version = "0.8.11" 2751 - source = "registry+https://github.com/rust-lang/crates.io-index" 2752 - checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" 2753 - dependencies = [ 2754 - "libc", 2755 - "log", 2756 - "wasi", 2757 - "windows-sys 0.48.0", 2758 - ] 2759 - 2760 - [[package]] 2761 - name = "mio" 2762 version = "1.1.1" 2763 source = "registry+https://github.com/rust-lang/crates.io-index" 2764 checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" ··· 2815 checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" 2816 2817 [[package]] 2818 - name = "newline-converter" 2819 - version = "0.3.0" 2820 - source = "registry+https://github.com/rust-lang/crates.io-index" 2821 - checksum = "47b6b097ecb1cbfed438542d16e84fd7ad9b0c76c8a65b7f9039212a3d14dc7f" 2822 - dependencies = [ 2823 - "unicode-segmentation", 2824 - ] 2825 - 2826 - [[package]] 2827 name = "nibble_vec" 2828 version = "0.1.0" 2829 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2883 "kqueue", 2884 "libc", 2885 "log", 2886 - "mio 1.1.1", 2887 "notify-types", 2888 "walkdir", 2889 "windows-sys 0.60.2", ··· 2891 2892 [[package]] 2893 name = "notify-debouncer-full" 2894 - version = "0.6.0" 2895 source = "registry+https://github.com/rust-lang/crates.io-index" 2896 - checksum = "375bd3a138be7bfeff3480e4a623df4cbfb55b79df617c055cd810ba466fa078" 2897 dependencies = [ 2898 "file-id", 2899 "log", ··· 2975 ] 2976 2977 [[package]] 2978 name = "once_cell" 2979 version = "1.21.3" 2980 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3055 3056 [[package]] 3057 name = "oxc" 3058 - version = "0.92.0" 3059 source = "registry+https://github.com/rust-lang/crates.io-index" 3060 - checksum = "514174b0464005dd95e873f5236089949ab77c79cc8a8e9352721f06d45a0b8d" 3061 dependencies = [ 3062 "oxc_allocator", 3063 "oxc_ast", ··· 3105 "textwrap", 3106 "thiserror 2.0.18", 3107 "unicode-segmentation", 3108 - "unicode-width 0.2.2", 3109 ] 3110 3111 [[package]] ··· 3121 3122 [[package]] 3123 name = "oxc_allocator" 3124 - version = "0.92.0" 3125 source = "registry+https://github.com/rust-lang/crates.io-index" 3126 - checksum = "79d9ac4e239df6f418d86176aa4af85f850f60823a8708fd50a6f7d6f475a07d" 3127 dependencies = [ 3128 "allocator-api2", 3129 "bumpalo", ··· 3136 3137 [[package]] 3138 name = "oxc_ast" 3139 - version = "0.92.0" 3140 source = "registry+https://github.com/rust-lang/crates.io-index" 3141 - checksum = "013500b777b89130b5f79b545d252d9a147db7ef91f07954da394400ae7f14c9" 3142 dependencies = [ 3143 "bitflags 2.10.0", 3144 "oxc_allocator", ··· 3153 3154 [[package]] 3155 name = "oxc_ast_macros" 3156 - version = "0.92.0" 3157 source = "registry+https://github.com/rust-lang/crates.io-index" 3158 - checksum = "9711ea5f01691277822413f0bb56085ac1b867f9cb2090387f634a94a2d4f64c" 3159 dependencies = [ 3160 "phf", 3161 "proc-macro2", ··· 3165 3166 [[package]] 3167 name = "oxc_ast_visit" 3168 - version = "0.92.0" 3169 source = "registry+https://github.com/rust-lang/crates.io-index" 3170 - checksum = "95f0e1f45a19d14d9e4660f426fb8abe31c86b43a43c426a6d182e786994b0bb" 3171 dependencies = [ 3172 "oxc_allocator", 3173 "oxc_ast", ··· 3177 3178 [[package]] 3179 name = "oxc_cfg" 3180 - version = "0.92.0" 3181 source = "registry+https://github.com/rust-lang/crates.io-index" 3182 - checksum = "a32fdc832b6ed9b007f44022e3b273e5b2cc6d798a91ea5b46e150858b17be40" 3183 dependencies = [ 3184 "bitflags 2.10.0", 3185 "itertools", 3186 - "nonmax", 3187 "oxc_index", 3188 "oxc_syntax", 3189 "petgraph", ··· 3192 3193 [[package]] 3194 name = "oxc_codegen" 3195 - version = "0.92.0" 3196 source = "registry+https://github.com/rust-lang/crates.io-index" 3197 - checksum = "b9e9e29e30d2252903c4c5e1104fb7542365d57ceba83e16d4f52b436f7ffa5d" 3198 dependencies = [ 3199 "bitflags 2.10.0", 3200 "cow-utils", 3201 "dragonbox_ecma", 3202 "itoa", 3203 - "nonmax", 3204 "oxc_allocator", 3205 "oxc_ast", 3206 "oxc_data_structures", ··· 3214 3215 [[package]] 3216 name = "oxc_compat" 3217 - version = "0.92.0" 3218 source = "registry+https://github.com/rust-lang/crates.io-index" 3219 - checksum = "42631ddd366f3a6b829d346a5d8ceb4fb3a3796293916f437b244c4e60fa4dd9" 3220 dependencies = [ 3221 "cow-utils", 3222 "oxc-browserslist", ··· 3227 3228 [[package]] 3229 name = "oxc_data_structures" 3230 - version = "0.92.0" 3231 source = "registry+https://github.com/rust-lang/crates.io-index" 3232 - checksum = "5bccdfe08b75babe4944aefcc84f8d795d48a0155fcb20c4855c46eabf6e5d49" 3233 dependencies = [ 3234 "ropey", 3235 ] 3236 3237 [[package]] 3238 name = "oxc_diagnostics" 3239 - version = "0.92.0" 3240 source = "registry+https://github.com/rust-lang/crates.io-index" 3241 - checksum = "e710dd26a2946f906ccd449a2d34f195b0461e5a8776db067ed207189f0213f3" 3242 dependencies = [ 3243 "cow-utils", 3244 "oxc-miette", ··· 3247 3248 [[package]] 3249 name = "oxc_ecmascript" 3250 - version = "0.92.0" 3251 source = "registry+https://github.com/rust-lang/crates.io-index" 3252 - checksum = "80c03f1a2246f422197c317585b056dbc283a4cfb10c8058cb3296b87cb835da" 3253 dependencies = [ 3254 "cow-utils", 3255 "num-bigint", ··· 3262 3263 [[package]] 3264 name = "oxc_estree" 3265 - version = "0.92.0" 3266 source = "registry+https://github.com/rust-lang/crates.io-index" 3267 - checksum = "36ee39a2fc76ae96ccb5dcfea6c430e09e93e4fbcd9ec7f35fe787ea3fb6873a" 3268 dependencies = [ 3269 "dragonbox_ecma", 3270 "itoa", ··· 3273 3274 [[package]] 3275 name = "oxc_index" 3276 - version = "3.1.0" 3277 source = "registry+https://github.com/rust-lang/crates.io-index" 3278 - checksum = "967ae797e1f284bd1385f2d8e8ab94293ad27f623c76839ecf66827521365f5b" 3279 dependencies = [ 3280 "rayon", 3281 "serde", 3282 ] 3283 3284 [[package]] 3285 name = "oxc_isolated_declarations" 3286 - version = "0.92.0" 3287 source = "registry+https://github.com/rust-lang/crates.io-index" 3288 - checksum = "132bb2006e95e63c0b07cc45c30e6d0de6bb643036d7b6316f7c1398a72c1c42" 3289 dependencies = [ 3290 "bitflags 2.10.0", 3291 "oxc_allocator", ··· 3300 3301 [[package]] 3302 name = "oxc_mangler" 3303 - version = "0.92.0" 3304 source = "registry+https://github.com/rust-lang/crates.io-index" 3305 - checksum = "cb797e995b53f0e112b0a1359f967de7cb3a702b46fa2492d6025be4de175a65" 3306 dependencies = [ 3307 "itertools", 3308 "oxc_allocator", ··· 3311 "oxc_index", 3312 "oxc_semantic", 3313 "oxc_span", 3314 "rustc-hash", 3315 ] 3316 3317 [[package]] 3318 name = "oxc_minifier" 3319 - version = "0.92.0" 3320 source = "registry+https://github.com/rust-lang/crates.io-index" 3321 - checksum = "61dfdcff432cacd8257093842d1494225f72cbea8286610b86abfbad59a16003" 3322 dependencies = [ 3323 "cow-utils", 3324 "oxc_allocator", ··· 3328 "oxc_compat", 3329 "oxc_data_structures", 3330 "oxc_ecmascript", 3331 "oxc_mangler", 3332 "oxc_parser", 3333 "oxc_regular_expression", ··· 3340 3341 [[package]] 3342 name = "oxc_parser" 3343 - version = "0.92.0" 3344 source = "registry+https://github.com/rust-lang/crates.io-index" 3345 - checksum = "86612cd26f817679d522b7ed33e525537ec5c0a7165d4d23138b8cbaf896d0d5" 3346 dependencies = [ 3347 "bitflags 2.10.0", 3348 "cow-utils", ··· 3363 3364 [[package]] 3365 name = "oxc_regular_expression" 3366 - version = "0.92.0" 3367 source = "registry+https://github.com/rust-lang/crates.io-index" 3368 - checksum = "e7dc07d4e5d8337f5b6bbadadf1787d2a320f99969c9602ed03c823babd0084a" 3369 dependencies = [ 3370 "bitflags 2.10.0", 3371 "oxc_allocator", ··· 3379 3380 [[package]] 3381 name = "oxc_resolver" 3382 - version = "11.9.0" 3383 source = "registry+https://github.com/rust-lang/crates.io-index" 3384 - checksum = "9bc696688fc6cbab56971f02badc233541f964f4705240c986abc02535a3728e" 3385 dependencies = [ 3386 "cfg-if", 3387 "indexmap", 3388 "json-strip-comments", 3389 - "libc", 3390 "once_cell", 3391 "papaya", 3392 "pnp", 3393 "rustc-hash", 3394 "serde", 3395 "serde_json", 3396 "simdutf8", 3397 "thiserror 2.0.18", 3398 "tracing", ··· 3402 3403 [[package]] 3404 name = "oxc_semantic" 3405 - version = "0.92.0" 3406 source = "registry+https://github.com/rust-lang/crates.io-index" 3407 - checksum = "f9ce8984f6054d3deafebff4e318fea5a38d281905be8bf555e625a7d7c63220" 3408 dependencies = [ 3409 "itertools", 3410 "oxc_allocator", 3411 "oxc_ast", 3412 "oxc_ast_visit", ··· 3417 "oxc_index", 3418 "oxc_span", 3419 "oxc_syntax", 3420 - "phf", 3421 "rustc-hash", 3422 "self_cell", 3423 ] 3424 3425 [[package]] 3426 name = "oxc_sourcemap" 3427 - version = "4.2.0" 3428 source = "registry+https://github.com/rust-lang/crates.io-index" 3429 - checksum = "d3e5d53a1bdb071d10a83cc0b4c69ca6ebb55d55fc6333897aef72c057830b95" 3430 dependencies = [ 3431 "base64-simd", 3432 "json-escape-simd", ··· 3437 3438 [[package]] 3439 name = "oxc_span" 3440 - version = "0.92.0" 3441 source = "registry+https://github.com/rust-lang/crates.io-index" 3442 - checksum = "be7ea89d6e858be16ef14f9a9be81ee210c17cb29bb95d5c86881251075071af" 3443 dependencies = [ 3444 "compact_str", 3445 "oxc-miette", ··· 3451 3452 [[package]] 3453 name = "oxc_syntax" 3454 - version = "0.92.0" 3455 source = "registry+https://github.com/rust-lang/crates.io-index" 3456 - checksum = "a6477f14f0e380033455f0e3e6cdc33fd19932fe5f627a17b38e4666649336cb" 3457 dependencies = [ 3458 "bitflags 2.10.0", 3459 "cow-utils", ··· 3466 "oxc_index", 3467 "oxc_span", 3468 "phf", 3469 - "rustc-hash", 3470 "serde", 3471 "unicode-id-start", 3472 ] 3473 3474 [[package]] 3475 name = "oxc_transformer" 3476 - version = "0.92.0" 3477 source = "registry+https://github.com/rust-lang/crates.io-index" 3478 - checksum = "7c10b7504559c08191c3881ef53fb671619c62c9cc26bd5e66274c68057e6ad3" 3479 dependencies = [ 3480 "base64", 3481 "compact_str", ··· 3489 "oxc_data_structures", 3490 "oxc_diagnostics", 3491 "oxc_ecmascript", 3492 - "oxc_parser", 3493 "oxc_regular_expression", 3494 "oxc_semantic", 3495 "oxc_span", ··· 3503 3504 [[package]] 3505 name = "oxc_transformer_plugins" 3506 - version = "0.92.0" 3507 source = "registry+https://github.com/rust-lang/crates.io-index" 3508 - checksum = "1592bc6214448a4986e72e1f7a71ca52e5b7135e831e806eb09bb9a29343dbaf" 3509 dependencies = [ 3510 "cow-utils", 3511 "itoa", ··· 3525 3526 [[package]] 3527 name = "oxc_traverse" 3528 - version = "0.92.0" 3529 source = "registry+https://github.com/rust-lang/crates.io-index" 3530 - checksum = "8bc2faa1b10f9044f0ef34cb3959684836733d0168ce98504e47010e000ee62e" 3531 dependencies = [ 3532 "itoa", 3533 "oxc_allocator", ··· 3543 3544 [[package]] 3545 name = "oxipng" 3546 - version = "9.1.5" 3547 source = "registry+https://github.com/rust-lang/crates.io-index" 3548 - checksum = "26c613f0f566526a647c7473f6a8556dbce22c91b13485ee4b4ec7ab648e4973" 3549 dependencies = [ 3550 "bitvec", 3551 "clap", ··· 3556 "indexmap", 3557 "libdeflater", 3558 "log", 3559 "rayon", 3560 "rgb", 3561 "rustc-hash", ··· 3594 "smallvec", 3595 "windows-link", 3596 ] 3597 3598 [[package]] 3599 name = "paste" ··· 3826 checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" 3827 3828 [[package]] 3829 - name = "prefetch-prerender" 3830 - version = "0.1.0" 3831 - dependencies = [ 3832 - "maud", 3833 - "maudit", 3834 - ] 3835 - 3836 - [[package]] 3837 name = "proc-macro-crate" 3838 version = "3.4.0" 3839 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3906 3907 [[package]] 3908 name = "pulldown-cmark" 3909 - version = "0.12.2" 3910 source = "registry+https://github.com/rust-lang/crates.io-index" 3911 - checksum = "f86ba2052aebccc42cbbb3ed234b8b13ce76f75c3551a303cb2bcffcff12bb14" 3912 dependencies = [ 3913 "bitflags 2.10.0", 3914 "getopts", ··· 4149 ] 4150 4151 [[package]] 4152 name = "regex" 4153 version = "1.12.2" 4154 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4237 source = "registry+https://github.com/rust-lang/crates.io-index" 4238 checksum = "77dff57c9de498bb1eb5b1ce682c2e3a0ae956b266fa0933c3e151b87b078967" 4239 dependencies = [ 4240 - "unicode-width 0.2.2", 4241 "yansi", 4242 ] 4243 4244 [[package]] 4245 name = "ropey" 4246 version = "1.6.1" 4247 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4448 ] 4449 4450 [[package]] 4451 name = "serde_urlencoded" 4452 version = "0.7.1" 4453 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4524 checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" 4525 dependencies = [ 4526 "libc", 4527 - "mio 0.8.11", 4528 "signal-hook", 4529 ] 4530 ··· 4545 checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" 4546 4547 [[package]] 4548 name = "simd_helpers" 4549 version = "0.1.0" 4550 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4586 version = "1.15.1" 4587 source = "registry+https://github.com/rust-lang/crates.io-index" 4588 checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" 4589 4590 [[package]] 4591 name = "smawk" ··· 4764 dependencies = [ 4765 "smawk", 4766 "unicode-linebreak", 4767 - "unicode-width 0.2.2", 4768 ] 4769 4770 [[package]] ··· 4885 dependencies = [ 4886 "bytes", 4887 "libc", 4888 - "mio 1.1.1", 4889 "pin-project-lite", 4890 "signal-hook-registry", 4891 "socket2", ··· 4930 ] 4931 4932 [[package]] 4933 name = "toml_datetime" 4934 version = "0.6.11" 4935 source = "registry+https://github.com/rust-lang/crates.io-index" 4936 checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" 4937 4938 [[package]] 4939 name = "toml_datetime" ··· 4951 checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" 4952 dependencies = [ 4953 "indexmap", 4954 "toml_datetime 0.6.11", 4955 "toml_write", 4956 "winnow", ··· 4965 "indexmap", 4966 "toml_datetime 0.7.5+spec-1.1.0", 4967 "toml_parser", 4968 "winnow", 4969 ] 4970 ··· 4984 checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" 4985 4986 [[package]] 4987 name = "tower" 4988 version = "0.5.3" 4989 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 5210 5211 [[package]] 5212 name = "unicode-width" 5213 - version = "0.1.14" 5214 - source = "registry+https://github.com/rust-lang/crates.io-index" 5215 - checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" 5216 - 5217 - [[package]] 5218 - name = "unicode-width" 5219 version = "0.2.2" 5220 source = "registry+https://github.com/rust-lang/crates.io-index" 5221 checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" ··· 5332 checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" 5333 5334 [[package]] 5335 name = "version_check" 5336 version = "0.9.5" 5337 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 5591 5592 [[package]] 5593 name = "windows-sys" 5594 - version = "0.48.0" 5595 - source = "registry+https://github.com/rust-lang/crates.io-index" 5596 - checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" 5597 - dependencies = [ 5598 - "windows-targets 0.48.5", 5599 - ] 5600 - 5601 - [[package]] 5602 - name = "windows-sys" 5603 version = "0.52.0" 5604 source = "registry+https://github.com/rust-lang/crates.io-index" 5605 checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" ··· 5636 5637 [[package]] 5638 name = "windows-targets" 5639 - version = "0.48.5" 5640 - source = "registry+https://github.com/rust-lang/crates.io-index" 5641 - checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" 5642 - dependencies = [ 5643 - "windows_aarch64_gnullvm 0.48.5", 5644 - "windows_aarch64_msvc 0.48.5", 5645 - "windows_i686_gnu 0.48.5", 5646 - "windows_i686_msvc 0.48.5", 5647 - "windows_x86_64_gnu 0.48.5", 5648 - "windows_x86_64_gnullvm 0.48.5", 5649 - "windows_x86_64_msvc 0.48.5", 5650 - ] 5651 - 5652 - [[package]] 5653 - name = "windows-targets" 5654 version = "0.52.6" 5655 source = "registry+https://github.com/rust-lang/crates.io-index" 5656 checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" ··· 5693 5694 [[package]] 5695 name = "windows_aarch64_gnullvm" 5696 - version = "0.48.5" 5697 - source = "registry+https://github.com/rust-lang/crates.io-index" 5698 - checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" 5699 - 5700 - [[package]] 5701 - name = "windows_aarch64_gnullvm" 5702 version = "0.52.6" 5703 source = "registry+https://github.com/rust-lang/crates.io-index" 5704 checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" ··· 5711 5712 [[package]] 5713 name = "windows_aarch64_msvc" 5714 - version = "0.48.5" 5715 - source = "registry+https://github.com/rust-lang/crates.io-index" 5716 - checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" 5717 - 5718 - [[package]] 5719 - name = "windows_aarch64_msvc" 5720 version = "0.52.6" 5721 source = "registry+https://github.com/rust-lang/crates.io-index" 5722 checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" ··· 5726 version = "0.53.1" 5727 source = "registry+https://github.com/rust-lang/crates.io-index" 5728 checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" 5729 - 5730 - [[package]] 5731 - name = "windows_i686_gnu" 5732 - version = "0.48.5" 5733 - source = "registry+https://github.com/rust-lang/crates.io-index" 5734 - checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" 5735 5736 [[package]] 5737 name = "windows_i686_gnu" ··· 5759 5760 [[package]] 5761 name = "windows_i686_msvc" 5762 - version = "0.48.5" 5763 - source = "registry+https://github.com/rust-lang/crates.io-index" 5764 - checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" 5765 - 5766 - [[package]] 5767 - name = "windows_i686_msvc" 5768 version = "0.52.6" 5769 source = "registry+https://github.com/rust-lang/crates.io-index" 5770 checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" ··· 5777 5778 [[package]] 5779 name = "windows_x86_64_gnu" 5780 - version = "0.48.5" 5781 - source = "registry+https://github.com/rust-lang/crates.io-index" 5782 - checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" 5783 - 5784 - [[package]] 5785 - name = "windows_x86_64_gnu" 5786 version = "0.52.6" 5787 source = "registry+https://github.com/rust-lang/crates.io-index" 5788 checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" ··· 5795 5796 [[package]] 5797 name = "windows_x86_64_gnullvm" 5798 - version = "0.48.5" 5799 - source = "registry+https://github.com/rust-lang/crates.io-index" 5800 - checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" 5801 - 5802 - [[package]] 5803 - name = "windows_x86_64_gnullvm" 5804 version = "0.52.6" 5805 source = "registry+https://github.com/rust-lang/crates.io-index" 5806 checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" ··· 5810 version = "0.53.1" 5811 source = "registry+https://github.com/rust-lang/crates.io-index" 5812 checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" 5813 - 5814 - [[package]] 5815 - name = "windows_x86_64_msvc" 5816 - version = "0.48.5" 5817 - source = "registry+https://github.com/rust-lang/crates.io-index" 5818 - checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" 5819 5820 [[package]] 5821 name = "windows_x86_64_msvc"
··· 409 ] 410 411 [[package]] 412 name = "brk_rolldown" 413 + version = "0.8.0" 414 source = "registry+https://github.com/rust-lang/crates.io-index" 415 + checksum = "1c59974a49863697b5583a3292bcbe05cffb068a0ddda317b5f09ac01880b675" 416 dependencies = [ 417 "anyhow", 418 "append-only-vec", 419 "arcstr", 420 "bitflags 2.10.0", 421 "brk_rolldown_common", 422 + "brk_rolldown_dev_common", 423 + "brk_rolldown_devtools", 424 "brk_rolldown_ecmascript", 425 "brk_rolldown_ecmascript_utils", 426 "brk_rolldown_error", ··· 429 "brk_rolldown_plugin_chunk_import_map", 430 "brk_rolldown_plugin_data_uri", 431 "brk_rolldown_plugin_hmr", 432 + "brk_rolldown_plugin_lazy_compilation", 433 "brk_rolldown_plugin_oxc_runtime", 434 "brk_rolldown_resolver", 435 "brk_rolldown_sourcemap", 436 "brk_rolldown_std_utils", 437 "brk_rolldown_tracing", 438 "brk_rolldown_utils", 439 "brk_string_wizard", 440 "commondir", 441 "css-module-lexer", 442 "futures", 443 "indexmap", 444 "itertools", 445 "itoa", 446 + "json-escape-simd", 447 "memchr", 448 "oxc", 449 "oxc_allocator", ··· 452 "oxc_traverse", 453 "petgraph", 454 "rayon", 455 + "rolldown-notify", 456 "rustc-hash", 457 "serde", 458 "serde_json", ··· 465 466 [[package]] 467 name = "brk_rolldown_common" 468 + version = "0.8.0" 469 source = "registry+https://github.com/rust-lang/crates.io-index" 470 + checksum = "315da1c9e3a7ea47ce60339177eea8e2ad6e1019aa26b7d15a0d0aef0e42d886" 471 dependencies = [ 472 "anyhow", 473 "arcstr", ··· 496 ] 497 498 [[package]] 499 + name = "brk_rolldown_dev_common" 500 + version = "0.8.0" 501 + source = "registry+https://github.com/rust-lang/crates.io-index" 502 + checksum = "40f4e0044871873810d136770d070acab9d80c9a2d0f65ff1caae778be55f01b" 503 + dependencies = [ 504 + "brk_rolldown_common", 505 + "brk_rolldown_error", 506 + "derive_more", 507 + ] 508 + 509 + [[package]] 510 + name = "brk_rolldown_devtools" 511 + version = "0.8.0" 512 source = "registry+https://github.com/rust-lang/crates.io-index" 513 + checksum = "7ca26431eb07e95ba3c65860b0a76be6d17cd2992d7da114ae0286c63ee527a5" 514 dependencies = [ 515 "blake3", 516 + "brk_rolldown_devtools_action", 517 "dashmap", 518 "rustc-hash", 519 "serde", ··· 523 ] 524 525 [[package]] 526 + name = "brk_rolldown_devtools_action" 527 + version = "0.8.0" 528 source = "registry+https://github.com/rust-lang/crates.io-index" 529 + checksum = "83b4530239d43ee51d96151c31884c747d0e7aa7ee5fe12ba673854f3bde1460" 530 dependencies = [ 531 "serde", 532 "ts-rs", ··· 534 535 [[package]] 536 name = "brk_rolldown_ecmascript" 537 + version = "0.8.0" 538 source = "registry+https://github.com/rust-lang/crates.io-index" 539 + checksum = "e658a6dd55cfa2d7b8747d7515efb3703da2172d65fde07068b92d9e1f7b0df6" 540 dependencies = [ 541 "arcstr", 542 "brk_rolldown_error", ··· 547 548 [[package]] 549 name = "brk_rolldown_ecmascript_utils" 550 + version = "0.8.0" 551 source = "registry+https://github.com/rust-lang/crates.io-index" 552 + checksum = "06a4d59d600016843aa502a7fbe2778598fe92f934a87d9f7aeb20d840fcadf9" 553 dependencies = [ 554 "brk_rolldown_common", 555 + "brk_rolldown_utils", 556 "oxc", 557 "smallvec", 558 ] 559 560 [[package]] 561 name = "brk_rolldown_error" 562 + version = "0.8.0" 563 source = "registry+https://github.com/rust-lang/crates.io-index" 564 + checksum = "a1f96089242b4a06e9cb66e9425246c1ca15afb1dd05390b6e9d08016bb11421" 565 dependencies = [ 566 "anyhow", 567 "arcstr", 568 "bitflags 2.10.0", 569 "derive_more", 570 "heck", 571 "oxc", ··· 578 579 [[package]] 580 name = "brk_rolldown_fs" 581 + version = "0.8.0" 582 source = "registry+https://github.com/rust-lang/crates.io-index" 583 + checksum = "9af54a43116ab3a78777337bf779c8fa784cf3ac495e3429c16bd3b49fc8b351" 584 dependencies = [ 585 "oxc_resolver", 586 "vfs", ··· 588 589 [[package]] 590 name = "brk_rolldown_plugin" 591 + version = "0.8.0" 592 source = "registry+https://github.com/rust-lang/crates.io-index" 593 + checksum = "82aa5428ad77c0b581e98ab0c0a774e66682c8a21c9b25b58c632931ae11cf6c" 594 dependencies = [ 595 "anyhow", 596 "arcstr", 597 "async-trait", 598 "bitflags 2.10.0", 599 "brk_rolldown_common", 600 + "brk_rolldown_devtools", 601 "brk_rolldown_ecmascript", 602 "brk_rolldown_error", 603 "brk_rolldown_resolver", ··· 606 "brk_string_wizard", 607 "dashmap", 608 "derive_more", 609 + "nodejs-built-in-modules", 610 "oxc_index", 611 "rustc-hash", 612 "serde", ··· 619 620 [[package]] 621 name = "brk_rolldown_plugin_chunk_import_map" 622 + version = "0.8.0" 623 source = "registry+https://github.com/rust-lang/crates.io-index" 624 + checksum = "f0b84c39707298c248ffafaf2e66cf55e847b8dffa7d67a34af0a7646b965d47" 625 dependencies = [ 626 "arcstr", 627 "brk_rolldown_common", ··· 634 635 [[package]] 636 name = "brk_rolldown_plugin_data_uri" 637 + version = "0.8.0" 638 source = "registry+https://github.com/rust-lang/crates.io-index" 639 + checksum = "9a767b657b6d5911a296164bf219949463a275a48a717d88736c98857565104c" 640 dependencies = [ 641 "arcstr", 642 "base64-simd", ··· 649 650 [[package]] 651 name = "brk_rolldown_plugin_hmr" 652 + version = "0.8.0" 653 + source = "registry+https://github.com/rust-lang/crates.io-index" 654 + checksum = "da3a79c3092a390ea4a447bdf4963feeefe8dca32c4d59b113ca9d0317d1fd99" 655 + dependencies = [ 656 + "arcstr", 657 + "brk_rolldown_common", 658 + "brk_rolldown_plugin", 659 + "oxc", 660 + ] 661 + 662 + [[package]] 663 + name = "brk_rolldown_plugin_lazy_compilation" 664 + version = "0.8.0" 665 source = "registry+https://github.com/rust-lang/crates.io-index" 666 + checksum = "d6e1ca134fde27ea05ce0b743a1b37c908342c6449353ec966e3bbb4a383c1c9" 667 dependencies = [ 668 + "anyhow", 669 "arcstr", 670 "brk_rolldown_common", 671 "brk_rolldown_plugin", 672 + "brk_rolldown_utils", 673 "oxc", 674 ] 675 676 [[package]] 677 name = "brk_rolldown_plugin_oxc_runtime" 678 + version = "0.8.0" 679 source = "registry+https://github.com/rust-lang/crates.io-index" 680 + checksum = "5127d5c297804b583958eb562f9aab0dbd837536dbec6bd1abfa484c0f379ebe" 681 dependencies = [ 682 "arcstr", 683 "brk_rolldown_plugin", ··· 686 ] 687 688 [[package]] 689 + name = "brk_rolldown_plugin_replace" 690 + version = "0.8.0" 691 + source = "registry+https://github.com/rust-lang/crates.io-index" 692 + checksum = "6331fe3dfe7740a111ca37c3cf26f3633f9a10bdfdc92854656573632474e954" 693 + dependencies = [ 694 + "anyhow", 695 + "brk_rolldown_plugin", 696 + "brk_string_wizard", 697 + "oxc", 698 + "regex", 699 + "regress", 700 + "rustc-hash", 701 + ] 702 + 703 + [[package]] 704 name = "brk_rolldown_resolver" 705 + version = "0.8.0" 706 source = "registry+https://github.com/rust-lang/crates.io-index" 707 + checksum = "f37c2fdb7fd680a137bc6f16f6c1bfbf49fd3d8177939ed7aedf3fb62b0cf8bb" 708 dependencies = [ 709 + "anyhow", 710 "arcstr", 711 "brk_rolldown_common", 712 "brk_rolldown_fs", ··· 719 720 [[package]] 721 name = "brk_rolldown_sourcemap" 722 + version = "0.8.0" 723 source = "registry+https://github.com/rust-lang/crates.io-index" 724 + checksum = "6cdc7d8f4f2161dcd1f61ca1929cf1ef2328280cddb41bdba5a510064a83fa8e" 725 dependencies = [ 726 "brk_rolldown_utils", 727 "memchr", ··· 732 733 [[package]] 734 name = "brk_rolldown_std_utils" 735 + version = "0.8.0" 736 source = "registry+https://github.com/rust-lang/crates.io-index" 737 + checksum = "004f589ea02774a6ca2641ad375123c001abda5403896ed3b21ef7614925aef4" 738 dependencies = [ 739 "regex", 740 ] 741 742 [[package]] 743 name = "brk_rolldown_tracing" 744 + version = "0.8.0" 745 source = "registry+https://github.com/rust-lang/crates.io-index" 746 + checksum = "6c64033001d5a5efe984f762b2ee51c968d05310d1a8de53e71cce91d1af1b0c" 747 dependencies = [ 748 "tracing", 749 "tracing-chrome", ··· 752 753 [[package]] 754 name = "brk_rolldown_utils" 755 + version = "0.8.0" 756 source = "registry+https://github.com/rust-lang/crates.io-index" 757 + checksum = "4040e087114fe7c502a3f4f592e03b78e50515c36b3c9c3f7adcf227326eea65" 758 dependencies = [ 759 "anyhow", 760 "arcstr", 761 "async-scoped", 762 "base-encode", 763 "base64-simd", 764 + "brk_rolldown_error", 765 "brk_rolldown_std_utils", 766 "cow-utils", 767 "dashmap", ··· 790 ] 791 792 [[package]] 793 name = "brk_string_wizard" 794 + version = "0.8.0" 795 source = "registry+https://github.com/rust-lang/crates.io-index" 796 + checksum = "1340aa44bcaf15d553ee4bd9e07864102ea99ea5bccf8918ea898de760af7183" 797 dependencies = [ 798 "memchr", 799 "oxc_index", 800 "oxc_sourcemap", 801 + "regex", 802 "rustc-hash", 803 "serde", 804 ] ··· 811 812 [[package]] 813 name = "bumpalo" 814 + version = "3.19.1" 815 source = "registry+https://github.com/rust-lang/crates.io-index" 816 + checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" 817 dependencies = [ 818 "allocator-api2", 819 ] ··· 991 dependencies = [ 992 "anyhow", 993 "bincode", 994 + "colored 2.2.0", 995 "glob", 996 "libc", 997 "nix", ··· 1064 ] 1065 1066 [[package]] 1067 + name = "colored" 1068 + version = "3.1.1" 1069 + source = "registry+https://github.com/rust-lang/crates.io-index" 1070 + checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" 1071 + dependencies = [ 1072 + "windows-sys 0.61.2", 1073 + ] 1074 + 1075 + [[package]] 1076 name = "commondir" 1077 version = "1.0.0" 1078 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1201 1202 [[package]] 1203 name = "crossterm" 1204 + version = "0.29.0" 1205 source = "registry+https://github.com/rust-lang/crates.io-index" 1206 + checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" 1207 dependencies = [ 1208 + "bitflags 2.10.0", 1209 "crossterm_winapi", 1210 + "derive_more", 1211 + "document-features", 1212 + "mio", 1213 "parking_lot", 1214 + "rustix", 1215 "signal-hook", 1216 "signal-hook-mio", 1217 "winapi", ··· 1330 checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" 1331 1332 [[package]] 1333 + name = "depinfo" 1334 + version = "0.7.3" 1335 + source = "registry+https://github.com/rust-lang/crates.io-index" 1336 + checksum = "ef6dbc1a9be8240ab2bf1f337cd232ca39f361f698227fae35eff7b11690278f" 1337 + dependencies = [ 1338 + "thiserror 2.0.18", 1339 + ] 1340 + 1341 + [[package]] 1342 name = "deranged" 1343 version = "0.5.5" 1344 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1440 ] 1441 1442 [[package]] 1443 + name = "document-features" 1444 + version = "0.2.12" 1445 + source = "registry+https://github.com/rust-lang/crates.io-index" 1446 + checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" 1447 + dependencies = [ 1448 + "litrs", 1449 + ] 1450 + 1451 + [[package]] 1452 name = "dragonbox_ecma" 1453 version = "0.0.5" 1454 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1468 dependencies = [ 1469 "dtoa", 1470 ] 1471 1472 [[package]] 1473 name = "dyn-clone" ··· 1669 version = "0.5.7" 1670 source = "registry+https://github.com/rust-lang/crates.io-index" 1671 checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" 1672 + 1673 + [[package]] 1674 + name = "fixtures-hot-reload" 1675 + version = "0.1.0" 1676 + dependencies = [ 1677 + "maud", 1678 + "maudit", 1679 + ] 1680 + 1681 + [[package]] 1682 + name = "fixtures-incremental-build" 1683 + version = "0.1.0" 1684 + dependencies = [ 1685 + "maud", 1686 + "maudit", 1687 + "serde", 1688 + ] 1689 + 1690 + [[package]] 1691 + name = "fixtures-prefetch-prerender" 1692 + version = "0.1.0" 1693 + dependencies = [ 1694 + "maud", 1695 + "maudit", 1696 + ] 1697 1698 [[package]] 1699 name = "flate2" ··· 1707 ] 1708 1709 [[package]] 1710 + name = "float-cmp" 1711 + version = "0.10.0" 1712 + source = "registry+https://github.com/rust-lang/crates.io-index" 1713 + checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" 1714 + dependencies = [ 1715 + "num-traits", 1716 + ] 1717 + 1718 + [[package]] 1719 name = "fnv" 1720 version = "1.0.7" 1721 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1856 ] 1857 1858 [[package]] 1859 name = "generic-array" 1860 version = "0.14.7" 1861 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1871 source = "registry+https://github.com/rust-lang/crates.io-index" 1872 checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" 1873 dependencies = [ 1874 + "unicode-width", 1875 ] 1876 1877 [[package]] ··· 1934 "cfg-if", 1935 "crunchy", 1936 "zerocopy", 1937 + ] 1938 + 1939 + [[package]] 1940 + name = "halfbrown" 1941 + version = "0.4.0" 1942 + source = "registry+https://github.com/rust-lang/crates.io-index" 1943 + checksum = "0c7ed2f2edad8a14c8186b847909a41fbb9c3eafa44f88bd891114ed5019da09" 1944 + dependencies = [ 1945 + "hashbrown 0.16.1", 1946 ] 1947 1948 [[package]] ··· 2281 2282 [[package]] 2283 name = "inquire" 2284 + version = "0.9.2" 2285 source = "registry+https://github.com/rust-lang/crates.io-index" 2286 + checksum = "ae51d5da01ce7039024fbdec477767c102c454dbdb09d4e2a432ece705b1b25d" 2287 dependencies = [ 2288 "bitflags 2.10.0", 2289 "crossterm", 2290 "dyn-clone", 2291 "fuzzy-matcher", 2292 "unicode-segmentation", 2293 + "unicode-width", 2294 ] 2295 2296 [[package]] ··· 2371 2372 [[package]] 2373 name = "json-escape-simd" 2374 + version = "3.0.1" 2375 source = "registry+https://github.com/rust-lang/crates.io-index" 2376 + checksum = "a3c2a6c0b4b5637c41719973ef40c6a1cf564f9db6958350de6193fbee9c23f5" 2377 2378 [[package]] 2379 name = "json-strip-comments" ··· 2490 checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" 2491 2492 [[package]] 2493 + name = "litrs" 2494 + version = "1.0.0" 2495 + source = "registry+https://github.com/rust-lang/crates.io-index" 2496 + checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" 2497 + 2498 + [[package]] 2499 name = "local-ip-address" 2500 version = "0.6.9" 2501 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2592 dependencies = [ 2593 "base64", 2594 "brk_rolldown", 2595 + "brk_rolldown_common", 2596 + "brk_rolldown_plugin_replace", 2597 "chrono", 2598 + "colored 3.1.1", 2599 "env_logger", 2600 "glob", 2601 "image", ··· 2611 "rayon", 2612 "rustc-hash", 2613 "serde", 2614 + "serde_json", 2615 "serde_yaml", 2616 "slug", 2617 "syntect", ··· 2630 "cargo_metadata", 2631 "chrono", 2632 "clap", 2633 + "colored 3.1.1", 2634 + "depinfo", 2635 "flate2", 2636 "futures", 2637 "inquire", ··· 2643 "serde_json", 2644 "spinach", 2645 "tar", 2646 + "tempfile", 2647 "tokio", 2648 "tokio-util", 2649 + "toml", 2650 + "toml_edit 0.24.0+spec-1.1.0", 2651 "tower-http", 2652 "tracing", 2653 "tracing-subscriber", ··· 2797 2798 [[package]] 2799 name = "mio" 2800 version = "1.1.1" 2801 source = "registry+https://github.com/rust-lang/crates.io-index" 2802 checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" ··· 2853 checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" 2854 2855 [[package]] 2856 name = "nibble_vec" 2857 version = "0.1.0" 2858 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2912 "kqueue", 2913 "libc", 2914 "log", 2915 + "mio", 2916 "notify-types", 2917 "walkdir", 2918 "windows-sys 0.60.2", ··· 2920 2921 [[package]] 2922 name = "notify-debouncer-full" 2923 + version = "0.7.0" 2924 source = "registry+https://github.com/rust-lang/crates.io-index" 2925 + checksum = "c02b49179cfebc9932238d04d6079912d26de0379328872846118a0fa0dbb302" 2926 dependencies = [ 2927 "file-id", 2928 "log", ··· 3004 ] 3005 3006 [[package]] 3007 + name = "objc2-core-foundation" 3008 + version = "0.3.2" 3009 + source = "registry+https://github.com/rust-lang/crates.io-index" 3010 + checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" 3011 + dependencies = [ 3012 + "bitflags 2.10.0", 3013 + ] 3014 + 3015 + [[package]] 3016 + name = "objc2-core-services" 3017 + version = "0.3.2" 3018 + source = "registry+https://github.com/rust-lang/crates.io-index" 3019 + checksum = "583300ad934cba24ff5292aee751ecc070f7ca6b39a574cc21b7b5e588e06a0b" 3020 + dependencies = [ 3021 + "libc", 3022 + "objc2-core-foundation", 3023 + ] 3024 + 3025 + [[package]] 3026 name = "once_cell" 3027 version = "1.21.3" 3028 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3103 3104 [[package]] 3105 name = "oxc" 3106 + version = "0.108.0" 3107 source = "registry+https://github.com/rust-lang/crates.io-index" 3108 + checksum = "88bc240e07b45f0d2746e56db4eab367eb68252c454f1a1fa34b5cbe85ff71f8" 3109 dependencies = [ 3110 "oxc_allocator", 3111 "oxc_ast", ··· 3153 "textwrap", 3154 "thiserror 2.0.18", 3155 "unicode-segmentation", 3156 + "unicode-width", 3157 ] 3158 3159 [[package]] ··· 3169 3170 [[package]] 3171 name = "oxc_allocator" 3172 + version = "0.108.0" 3173 source = "registry+https://github.com/rust-lang/crates.io-index" 3174 + checksum = "78958640bcae9b5b42f9eaafe4995b5460195e961439c236095547bb78952f8d" 3175 dependencies = [ 3176 "allocator-api2", 3177 "bumpalo", ··· 3184 3185 [[package]] 3186 name = "oxc_ast" 3187 + version = "0.108.0" 3188 source = "registry+https://github.com/rust-lang/crates.io-index" 3189 + checksum = "e0d1a3c841ad6204dcdba2e584efbff30ec7a5a2c88851108dd39a2ed4be3af3" 3190 dependencies = [ 3191 "bitflags 2.10.0", 3192 "oxc_allocator", ··· 3201 3202 [[package]] 3203 name = "oxc_ast_macros" 3204 + version = "0.108.0" 3205 source = "registry+https://github.com/rust-lang/crates.io-index" 3206 + checksum = "3fc4d7eb802fc2bfc49fdc004e875a4009c17657f53372af111eb9d98dc4a15f" 3207 dependencies = [ 3208 "phf", 3209 "proc-macro2", ··· 3213 3214 [[package]] 3215 name = "oxc_ast_visit" 3216 + version = "0.108.0" 3217 source = "registry+https://github.com/rust-lang/crates.io-index" 3218 + checksum = "561ace6525ddc90b36103764a959eb261ff7f92a76172a34ac2d24d579f1260d" 3219 dependencies = [ 3220 "oxc_allocator", 3221 "oxc_ast", ··· 3225 3226 [[package]] 3227 name = "oxc_cfg" 3228 + version = "0.108.0" 3229 source = "registry+https://github.com/rust-lang/crates.io-index" 3230 + checksum = "0d38997b36d3ad179b672110080c42ab9bf5c1761767754be148c5cbf8982947" 3231 dependencies = [ 3232 "bitflags 2.10.0", 3233 "itertools", 3234 "oxc_index", 3235 "oxc_syntax", 3236 "petgraph", ··· 3239 3240 [[package]] 3241 name = "oxc_codegen" 3242 + version = "0.108.0" 3243 source = "registry+https://github.com/rust-lang/crates.io-index" 3244 + checksum = "0a075130a060ebc4bcf09a55fcf521243527a820937dccda4af92524d4c3def2" 3245 dependencies = [ 3246 "bitflags 2.10.0", 3247 "cow-utils", 3248 "dragonbox_ecma", 3249 "itoa", 3250 "oxc_allocator", 3251 "oxc_ast", 3252 "oxc_data_structures", ··· 3260 3261 [[package]] 3262 name = "oxc_compat" 3263 + version = "0.108.0" 3264 source = "registry+https://github.com/rust-lang/crates.io-index" 3265 + checksum = "c4df14ee33385dff8fc347c6ddb62b8c7168c4abf6957deec8415575e0f0f2e3" 3266 dependencies = [ 3267 "cow-utils", 3268 "oxc-browserslist", ··· 3273 3274 [[package]] 3275 name = "oxc_data_structures" 3276 + version = "0.108.0" 3277 source = "registry+https://github.com/rust-lang/crates.io-index" 3278 + checksum = "397842ac155f7c3f707232cc8758c0e67919ac7f75ec3bc34680ae176aca8b61" 3279 dependencies = [ 3280 "ropey", 3281 ] 3282 3283 [[package]] 3284 name = "oxc_diagnostics" 3285 + version = "0.108.0" 3286 source = "registry+https://github.com/rust-lang/crates.io-index" 3287 + checksum = "2739661b22eb7abe3966ebbe1eb236337f940eed7e9598bdb089c3353aa2c15f" 3288 dependencies = [ 3289 "cow-utils", 3290 "oxc-miette", ··· 3293 3294 [[package]] 3295 name = "oxc_ecmascript" 3296 + version = "0.108.0" 3297 source = "registry+https://github.com/rust-lang/crates.io-index" 3298 + checksum = "ef913bdaae2ed48335b500a25ecc6a9f186ca855968b5edfc6d1ebad4d0b2124" 3299 dependencies = [ 3300 "cow-utils", 3301 "num-bigint", ··· 3308 3309 [[package]] 3310 name = "oxc_estree" 3311 + version = "0.108.0" 3312 source = "registry+https://github.com/rust-lang/crates.io-index" 3313 + checksum = "a61584ac8cd52d6b6c05a7a5d4b883d5666ea4612ddfe3429f28f7bcd1e93a14" 3314 dependencies = [ 3315 "dragonbox_ecma", 3316 "itoa", ··· 3319 3320 [[package]] 3321 name = "oxc_index" 3322 + version = "4.1.0" 3323 source = "registry+https://github.com/rust-lang/crates.io-index" 3324 + checksum = "eb3e6120999627ec9703025eab7c9f410ebb7e95557632a8902ca48210416c2b" 3325 dependencies = [ 3326 + "nonmax", 3327 "rayon", 3328 "serde", 3329 ] 3330 3331 [[package]] 3332 name = "oxc_isolated_declarations" 3333 + version = "0.108.0" 3334 source = "registry+https://github.com/rust-lang/crates.io-index" 3335 + checksum = "04ddf2f1f37acbea0f3a7bc5b40b3e5e5a83a9ea845e48fbddf0e6da591994cc" 3336 dependencies = [ 3337 "bitflags 2.10.0", 3338 "oxc_allocator", ··· 3347 3348 [[package]] 3349 name = "oxc_mangler" 3350 + version = "0.108.0" 3351 source = "registry+https://github.com/rust-lang/crates.io-index" 3352 + checksum = "dedab0866f3106cbc831c79e522c5f5ca33412562f79cfecdbb600bbbd896261" 3353 dependencies = [ 3354 "itertools", 3355 "oxc_allocator", ··· 3358 "oxc_index", 3359 "oxc_semantic", 3360 "oxc_span", 3361 + "oxc_syntax", 3362 "rustc-hash", 3363 ] 3364 3365 [[package]] 3366 name = "oxc_minifier" 3367 + version = "0.108.0" 3368 source = "registry+https://github.com/rust-lang/crates.io-index" 3369 + checksum = "69b6f6e96160888f73125b7345d3934fb0027c3e27cdfa99a3dd042ae033a77d" 3370 dependencies = [ 3371 "cow-utils", 3372 "oxc_allocator", ··· 3376 "oxc_compat", 3377 "oxc_data_structures", 3378 "oxc_ecmascript", 3379 + "oxc_index", 3380 "oxc_mangler", 3381 "oxc_parser", 3382 "oxc_regular_expression", ··· 3389 3390 [[package]] 3391 name = "oxc_parser" 3392 + version = "0.108.0" 3393 source = "registry+https://github.com/rust-lang/crates.io-index" 3394 + checksum = "06898c992b263f8e4dfcc338528445492a8d61292ad78a0ad7863a265e7beda2" 3395 dependencies = [ 3396 "bitflags 2.10.0", 3397 "cow-utils", ··· 3412 3413 [[package]] 3414 name = "oxc_regular_expression" 3415 + version = "0.108.0" 3416 source = "registry+https://github.com/rust-lang/crates.io-index" 3417 + checksum = "7c658b8d107d9534816312d1fd4b77311df648d07ac8af0417355a8cbb09749b" 3418 dependencies = [ 3419 "bitflags 2.10.0", 3420 "oxc_allocator", ··· 3428 3429 [[package]] 3430 name = "oxc_resolver" 3431 + version = "11.16.4" 3432 source = "registry+https://github.com/rust-lang/crates.io-index" 3433 + checksum = "b903284699f550838a491118e58e9d9adb4941c2514f148aedff1ce4b4fbd578" 3434 dependencies = [ 3435 "cfg-if", 3436 + "fast-glob", 3437 "indexmap", 3438 "json-strip-comments", 3439 + "nodejs-built-in-modules", 3440 "once_cell", 3441 "papaya", 3442 + "parking_lot", 3443 "pnp", 3444 "rustc-hash", 3445 + "rustix", 3446 + "self_cell", 3447 "serde", 3448 "serde_json", 3449 + "simd-json", 3450 "simdutf8", 3451 "thiserror 2.0.18", 3452 "tracing", ··· 3456 3457 [[package]] 3458 name = "oxc_semantic" 3459 + version = "0.108.0" 3460 source = "registry+https://github.com/rust-lang/crates.io-index" 3461 + checksum = "7ef9534d21d00ac38ca4eab91e7b7f4fa0f1c7f0279d07865074c05357366d5c" 3462 dependencies = [ 3463 "itertools", 3464 + "memchr", 3465 "oxc_allocator", 3466 "oxc_ast", 3467 "oxc_ast_visit", ··· 3472 "oxc_index", 3473 "oxc_span", 3474 "oxc_syntax", 3475 "rustc-hash", 3476 "self_cell", 3477 + "smallvec", 3478 ] 3479 3480 [[package]] 3481 name = "oxc_sourcemap" 3482 + version = "6.0.1" 3483 source = "registry+https://github.com/rust-lang/crates.io-index" 3484 + checksum = "36801dbbd025f2fa133367494e38eef75a53d334ae6746ba0c889fc4e76fa3a3" 3485 dependencies = [ 3486 "base64-simd", 3487 "json-escape-simd", ··· 3492 3493 [[package]] 3494 name = "oxc_span" 3495 + version = "0.108.0" 3496 source = "registry+https://github.com/rust-lang/crates.io-index" 3497 + checksum = "3416e347dd4837cdfbffc49bd2ef106ba592133268a962381cc82d24e8593e40" 3498 dependencies = [ 3499 "compact_str", 3500 "oxc-miette", ··· 3506 3507 [[package]] 3508 name = "oxc_syntax" 3509 + version = "0.108.0" 3510 source = "registry+https://github.com/rust-lang/crates.io-index" 3511 + checksum = "c44aa646ecb431595b3255b6eee2a7f9f292422b76cf5c156a825bd042073453" 3512 dependencies = [ 3513 "bitflags 2.10.0", 3514 "cow-utils", ··· 3521 "oxc_index", 3522 "oxc_span", 3523 "phf", 3524 "serde", 3525 "unicode-id-start", 3526 ] 3527 3528 [[package]] 3529 name = "oxc_transformer" 3530 + version = "0.108.0" 3531 source = "registry+https://github.com/rust-lang/crates.io-index" 3532 + checksum = "118d7149205362a9ab9de91112f36c13e4192db5036d5cad7cc083a849146450" 3533 dependencies = [ 3534 "base64", 3535 "compact_str", ··· 3543 "oxc_data_structures", 3544 "oxc_diagnostics", 3545 "oxc_ecmascript", 3546 "oxc_regular_expression", 3547 "oxc_semantic", 3548 "oxc_span", ··· 3556 3557 [[package]] 3558 name = "oxc_transformer_plugins" 3559 + version = "0.108.0" 3560 source = "registry+https://github.com/rust-lang/crates.io-index" 3561 + checksum = "a4f8bc0f738f2fa6703560466f3f45e811d1738be86ed50f1e68ea9a9204e9b6" 3562 dependencies = [ 3563 "cow-utils", 3564 "itoa", ··· 3578 3579 [[package]] 3580 name = "oxc_traverse" 3581 + version = "0.108.0" 3582 source = "registry+https://github.com/rust-lang/crates.io-index" 3583 + checksum = "0b0c8dc012307ff62260d1f9f3073d4933c5f7e0a01e479f52f6ddd2a487154b" 3584 dependencies = [ 3585 "itoa", 3586 "oxc_allocator", ··· 3596 3597 [[package]] 3598 name = "oxipng" 3599 + version = "10.0.0" 3600 source = "registry+https://github.com/rust-lang/crates.io-index" 3601 + checksum = "d8c9a19c0bec7ec84da567e0a87abd378c0f6e988793f858ea5d58f4b87a81a8" 3602 dependencies = [ 3603 "bitvec", 3604 "clap", ··· 3609 "indexmap", 3610 "libdeflater", 3611 "log", 3612 + "parse-size", 3613 "rayon", 3614 "rgb", 3615 "rustc-hash", ··· 3648 "smallvec", 3649 "windows-link", 3650 ] 3651 + 3652 + [[package]] 3653 + name = "parse-size" 3654 + version = "1.1.0" 3655 + source = "registry+https://github.com/rust-lang/crates.io-index" 3656 + checksum = "487f2ccd1e17ce8c1bfab3a65c89525af41cfad4c8659021a1e9a2aacd73b89b" 3657 3658 [[package]] 3659 name = "paste" ··· 3886 checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" 3887 3888 [[package]] 3889 name = "proc-macro-crate" 3890 version = "3.4.0" 3891 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3958 3959 [[package]] 3960 name = "pulldown-cmark" 3961 + version = "0.13.0" 3962 source = "registry+https://github.com/rust-lang/crates.io-index" 3963 + checksum = "1e8bbe1a966bd2f362681a44f6edce3c2310ac21e4d5067a6e7ec396297a6ea0" 3964 dependencies = [ 3965 "bitflags 2.10.0", 3966 "getopts", ··· 4201 ] 4202 4203 [[package]] 4204 + name = "ref-cast" 4205 + version = "1.0.25" 4206 + source = "registry+https://github.com/rust-lang/crates.io-index" 4207 + checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" 4208 + dependencies = [ 4209 + "ref-cast-impl", 4210 + ] 4211 + 4212 + [[package]] 4213 + name = "ref-cast-impl" 4214 + version = "1.0.25" 4215 + source = "registry+https://github.com/rust-lang/crates.io-index" 4216 + checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" 4217 + dependencies = [ 4218 + "proc-macro2", 4219 + "quote", 4220 + "syn", 4221 + ] 4222 + 4223 + [[package]] 4224 name = "regex" 4225 version = "1.12.2" 4226 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4309 source = "registry+https://github.com/rust-lang/crates.io-index" 4310 checksum = "77dff57c9de498bb1eb5b1ce682c2e3a0ae956b266fa0933c3e151b87b078967" 4311 dependencies = [ 4312 + "unicode-width", 4313 "yansi", 4314 ] 4315 4316 [[package]] 4317 + name = "rolldown-notify" 4318 + version = "10.1.0" 4319 + source = "registry+https://github.com/rust-lang/crates.io-index" 4320 + checksum = "e8bf250d410b79487a6d054e6bd16ec08dddd2998f5f5e6291867a35066cfc37" 4321 + dependencies = [ 4322 + "bitflags 2.10.0", 4323 + "inotify", 4324 + "kqueue", 4325 + "libc", 4326 + "mio", 4327 + "objc2-core-foundation", 4328 + "objc2-core-services", 4329 + "rolldown-notify-types", 4330 + "tracing", 4331 + "walkdir", 4332 + "windows-sys 0.61.2", 4333 + ] 4334 + 4335 + [[package]] 4336 + name = "rolldown-notify-types" 4337 + version = "2.0.2" 4338 + source = "registry+https://github.com/rust-lang/crates.io-index" 4339 + checksum = "1931923a28e14c01a27ca56669669eb3e3de4068859c34e17b96c93ba3a61afe" 4340 + 4341 + [[package]] 4342 name = "ropey" 4343 version = "1.6.1" 4344 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4545 ] 4546 4547 [[package]] 4548 + name = "serde_spanned" 4549 + version = "0.6.9" 4550 + source = "registry+https://github.com/rust-lang/crates.io-index" 4551 + checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" 4552 + dependencies = [ 4553 + "serde", 4554 + ] 4555 + 4556 + [[package]] 4557 name = "serde_urlencoded" 4558 version = "0.7.1" 4559 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4630 checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" 4631 dependencies = [ 4632 "libc", 4633 + "mio", 4634 "signal-hook", 4635 ] 4636 ··· 4651 checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" 4652 4653 [[package]] 4654 + name = "simd-json" 4655 + version = "0.17.0" 4656 + source = "registry+https://github.com/rust-lang/crates.io-index" 4657 + checksum = "4255126f310d2ba20048db6321c81ab376f6a6735608bf11f0785c41f01f64e3" 4658 + dependencies = [ 4659 + "halfbrown", 4660 + "ref-cast", 4661 + "simdutf8", 4662 + "value-trait", 4663 + ] 4664 + 4665 + [[package]] 4666 name = "simd_helpers" 4667 version = "0.1.0" 4668 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4704 version = "1.15.1" 4705 source = "registry+https://github.com/rust-lang/crates.io-index" 4706 checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" 4707 + dependencies = [ 4708 + "serde", 4709 + ] 4710 4711 [[package]] 4712 name = "smawk" ··· 4885 dependencies = [ 4886 "smawk", 4887 "unicode-linebreak", 4888 + "unicode-width", 4889 ] 4890 4891 [[package]] ··· 5006 dependencies = [ 5007 "bytes", 5008 "libc", 5009 + "mio", 5010 "pin-project-lite", 5011 "signal-hook-registry", 5012 "socket2", ··· 5051 ] 5052 5053 [[package]] 5054 + name = "toml" 5055 + version = "0.8.23" 5056 + source = "registry+https://github.com/rust-lang/crates.io-index" 5057 + checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" 5058 + dependencies = [ 5059 + "serde", 5060 + "serde_spanned", 5061 + "toml_datetime 0.6.11", 5062 + "toml_edit 0.22.27", 5063 + ] 5064 + 5065 + [[package]] 5066 name = "toml_datetime" 5067 version = "0.6.11" 5068 source = "registry+https://github.com/rust-lang/crates.io-index" 5069 checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" 5070 + dependencies = [ 5071 + "serde", 5072 + ] 5073 5074 [[package]] 5075 name = "toml_datetime" ··· 5087 checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" 5088 dependencies = [ 5089 "indexmap", 5090 + "serde", 5091 + "serde_spanned", 5092 "toml_datetime 0.6.11", 5093 "toml_write", 5094 "winnow", ··· 5103 "indexmap", 5104 "toml_datetime 0.7.5+spec-1.1.0", 5105 "toml_parser", 5106 + "winnow", 5107 + ] 5108 + 5109 + [[package]] 5110 + name = "toml_edit" 5111 + version = "0.24.0+spec-1.1.0" 5112 + source = "registry+https://github.com/rust-lang/crates.io-index" 5113 + checksum = "8c740b185920170a6d9191122cafef7010bd6270a3824594bff6784c04d7f09e" 5114 + dependencies = [ 5115 + "indexmap", 5116 + "toml_datetime 0.7.5+spec-1.1.0", 5117 + "toml_parser", 5118 + "toml_writer", 5119 "winnow", 5120 ] 5121 ··· 5135 checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" 5136 5137 [[package]] 5138 + name = "toml_writer" 5139 + version = "1.0.6+spec-1.1.0" 5140 + source = "registry+https://github.com/rust-lang/crates.io-index" 5141 + checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" 5142 + 5143 + [[package]] 5144 name = "tower" 5145 version = "0.5.3" 5146 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 5367 5368 [[package]] 5369 name = "unicode-width" 5370 version = "0.2.2" 5371 source = "registry+https://github.com/rust-lang/crates.io-index" 5372 checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" ··· 5483 checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" 5484 5485 [[package]] 5486 + name = "value-trait" 5487 + version = "0.12.1" 5488 + source = "registry+https://github.com/rust-lang/crates.io-index" 5489 + checksum = "8e80f0c733af0720a501b3905d22e2f97662d8eacfe082a75ed7ffb5ab08cb59" 5490 + dependencies = [ 5491 + "float-cmp", 5492 + "halfbrown", 5493 + "itoa", 5494 + "ryu", 5495 + ] 5496 + 5497 + [[package]] 5498 name = "version_check" 5499 version = "0.9.5" 5500 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 5754 5755 [[package]] 5756 name = "windows-sys" 5757 version = "0.52.0" 5758 source = "registry+https://github.com/rust-lang/crates.io-index" 5759 checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" ··· 5790 5791 [[package]] 5792 name = "windows-targets" 5793 version = "0.52.6" 5794 source = "registry+https://github.com/rust-lang/crates.io-index" 5795 checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" ··· 5832 5833 [[package]] 5834 name = "windows_aarch64_gnullvm" 5835 version = "0.52.6" 5836 source = "registry+https://github.com/rust-lang/crates.io-index" 5837 checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" ··· 5844 5845 [[package]] 5846 name = "windows_aarch64_msvc" 5847 version = "0.52.6" 5848 source = "registry+https://github.com/rust-lang/crates.io-index" 5849 checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" ··· 5853 version = "0.53.1" 5854 source = "registry+https://github.com/rust-lang/crates.io-index" 5855 checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" 5856 5857 [[package]] 5858 name = "windows_i686_gnu" ··· 5880 5881 [[package]] 5882 name = "windows_i686_msvc" 5883 version = "0.52.6" 5884 source = "registry+https://github.com/rust-lang/crates.io-index" 5885 checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" ··· 5892 5893 [[package]] 5894 name = "windows_x86_64_gnu" 5895 version = "0.52.6" 5896 source = "registry+https://github.com/rust-lang/crates.io-index" 5897 checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" ··· 5904 5905 [[package]] 5906 name = "windows_x86_64_gnullvm" 5907 version = "0.52.6" 5908 source = "registry+https://github.com/rust-lang/crates.io-index" 5909 checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" ··· 5913 version = "0.53.1" 5914 source = "registry+https://github.com/rust-lang/crates.io-index" 5915 checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" 5916 5917 [[package]] 5918 name = "windows_x86_64_msvc"
+1 -1
Cargo.toml
··· 6 maudit = { path = "crates/maudit", version = "*" } 7 oubli = { path = "crates/oubli", version = "*" } 8 maud = { version = "0.27.0" } 9 - serde = { version = "1.0.216" } 10 11 [profile.profiling] 12 inherits = "release"
··· 6 maudit = { path = "crates/maudit", version = "*" } 7 oubli = { path = "crates/oubli", version = "*" } 8 maud = { version = "0.27.0" } 9 + serde = { version = "1.0.228" } 10 11 [profile.profiling] 12 inherits = "release"
+2 -2
benchmarks/realistic-blog/Cargo.toml
··· 7 [dependencies] 8 maudit = { workspace = true } 9 maud = "0.27.0" 10 - serde = { version = "1.0.216" } 11 - chrono = { version = "0.4.42", features = ["serde"] } 12 13 [dev-dependencies] 14 divan = { version = "3.0.5", package = "codspeed-divan-compat" }
··· 7 [dependencies] 8 maudit = { workspace = true } 9 maud = "0.27.0" 10 + serde = { version = "1.0.228" } 11 + chrono = { version = "0.4.43", features = ["serde"] } 12 13 [dev-dependencies] 14 divan = { version = "3.0.5", package = "codspeed-divan-compat" }
+4 -1
benchmarks/realistic-blog/src/routes/article.rs
··· 16 &self, 17 ctx: &mut DynamicRouteContext, 18 ) -> Pages<ArticlesParams, PaginatedContentPage<ArticleContent>> { 19 - let articles = &ctx.content.get_source::<ArticleContent>("articles").entries; 20 21 let mut articles = articles.to_vec(); 22 articles.sort_by(|a, b| b.data(ctx).date.cmp(&a.data(ctx).date));
··· 16 &self, 17 ctx: &mut DynamicRouteContext, 18 ) -> Pages<ArticlesParams, PaginatedContentPage<ArticleContent>> { 19 + let articles = ctx 20 + .content 21 + .get_source::<ArticleContent>("articles") 22 + .entries(); 23 24 let mut articles = articles.to_vec(); 25 articles.sort_by(|a, b| b.data(ctx).date.cmp(&a.data(ctx).date));
+3 -4
benchmarks/realistic-blog/src/routes/index.rs
··· 5 content::ArticleContent, 6 layout::layout, 7 routes::{ 8 - Article, Articles, 9 article::{ArticleParams, ArticlesParams}, 10 }, 11 }; 12 ··· 18 let mut articles = ctx 19 .content 20 .get_source::<ArticleContent>("articles") 21 - .entries 22 - .iter() 23 - .collect::<Vec<_>>(); // Collect into a Vec to allow sorting 24 25 // Sort by date, newest first 26 articles.sort_by(|a, b| b.data(ctx).date.cmp(&a.data(ctx).date));
··· 5 content::ArticleContent, 6 layout::layout, 7 routes::{ 8 article::{ArticleParams, ArticlesParams}, 9 + Article, Articles, 10 }, 11 }; 12 ··· 18 let mut articles = ctx 19 .content 20 .get_source::<ArticleContent>("articles") 21 + .entries() 22 + .to_vec(); // Clone into a Vec to allow sorting 23 24 // Sort by date, newest first 25 articles.sort_by(|a, b| b.data(ctx).date.cmp(&a.data(ctx).date));
+15 -12
crates/maudit/Cargo.toml
··· 22 maud = { workspace = true, optional = true } 23 24 # TODO: Allow making those optional 25 - rolldown = { package = "brk_rolldown", version = "0.2.3" } 26 serde = { workspace = true } 27 serde_yaml = "0.9.34" 28 - pulldown-cmark = "0.12.2" 29 tokio = { version = "1", features = ["macros", "rt-multi-thread"] } 30 - glob = "0.3.1" 31 - syntect = "5.0" 32 lol_html = "2.7.1" 33 slug = "0.1.6" 34 - image = "0.25.6" 35 webp = "0.3.1" 36 - oxipng = "9.1.5" 37 thumbhash = "0.1.0" 38 base64 = "0.22.1" 39 40 maudit-macros = { path = "../maudit-macros", version = "0.7.0" } 41 log = { version = "0.4", features = ["kv"] } 42 - env_logger = "0.11.5" 43 - chrono = "0.4.39" 44 - colored = "2.2.0" 45 rustc-hash = "2.1" 46 - thiserror = "2.0.9" 47 - oxc_sourcemap = "4.1.0" 48 rayon = "1.11.0" 49 - rapidhash = "4.1.1" 50 pathdiff = "0.2.3" 51 52 [dev-dependencies] 53 tempfile = "3.24.0"
··· 22 maud = { workspace = true, optional = true } 23 24 # TODO: Allow making those optional 25 + rolldown = { package = "brk_rolldown", version = "0.8.0" } 26 + rolldown_common = { package = "brk_rolldown_common", version = "0.8.0" } 27 serde = { workspace = true } 28 + serde_json = "1.0" 29 serde_yaml = "0.9.34" 30 + pulldown-cmark = "0.13.0" 31 tokio = { version = "1", features = ["macros", "rt-multi-thread"] } 32 + glob = "0.3.3" 33 + syntect = "5.3" 34 lol_html = "2.7.1" 35 slug = "0.1.6" 36 + image = "0.25.9" 37 webp = "0.3.1" 38 + oxipng = "10.0.0" 39 thumbhash = "0.1.0" 40 base64 = "0.22.1" 41 42 maudit-macros = { path = "../maudit-macros", version = "0.7.0" } 43 log = { version = "0.4", features = ["kv"] } 44 + env_logger = "0.11.8" 45 + chrono = "0.4.43" 46 + colored = "3.1.1" 47 rustc-hash = "2.1" 48 + thiserror = "2.0.18" 49 + oxc_sourcemap = "6.0.1" 50 rayon = "1.11.0" 51 + rapidhash = "4.2.1" 52 pathdiff = "0.2.3" 53 + rolldown_plugin_replace = { package = "brk_rolldown_plugin_replace", version = "0.8.0" } 54 55 [dev-dependencies] 56 tempfile = "3.24.0"
+58 -5
crates/maudit/src/assets/image.rs
··· 154 /// Get a placeholder for the image, which can be used for low-quality image placeholders (LQIP) or similar techniques. 155 /// 156 /// This uses the [ThumbHash](https://evanw.github.io/thumbhash/) algorithm to generate a very small placeholder image. 157 - pub fn placeholder(&self) -> ImagePlaceholder { 158 get_placeholder(&self.path, self.cache.as_ref()) 159 } 160 ··· 258 } 259 } 260 261 - fn get_placeholder(path: &PathBuf, cache: Option<&ImageCache>) -> ImagePlaceholder { 262 // Check cache first if provided 263 if let Some(cache) = cache 264 && let Some(cached) = cache.get_placeholder(path) 265 { 266 debug!("Using cached placeholder for {}", path.display()); 267 let thumbhash_base64 = base64::engine::general_purpose::STANDARD.encode(&cached.thumbhash); 268 - return ImagePlaceholder::new(cached.thumbhash, thumbhash_base64); 269 } 270 271 let total_start = Instant::now(); 272 273 let load_start = Instant::now(); 274 - let image = image::open(path).ok().unwrap(); 275 let (width, height) = image.dimensions(); 276 let (width, height) = (width as usize, height as usize); 277 debug!( ··· 329 cache.cache_placeholder(path, thumb_hash.clone()); 330 } 331 332 - ImagePlaceholder::new(thumb_hash, thumbhash_base64) 333 } 334 335 /// Port of https://github.com/evanw/thumbhash/blob/a652ce6ed691242f459f468f0a8756cda3b90a82/js/thumbhash.js#L234 ··· 516 ).into() 517 } 518 }
··· 154 /// Get a placeholder for the image, which can be used for low-quality image placeholders (LQIP) or similar techniques. 155 /// 156 /// This uses the [ThumbHash](https://evanw.github.io/thumbhash/) algorithm to generate a very small placeholder image. 157 + /// 158 + /// Returns an error if the image cannot be loaded. 159 + pub fn placeholder(&self) -> Result<ImagePlaceholder, crate::errors::AssetError> { 160 get_placeholder(&self.path, self.cache.as_ref()) 161 } 162 ··· 260 } 261 } 262 263 + fn get_placeholder( 264 + path: &PathBuf, 265 + cache: Option<&ImageCache>, 266 + ) -> Result<ImagePlaceholder, crate::errors::AssetError> { 267 // Check cache first if provided 268 if let Some(cache) = cache 269 && let Some(cached) = cache.get_placeholder(path) 270 { 271 debug!("Using cached placeholder for {}", path.display()); 272 let thumbhash_base64 = base64::engine::general_purpose::STANDARD.encode(&cached.thumbhash); 273 + return Ok(ImagePlaceholder::new(cached.thumbhash, thumbhash_base64)); 274 } 275 276 let total_start = Instant::now(); 277 278 let load_start = Instant::now(); 279 + let image = image::open(path).map_err(|e| crate::errors::AssetError::ImageLoadFailed { 280 + path: path.clone(), 281 + source: e, 282 + })?; 283 let (width, height) = image.dimensions(); 284 let (width, height) = (width as usize, height as usize); 285 debug!( ··· 337 cache.cache_placeholder(path, thumb_hash.clone()); 338 } 339 340 + Ok(ImagePlaceholder::new(thumb_hash, thumbhash_base64)) 341 } 342 343 /// Port of https://github.com/evanw/thumbhash/blob/a652ce6ed691242f459f468f0a8756cda3b90a82/js/thumbhash.js#L234 ··· 524 ).into() 525 } 526 } 527 + 528 + #[cfg(test)] 529 + mod tests { 530 + use crate::errors::AssetError; 531 + 532 + use super::*; 533 + use std::{error::Error, path::PathBuf}; 534 + 535 + #[test] 536 + fn test_placeholder_with_missing_file() { 537 + let nonexistent_path = PathBuf::from("/this/file/does/not/exist.png"); 538 + 539 + let result = get_placeholder(&nonexistent_path, None); 540 + 541 + assert!(result.is_err()); 542 + if let Err(AssetError::ImageLoadFailed { path, .. }) = result { 543 + assert_eq!(path, nonexistent_path); 544 + } else { 545 + panic!("Expected ImageLoadFailed error"); 546 + } 547 + } 548 + 549 + #[test] 550 + fn test_placeholder_with_valid_image() { 551 + let temp_dir = tempfile::tempdir().unwrap(); 552 + let image_path = temp_dir.path().join("test.png"); 553 + 554 + // Create a minimal valid 1x1 PNG file using the image crate to ensure correct CRCs 555 + let img = image::ImageBuffer::<image::Rgba<u8>, _>::from_fn(1, 1, |_x, _y| { 556 + image::Rgba([255, 0, 0, 255]) 557 + }); 558 + img.save(&image_path).unwrap(); 559 + 560 + let result = get_placeholder(&image_path, None); 561 + 562 + if let Err(e) = &result { 563 + eprintln!("get_placeholder failed: {:?}", e.source()); 564 + } 565 + 566 + assert!(result.is_ok()); 567 + let placeholder = result.unwrap(); 568 + assert!(!placeholder.thumbhash.is_empty()); 569 + assert!(!placeholder.thumbhash_base64.is_empty()); 570 + } 571 + }
+4 -8
crates/maudit/src/assets/image_cache.rs
··· 338 339 #[test] 340 fn test_build_options_integration() { 341 - use crate::build::options::{AssetsOptions, BuildOptions}; 342 343 // Test that BuildOptions can configure the cache directory 344 let custom_cache = PathBuf::from("/tmp/custom_maudit_cache"); 345 let build_options = BuildOptions { 346 - assets: AssetsOptions { 347 - image_cache_dir: custom_cache.clone(), 348 - ..Default::default() 349 - }, 350 ..Default::default() 351 }; 352 353 - // Create cache with build options 354 - let cache = ImageCache::with_cache_dir(&build_options.assets.image_cache_dir); 355 356 // Verify it uses the configured directory 357 - assert_eq!(cache.get_cache_dir(), custom_cache); 358 } 359 360 #[test]
··· 338 339 #[test] 340 fn test_build_options_integration() { 341 + use crate::build::options::BuildOptions; 342 343 // Test that BuildOptions can configure the cache directory 344 let custom_cache = PathBuf::from("/tmp/custom_maudit_cache"); 345 let build_options = BuildOptions { 346 + cache_dir: custom_cache.clone(), 347 ..Default::default() 348 }; 349 350 + let cache = ImageCache::with_cache_dir(build_options.assets_cache_dir()); 351 352 // Verify it uses the configured directory 353 + assert_eq!(cache.get_cache_dir(), custom_cache.join("assets")); 354 } 355 356 #[test]
+167 -46
crates/maudit/src/assets.rs
··· 432 } 433 434 fn make_filename(path: &Path, hash: &String, extension: Option<&str>) -> PathBuf { 435 - let file_stem = path.file_stem().unwrap(); 436 - let sanitized_stem = sanitize_filename::default_sanitize_file_name(file_stem.to_str().unwrap()); 437 438 let mut filename = PathBuf::new(); 439 filename.push(format!("{}.{}", sanitized_stem, hash)); ··· 532 533 #[cfg(test)] 534 mod tests { 535 - use super::*; 536 - use std::env; 537 538 - fn setup_temp_dir() -> PathBuf { 539 - // Create a temporary directory and test files 540 - let temp_dir = env::temp_dir().join("maudit_test"); 541 - std::fs::create_dir_all(&temp_dir).unwrap(); 542 543 - std::fs::write(temp_dir.join("style.css"), "body { background: red; }").unwrap(); 544 - std::fs::write(temp_dir.join("script.js"), "console.log('Hello, world!');").unwrap(); 545 - std::fs::write(temp_dir.join("image.png"), b"").unwrap(); 546 temp_dir 547 } 548 ··· 550 fn test_add_style() { 551 let temp_dir = setup_temp_dir(); 552 let mut page_assets = RouteAssets::default(); 553 - page_assets.add_style(temp_dir.join("style.css")).unwrap(); 554 555 assert!(page_assets.styles.len() == 1); 556 } ··· 561 let mut page_assets = RouteAssets::default(); 562 563 page_assets 564 - .include_style(temp_dir.join("style.css")) 565 .unwrap(); 566 567 assert!(page_assets.styles.len() == 1); ··· 573 let temp_dir = setup_temp_dir(); 574 let mut page_assets = RouteAssets::default(); 575 576 - page_assets.add_script(temp_dir.join("script.js")).unwrap(); 577 assert!(page_assets.scripts.len() == 1); 578 } 579 ··· 583 let mut page_assets = RouteAssets::default(); 584 585 page_assets 586 - .include_script(temp_dir.join("script.js")) 587 .unwrap(); 588 589 assert!(page_assets.scripts.len() == 1); ··· 595 let temp_dir = setup_temp_dir(); 596 let mut page_assets = RouteAssets::default(); 597 598 - page_assets.add_image(temp_dir.join("image.png")).unwrap(); 599 assert!(page_assets.images.len() == 1); 600 } 601 ··· 604 let temp_dir = setup_temp_dir(); 605 let mut page_assets = RouteAssets::default(); 606 607 - let image = page_assets.add_image(temp_dir.join("image.png")).unwrap(); 608 assert_eq!(image.url().chars().next(), Some('/')); 609 610 - let script = page_assets.add_script(temp_dir.join("script.js")).unwrap(); 611 assert_eq!(script.url().chars().next(), Some('/')); 612 613 - let style = page_assets.add_style(temp_dir.join("style.css")).unwrap(); 614 assert_eq!(style.url().chars().next(), Some('/')); 615 } 616 ··· 619 let temp_dir = setup_temp_dir(); 620 let mut page_assets = RouteAssets::default(); 621 622 - let image = page_assets.add_image(temp_dir.join("image.png")).unwrap(); 623 assert!(image.url().contains(&image.hash)); 624 625 - let script = page_assets.add_script(temp_dir.join("script.js")).unwrap(); 626 assert!(script.url().contains(&script.hash)); 627 628 - let style = page_assets.add_style(temp_dir.join("style.css")).unwrap(); 629 assert!(style.url().contains(&style.hash)); 630 } 631 ··· 634 let temp_dir = setup_temp_dir(); 635 let mut page_assets = RouteAssets::default(); 636 637 - let image = page_assets.add_image(temp_dir.join("image.png")).unwrap(); 638 assert!(image.build_path().to_string_lossy().contains(&image.hash)); 639 640 - let script = page_assets.add_script(temp_dir.join("script.js")).unwrap(); 641 assert!(script.build_path().to_string_lossy().contains(&script.hash)); 642 643 - let style = page_assets.add_style(temp_dir.join("style.css")).unwrap(); 644 assert!(style.build_path().to_string_lossy().contains(&style.hash)); 645 } 646 647 #[test] 648 fn test_image_hash_different_options() { 649 let temp_dir = setup_temp_dir(); 650 - let image_path = temp_dir.join("image.png"); 651 652 - // Create a simple test PNG (1x1 transparent pixel) 653 - let png_data = [ 654 - 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 655 - 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 656 - 0x00, 0x1F, 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, 0x0B, 0x49, 0x44, 0x41, 0x54, 0x78, 657 - 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00, 658 - 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82, 659 - ]; 660 - std::fs::write(&image_path, png_data).unwrap(); 661 662 - let mut page_assets = RouteAssets::default(); 663 664 // Test that different options produce different hashes 665 let image_default = page_assets.add_image(&image_path).unwrap(); ··· 716 #[test] 717 fn test_image_hash_same_options() { 718 let temp_dir = setup_temp_dir(); 719 - let image_path = temp_dir.join("image.png"); 720 721 // Create a simple test PNG (1x1 transparent pixel) 722 let png_data = [ ··· 728 ]; 729 std::fs::write(&image_path, png_data).unwrap(); 730 731 - let mut page_assets = RouteAssets::default(); 732 733 // Same options should produce same hash 734 let image1 = page_assets ··· 762 #[test] 763 fn test_style_hash_different_options() { 764 let temp_dir = setup_temp_dir(); 765 - let style_path = temp_dir.join("style.css"); 766 767 - let mut page_assets = RouteAssets::new(&RouteAssetsOptions::default(), None); 768 769 // Test that different tailwind options produce different hashes 770 let style_default = page_assets.add_style(&style_path).unwrap(); ··· 784 785 // Create two identical files with different paths 786 let content = "body { background: blue; }"; 787 - let style1_path = temp_dir.join("style1.css"); 788 - let style2_path = temp_dir.join("style2.css"); 789 790 std::fs::write(&style1_path, content).unwrap(); 791 std::fs::write(&style2_path, content).unwrap(); 792 793 - let mut page_assets = RouteAssets::new(&RouteAssetsOptions::default(), None); 794 795 let style1 = page_assets.add_style(&style1_path).unwrap(); 796 let style2 = page_assets.add_style(&style2_path).unwrap(); ··· 804 #[test] 805 fn test_hash_includes_content() { 806 let temp_dir = setup_temp_dir(); 807 - let style_path = temp_dir.join("dynamic_style.css"); 808 809 - let assets_options = RouteAssetsOptions::default(); 810 - let mut page_assets = RouteAssets::new(&assets_options, None); 811 812 // Write first content and get hash 813 std::fs::write(&style_path, "body { background: red; }").unwrap(); ··· 823 hash1, hash2, 824 "Different content should produce different hashes" 825 ); 826 } 827 }
··· 432 } 433 434 fn make_filename(path: &Path, hash: &String, extension: Option<&str>) -> PathBuf { 435 + let file_stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("asset"); 436 + 437 + let sanitized_stem = sanitize_filename::default_sanitize_file_name(file_stem); 438 439 let mut filename = PathBuf::new(); 440 filename.push(format!("{}.{}", sanitized_stem, hash)); ··· 533 534 #[cfg(test)] 535 mod tests { 536 + use std::path::PathBuf; 537 + 538 + use crate::{ 539 + AssetHashingStrategy, 540 + assets::{ 541 + Asset, ImageFormat, ImageOptions, RouteAssets, RouteAssetsOptions, StyleOptions, 542 + make_filename, 543 + }, 544 + }; 545 546 + fn setup_temp_dir() -> tempfile::TempDir { 547 + let temp_dir = tempfile::tempdir().unwrap(); 548 549 + std::fs::write( 550 + temp_dir.path().join("style.css"), 551 + "body { background: red; }", 552 + ) 553 + .unwrap(); 554 + std::fs::write( 555 + temp_dir.path().join("script.js"), 556 + "console.log('Hello, world!');", 557 + ) 558 + .unwrap(); 559 + std::fs::write(temp_dir.path().join("image.png"), b"").unwrap(); 560 temp_dir 561 } 562 ··· 564 fn test_add_style() { 565 let temp_dir = setup_temp_dir(); 566 let mut page_assets = RouteAssets::default(); 567 + page_assets 568 + .add_style(temp_dir.path().join("style.css")) 569 + .unwrap(); 570 571 assert!(page_assets.styles.len() == 1); 572 } ··· 577 let mut page_assets = RouteAssets::default(); 578 579 page_assets 580 + .include_style(temp_dir.path().join("style.css")) 581 .unwrap(); 582 583 assert!(page_assets.styles.len() == 1); ··· 589 let temp_dir = setup_temp_dir(); 590 let mut page_assets = RouteAssets::default(); 591 592 + page_assets 593 + .add_script(temp_dir.path().join("script.js")) 594 + .unwrap(); 595 assert!(page_assets.scripts.len() == 1); 596 } 597 ··· 601 let mut page_assets = RouteAssets::default(); 602 603 page_assets 604 + .include_script(temp_dir.path().join("script.js")) 605 .unwrap(); 606 607 assert!(page_assets.scripts.len() == 1); ··· 613 let temp_dir = setup_temp_dir(); 614 let mut page_assets = RouteAssets::default(); 615 616 + page_assets 617 + .add_image(temp_dir.path().join("image.png")) 618 + .unwrap(); 619 assert!(page_assets.images.len() == 1); 620 } 621 ··· 624 let temp_dir = setup_temp_dir(); 625 let mut page_assets = RouteAssets::default(); 626 627 + let image = page_assets 628 + .add_image(temp_dir.path().join("image.png")) 629 + .unwrap(); 630 assert_eq!(image.url().chars().next(), Some('/')); 631 632 + let script = page_assets 633 + .add_script(temp_dir.path().join("script.js")) 634 + .unwrap(); 635 assert_eq!(script.url().chars().next(), Some('/')); 636 637 + let style = page_assets 638 + .add_style(temp_dir.path().join("style.css")) 639 + .unwrap(); 640 assert_eq!(style.url().chars().next(), Some('/')); 641 } 642 ··· 645 let temp_dir = setup_temp_dir(); 646 let mut page_assets = RouteAssets::default(); 647 648 + let image = page_assets 649 + .add_image(temp_dir.path().join("image.png")) 650 + .unwrap(); 651 assert!(image.url().contains(&image.hash)); 652 653 + let script = page_assets 654 + .add_script(temp_dir.path().join("script.js")) 655 + .unwrap(); 656 assert!(script.url().contains(&script.hash)); 657 658 + let style = page_assets 659 + .add_style(temp_dir.path().join("style.css")) 660 + .unwrap(); 661 assert!(style.url().contains(&style.hash)); 662 } 663 ··· 666 let temp_dir = setup_temp_dir(); 667 let mut page_assets = RouteAssets::default(); 668 669 + let image = page_assets 670 + .add_image(temp_dir.path().join("image.png")) 671 + .unwrap(); 672 assert!(image.build_path().to_string_lossy().contains(&image.hash)); 673 674 + let script = page_assets 675 + .add_script(temp_dir.path().join("script.js")) 676 + .unwrap(); 677 assert!(script.build_path().to_string_lossy().contains(&script.hash)); 678 679 + let style = page_assets 680 + .add_style(temp_dir.path().join("style.css")) 681 + .unwrap(); 682 assert!(style.build_path().to_string_lossy().contains(&style.hash)); 683 } 684 685 #[test] 686 fn test_image_hash_different_options() { 687 let temp_dir = setup_temp_dir(); 688 + let image_path = temp_dir.path().join("image.png"); 689 690 + let img = image::ImageBuffer::<image::Rgba<u8>, _>::from_fn(1, 1, |_x, _y| { 691 + image::Rgba([255, 0, 0, 255]) 692 + }); 693 + img.save(&image_path).unwrap(); 694 695 + let mut page_assets = RouteAssets::new( 696 + &RouteAssetsOptions { 697 + hashing_strategy: AssetHashingStrategy::Precise, 698 + ..Default::default() 699 + }, 700 + None, 701 + ); 702 703 // Test that different options produce different hashes 704 let image_default = page_assets.add_image(&image_path).unwrap(); ··· 755 #[test] 756 fn test_image_hash_same_options() { 757 let temp_dir = setup_temp_dir(); 758 + let image_path = temp_dir.path().join("image.png"); 759 760 // Create a simple test PNG (1x1 transparent pixel) 761 let png_data = [ ··· 767 ]; 768 std::fs::write(&image_path, png_data).unwrap(); 769 770 + let mut page_assets = RouteAssets::new( 771 + &RouteAssetsOptions { 772 + hashing_strategy: AssetHashingStrategy::Precise, 773 + ..Default::default() 774 + }, 775 + None, 776 + ); 777 778 // Same options should produce same hash 779 let image1 = page_assets ··· 807 #[test] 808 fn test_style_hash_different_options() { 809 let temp_dir = setup_temp_dir(); 810 + let style_path = temp_dir.path().join("style.css"); 811 812 + let mut page_assets = RouteAssets::new( 813 + &RouteAssetsOptions { 814 + hashing_strategy: AssetHashingStrategy::Precise, 815 + ..Default::default() 816 + }, 817 + None, 818 + ); 819 820 // Test that different tailwind options produce different hashes 821 let style_default = page_assets.add_style(&style_path).unwrap(); ··· 835 836 // Create two identical files with different paths 837 let content = "body { background: blue; }"; 838 + let style1_path = temp_dir.path().join("style1.css"); 839 + let style2_path = temp_dir.path().join("style2.css"); 840 841 std::fs::write(&style1_path, content).unwrap(); 842 std::fs::write(&style2_path, content).unwrap(); 843 844 + let mut page_assets = RouteAssets::new( 845 + &RouteAssetsOptions { 846 + hashing_strategy: AssetHashingStrategy::Precise, 847 + ..Default::default() 848 + }, 849 + None, 850 + ); 851 852 let style1 = page_assets.add_style(&style1_path).unwrap(); 853 let style2 = page_assets.add_style(&style2_path).unwrap(); ··· 861 #[test] 862 fn test_hash_includes_content() { 863 let temp_dir = setup_temp_dir(); 864 + let style_path = temp_dir.path().join("dynamic_style.css"); 865 866 + let mut page_assets = RouteAssets::new( 867 + &RouteAssetsOptions { 868 + hashing_strategy: AssetHashingStrategy::Precise, 869 + ..Default::default() 870 + }, 871 + None, 872 + ); 873 874 // Write first content and get hash 875 std::fs::write(&style_path, "body { background: red; }").unwrap(); ··· 885 hash1, hash2, 886 "Different content should produce different hashes" 887 ); 888 + } 889 + 890 + #[test] 891 + fn test_make_filename_normal_path() { 892 + let path = PathBuf::from("/foo/bar/test.png"); 893 + let hash = "abc12".to_string(); 894 + 895 + let filename = make_filename(&path, &hash, Some("png")); 896 + 897 + // Format is: stem.hash with extension hash.ext 898 + assert_eq!(filename.to_string_lossy(), "test.abc12.png"); 899 + } 900 + 901 + #[test] 902 + fn test_make_filename_no_extension() { 903 + let path = PathBuf::from("/foo/bar/test"); 904 + let hash = "abc12".to_string(); 905 + 906 + let filename = make_filename(&path, &hash, None); 907 + 908 + assert_eq!(filename.to_string_lossy(), "test.abc12"); 909 + } 910 + 911 + #[test] 912 + fn test_make_filename_fallback_for_root_path() { 913 + // Root path has no file stem 914 + let path = PathBuf::from("/"); 915 + let hash = "abc12".to_string(); 916 + 917 + let filename = make_filename(&path, &hash, Some("css")); 918 + 919 + // Should fallback to "asset" 920 + assert_eq!(filename.to_string_lossy(), "asset.abc12.css"); 921 + } 922 + 923 + #[test] 924 + fn test_make_filename_fallback_for_dotdot_path() { 925 + // Path ending with ".." has no file stem 926 + let path = PathBuf::from("/foo/.."); 927 + let hash = "xyz99".to_string(); 928 + 929 + let filename = make_filename(&path, &hash, Some("js")); 930 + 931 + // Should fallback to "asset" 932 + assert_eq!(filename.to_string_lossy(), "asset.xyz99.js"); 933 + } 934 + 935 + #[test] 936 + fn test_make_filename_with_special_characters() { 937 + // Test that special characters get sanitized 938 + let path = PathBuf::from("/foo/test:file*.txt"); 939 + let hash = "def45".to_string(); 940 + 941 + let filename = make_filename(&path, &hash, Some("txt")); 942 + 943 + // Special characters should be replaced with underscores 944 + let result = filename.to_string_lossy(); 945 + assert!(result.contains("test_file_")); 946 + assert!(result.ends_with(".def45.txt")); 947 } 948 }
+104 -13
crates/maudit/src/build/options.rs
··· 1 - use std::{env, path::PathBuf}; 2 3 use crate::{assets::RouteAssetsOptions, is_dev, sitemap::SitemapOptions}; 4 ··· 36 /// assets: AssetsOptions { 37 /// assets_dir: "_assets".into(), 38 /// tailwind_binary_path: "./node_modules/.bin/tailwindcss".into(), 39 - /// image_cache_dir: ".cache/maudit/images".into(), 40 /// ..Default::default() 41 /// }, 42 /// prefetch: PrefetchOptions { ··· 61 /// At the speed Maudit operates at, not cleaning the output directory may offer a significant performance improvement at the cost of potentially serving stale content. 62 pub clean_output_dir: bool, 63 64 pub assets: AssetsOptions, 65 66 pub prefetch: PrefetchOptions, ··· 124 hashing_strategy: self.assets.hashing_strategy, 125 } 126 } 127 } 128 129 #[derive(Clone)] ··· 139 /// Note that this value is not automatically joined with the `output_dir` in `BuildOptions`. Use [`BuildOptions::route_assets_options()`] to get a `RouteAssetsOptions` with the correct final path. 140 pub assets_dir: PathBuf, 141 142 - /// Directory to use for image cache storage. 143 - /// Defaults to `target/maudit_cache/images`. 144 - /// 145 - /// This cache is used to store processed images and their placeholders to speed up subsequent builds. 146 - pub image_cache_dir: PathBuf, 147 - 148 /// Strategy to use when hashing assets for fingerprinting. 149 /// 150 /// Defaults to [`AssetHashingStrategy::Precise`] in production builds, and [`AssetHashingStrategy::FastImprecise`] in development builds. Note that this means that the cache isn't shared between dev and prod builds by default, if you have a lot of assets you may want to set this to the same value in both environments. ··· 164 Self { 165 tailwind_binary_path: "tailwindcss".into(), 166 assets_dir: "_maudit".into(), 167 - image_cache_dir: { 168 - let target_dir = 169 - env::var("CARGO_TARGET_DIR").unwrap_or_else(|_| "target".to_string()); 170 - PathBuf::from(target_dir).join("maudit_cache/images") 171 - }, 172 hashing_strategy: if is_dev() { 173 AssetHashingStrategy::FastImprecise 174 } else { ··· 196 /// ``` 197 impl Default for BuildOptions { 198 fn default() -> Self { 199 Self { 200 base_url: None, 201 output_dir: "dist".into(), 202 static_dir: "static".into(), 203 clean_output_dir: true, 204 prefetch: PrefetchOptions::default(), 205 assets: AssetsOptions::default(), 206 sitemap: SitemapOptions::default(), 207 } 208 } 209 }
··· 1 + use std::{fs, path::PathBuf}; 2 3 use crate::{assets::RouteAssetsOptions, is_dev, sitemap::SitemapOptions}; 4 ··· 36 /// assets: AssetsOptions { 37 /// assets_dir: "_assets".into(), 38 /// tailwind_binary_path: "./node_modules/.bin/tailwindcss".into(), 39 /// ..Default::default() 40 /// }, 41 /// prefetch: PrefetchOptions { ··· 60 /// At the speed Maudit operates at, not cleaning the output directory may offer a significant performance improvement at the cost of potentially serving stale content. 61 pub clean_output_dir: bool, 62 63 + /// Whether to enable incremental builds. 64 + /// 65 + /// When enabled, Maudit tracks which assets are used by which routes and only rebuilds 66 + /// routes affected by changed files. This can significantly speed up rebuilds when only 67 + /// a few files have changed. 68 + /// 69 + /// Defaults to `true` in dev mode (`maudit dev`) and `false` in production builds. 70 + pub incremental: bool, 71 + 72 + /// Directory for build cache storage (incremental build state, etc.). 73 + /// 74 + /// Defaults to `target/maudit_cache/{package_name}` where `{package_name}` is derived 75 + /// from the current directory name. 76 + pub cache_dir: PathBuf, 77 + 78 + /// Directory for caching processed assets (images, etc.). 79 + /// 80 + /// If `None`, defaults to `{cache_dir}/assets`. 81 + pub assets_cache_dir: Option<PathBuf>, 82 + 83 pub assets: AssetsOptions, 84 85 pub prefetch: PrefetchOptions, ··· 143 hashing_strategy: self.assets.hashing_strategy, 144 } 145 } 146 + 147 + /// Returns the directory for caching processed assets (images, etc.). 148 + /// Uses `assets_cache_dir` if set, otherwise defaults to `{cache_dir}/assets`. 149 + pub fn assets_cache_dir(&self) -> PathBuf { 150 + self.assets_cache_dir 151 + .clone() 152 + .unwrap_or_else(|| self.cache_dir.join("assets")) 153 + } 154 } 155 156 #[derive(Clone)] ··· 166 /// Note that this value is not automatically joined with the `output_dir` in `BuildOptions`. Use [`BuildOptions::route_assets_options()`] to get a `RouteAssetsOptions` with the correct final path. 167 pub assets_dir: PathBuf, 168 169 /// Strategy to use when hashing assets for fingerprinting. 170 /// 171 /// Defaults to [`AssetHashingStrategy::Precise`] in production builds, and [`AssetHashingStrategy::FastImprecise`] in development builds. Note that this means that the cache isn't shared between dev and prod builds by default, if you have a lot of assets you may want to set this to the same value in both environments. ··· 185 Self { 186 tailwind_binary_path: "tailwindcss".into(), 187 assets_dir: "_maudit".into(), 188 hashing_strategy: if is_dev() { 189 AssetHashingStrategy::FastImprecise 190 } else { ··· 212 /// ``` 213 impl Default for BuildOptions { 214 fn default() -> Self { 215 + let site_name = get_site_name(); 216 + let cache_dir = find_target_dir() 217 + .unwrap_or_else(|_| PathBuf::from("target")) 218 + .join("maudit_cache") 219 + .join(&site_name); 220 + 221 Self { 222 base_url: None, 223 output_dir: "dist".into(), 224 static_dir: "static".into(), 225 clean_output_dir: true, 226 + incremental: is_dev(), 227 + cache_dir, 228 + assets_cache_dir: None, 229 prefetch: PrefetchOptions::default(), 230 assets: AssetsOptions::default(), 231 sitemap: SitemapOptions::default(), 232 } 233 } 234 } 235 + 236 + /// Get the site name for cache directory purposes. 237 + /// 238 + /// Uses the current executable's name (which matches the package/binary name), 239 + /// falling back to the current directory name. 240 + fn get_site_name() -> String { 241 + // Get the binary name from the current executable 242 + std::env::current_exe() 243 + .ok() 244 + .and_then(|p| p.file_name().map(|s| s.to_string_lossy().to_string())) 245 + .unwrap_or_else(|| { 246 + // Fallback to current directory name 247 + std::env::current_dir() 248 + .ok() 249 + .and_then(|p| p.file_name().map(|s| s.to_string_lossy().to_string())) 250 + .unwrap_or_else(|| "default".to_string()) 251 + }) 252 + } 253 + 254 + /// Find the target directory using multiple strategies 255 + /// 256 + /// This function tries multiple approaches to locate the target directory: 257 + /// 1. CARGO_TARGET_DIR / CARGO_BUILD_TARGET_DIR environment variables 258 + /// 2. Local ./target directory 259 + /// 3. Workspace root target directory (walking up to find [workspace]) 260 + /// 4. Fallback to relative "target" path 261 + fn find_target_dir() -> Result<PathBuf, std::io::Error> { 262 + // 1. Check CARGO_TARGET_DIR and CARGO_BUILD_TARGET_DIR environment variables 263 + for env_var in ["CARGO_TARGET_DIR", "CARGO_BUILD_TARGET_DIR"] { 264 + if let Ok(target_dir) = std::env::var(env_var) { 265 + let path = PathBuf::from(&target_dir); 266 + if path.exists() { 267 + return Ok(path); 268 + } 269 + } 270 + } 271 + 272 + // 2. Look for target directory in current directory 273 + let local_target = PathBuf::from("target"); 274 + if local_target.exists() { 275 + return Ok(local_target); 276 + } 277 + 278 + // 3. Try to find workspace root by looking for Cargo.toml with [workspace] 279 + let mut current = std::env::current_dir()?; 280 + loop { 281 + let cargo_toml = current.join("Cargo.toml"); 282 + if cargo_toml.exists() 283 + && let Ok(content) = fs::read_to_string(&cargo_toml) 284 + && content.contains("[workspace]") 285 + { 286 + let workspace_target = current.join("target"); 287 + if workspace_target.exists() { 288 + return Ok(workspace_target); 289 + } 290 + } 291 + 292 + // Move up to parent directory 293 + if !current.pop() { 294 + break; 295 + } 296 + } 297 + 298 + // 4. Final fallback to relative path 299 + Ok(PathBuf::from("target")) 300 + }
+1137
crates/maudit/src/build/state.rs
···
··· 1 + use rustc_hash::{FxHashMap, FxHashSet}; 2 + use serde::{Deserialize, Serialize}; 3 + use std::fs; 4 + use std::path::{Path, PathBuf}; 5 + 6 + /// Identifies a specific route or variant for incremental rebuilds 7 + #[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] 8 + pub enum RouteIdentifier { 9 + /// A base route with optional page parameters 10 + /// Params are stored as a sorted Vec for hashing purposes 11 + Base { 12 + route_path: String, 13 + params: Option<Vec<(String, Option<String>)>>, 14 + }, 15 + /// A variant route with optional page parameters 16 + /// Params are stored as a sorted Vec for hashing purposes 17 + Variant { 18 + variant_id: String, 19 + variant_path: String, 20 + params: Option<Vec<(String, Option<String>)>>, 21 + }, 22 + } 23 + 24 + impl RouteIdentifier { 25 + pub fn base(route_path: String, params: Option<FxHashMap<String, Option<String>>>) -> Self { 26 + Self::Base { 27 + route_path, 28 + params: params.map(|p| { 29 + let mut sorted: Vec<_> = p.into_iter().collect(); 30 + sorted.sort_by(|a, b| a.0.cmp(&b.0)); 31 + sorted 32 + }), 33 + } 34 + } 35 + 36 + pub fn variant( 37 + variant_id: String, 38 + variant_path: String, 39 + params: Option<FxHashMap<String, Option<String>>>, 40 + ) -> Self { 41 + Self::Variant { 42 + variant_id, 43 + variant_path, 44 + params: params.map(|p| { 45 + let mut sorted: Vec<_> = p.into_iter().collect(); 46 + sorted.sort_by(|a, b| a.0.cmp(&b.0)); 47 + sorted 48 + }), 49 + } 50 + } 51 + } 52 + 53 + /// Tracks build state for incremental builds 54 + #[derive(Debug, Default, Serialize, Deserialize)] 55 + pub struct BuildState { 56 + /// Maps asset paths to routes that use them 57 + /// Key: canonicalized asset path 58 + /// Value: set of routes using this asset 59 + pub asset_to_routes: FxHashMap<PathBuf, FxHashSet<RouteIdentifier>>, 60 + 61 + /// Maps source file paths to routes defined in them 62 + /// Key: canonicalized source file path (e.g., src/pages/index.rs) 63 + /// Value: set of routes defined in this source file 64 + pub source_to_routes: FxHashMap<PathBuf, FxHashSet<RouteIdentifier>>, 65 + 66 + /// Maps content file paths to routes that use them 67 + /// Key: canonicalized content file path (e.g., content/articles/hello.md) 68 + /// Value: set of routes using this specific content file 69 + /// This provides granular tracking - if only hello.md changes, only routes 70 + /// that accessed hello.md need to be rebuilt. 71 + pub content_file_to_routes: FxHashMap<PathBuf, FxHashSet<RouteIdentifier>>, 72 + 73 + /// Maps content file paths to the content source that owns them 74 + /// Key: canonicalized content file path (e.g., content/articles/hello.md) 75 + /// Value: content source name (e.g., "articles") 76 + /// This allows selective re-initialization of only the content sources 77 + /// whose files have changed. 78 + pub content_file_to_source: FxHashMap<PathBuf, String>, 79 + 80 + /// Stores all bundler input paths from the last build 81 + /// This needs to be preserved to ensure consistent bundling 82 + pub bundler_inputs: Vec<String>, 83 + } 84 + 85 + impl BuildState { 86 + pub fn new() -> Self { 87 + Self::default() 88 + } 89 + 90 + /// Load build state from disk cache 91 + pub fn load(cache_dir: &Path) -> Result<Self, Box<dyn std::error::Error>> { 92 + let state_path = cache_dir.join("build_state.json"); 93 + 94 + if !state_path.exists() { 95 + return Ok(Self::new()); 96 + } 97 + 98 + let content = fs::read_to_string(&state_path)?; 99 + let state: BuildState = serde_json::from_str(&content)?; 100 + Ok(state) 101 + } 102 + 103 + /// Save build state to disk cache 104 + pub fn save(&self, cache_dir: &Path) -> Result<(), Box<dyn std::error::Error>> { 105 + fs::create_dir_all(cache_dir)?; 106 + let state_path = cache_dir.join("build_state.json"); 107 + let content = serde_json::to_string_pretty(self)?; 108 + fs::write(state_path, content)?; 109 + Ok(()) 110 + } 111 + 112 + /// Add an asset->route mapping 113 + pub fn track_asset(&mut self, asset_path: PathBuf, route_id: RouteIdentifier) { 114 + self.asset_to_routes 115 + .entry(asset_path) 116 + .or_default() 117 + .insert(route_id); 118 + } 119 + 120 + /// Add a source file->route mapping 121 + /// This tracks which .rs file defines which routes for incremental rebuilds 122 + pub fn track_source_file(&mut self, source_path: PathBuf, route_id: RouteIdentifier) { 123 + self.source_to_routes 124 + .entry(source_path) 125 + .or_default() 126 + .insert(route_id); 127 + } 128 + 129 + /// Add a content file->route mapping 130 + /// This tracks which specific content files are used by which routes for incremental rebuilds. 131 + /// This provides granular tracking - only routes that actually accessed a specific file 132 + /// will be rebuilt when that file changes. 133 + /// 134 + /// The file path is canonicalized before storage to ensure consistent lookups when 135 + /// comparing against absolute paths from the file watcher. 136 + pub fn track_content_file(&mut self, file_path: PathBuf, route_id: RouteIdentifier) { 137 + // Canonicalize the path to ensure consistent matching with absolute paths from the watcher 138 + let canonical_path = file_path.canonicalize().unwrap_or(file_path); 139 + self.content_file_to_routes 140 + .entry(canonical_path) 141 + .or_default() 142 + .insert(route_id); 143 + } 144 + 145 + /// Add a content file->source mapping 146 + /// This tracks which content source owns each file, allowing selective re-initialization 147 + /// of only the sources whose files have changed. 148 + /// 149 + /// The file path is canonicalized before storage to ensure consistent lookups. 150 + pub fn track_content_file_source(&mut self, file_path: PathBuf, source_name: String) { 151 + let canonical_path = file_path.canonicalize().unwrap_or(file_path); 152 + self.content_file_to_source 153 + .insert(canonical_path, source_name); 154 + } 155 + 156 + /// Get the names of content sources that have files in the changed files list. 157 + /// Returns `None` if any changed content file is not tracked (new file), indicating 158 + /// that all content sources should be re-initialized. 159 + /// 160 + /// Only considers files that look like content files (have common content extensions). 161 + pub fn get_affected_content_sources( 162 + &self, 163 + changed_files: &[PathBuf], 164 + ) -> Option<FxHashSet<String>> { 165 + let content_extensions = ["md", "mdx", "yaml", "yml", "json", "toml"]; 166 + let mut affected_sources = FxHashSet::default(); 167 + 168 + for changed_file in changed_files { 169 + // Skip files that don't look like content files 170 + let is_content_file = changed_file 171 + .extension() 172 + .and_then(|ext| ext.to_str()) 173 + .map(|ext| content_extensions.contains(&ext)) 174 + .unwrap_or(false); 175 + 176 + if !is_content_file { 177 + continue; 178 + } 179 + 180 + // Try to find the source for this file 181 + let canonical = changed_file.canonicalize().ok(); 182 + 183 + let source = canonical 184 + .as_ref() 185 + .and_then(|c| self.content_file_to_source.get(c)) 186 + .or_else(|| self.content_file_to_source.get(changed_file)); 187 + 188 + match source { 189 + Some(source_name) => { 190 + affected_sources.insert(source_name.clone()); 191 + } 192 + None => { 193 + // Unknown content file - could be a new file 194 + // Fall back to re-initializing all sources 195 + return None; 196 + } 197 + } 198 + } 199 + 200 + Some(affected_sources) 201 + } 202 + 203 + /// Get all routes affected by changes to specific files. 204 + /// 205 + /// Returns `Some(routes)` if all changed files were found in the mappings, 206 + /// or `None` if any changed file is untracked (meaning we need a full rebuild). 207 + /// 208 + /// This handles the case where files like those referenced by `include_str!()` 209 + /// are not tracked at the route level - when these change, we fall back to 210 + /// rebuilding all routes to ensure correctness. 211 + /// 212 + /// Note: Existing directories are not considered "untracked" - they are checked 213 + /// via prefix matching, but a new/unknown directory won't trigger a full rebuild. 214 + pub fn get_affected_routes( 215 + &self, 216 + changed_files: &[PathBuf], 217 + ) -> Option<FxHashSet<RouteIdentifier>> { 218 + let mut affected_routes = FxHashSet::default(); 219 + let mut has_untracked_file = false; 220 + 221 + for changed_file in changed_files { 222 + let mut file_was_tracked = false; 223 + 224 + // Canonicalize the changed file path for consistent comparison 225 + // All asset paths in asset_to_routes are stored as canonical paths 226 + let canonical_changed = changed_file.canonicalize().ok(); 227 + 228 + // Check source file mappings first (for .rs files) 229 + if let Some(canonical) = &canonical_changed 230 + && let Some(routes) = self.source_to_routes.get(canonical) 231 + { 232 + affected_routes.extend(routes.iter().cloned()); 233 + file_was_tracked = true; 234 + // Continue to also check asset mappings (a file could be both) 235 + } 236 + 237 + // Also check with original path for source files 238 + if let Some(routes) = self.source_to_routes.get(changed_file) { 239 + affected_routes.extend(routes.iter().cloned()); 240 + file_was_tracked = true; 241 + } 242 + 243 + // Try exact match with canonical path for assets 244 + if let Some(canonical) = &canonical_changed 245 + && let Some(routes) = self.asset_to_routes.get(canonical) 246 + { 247 + affected_routes.extend(routes.iter().cloned()); 248 + file_was_tracked = true; 249 + } 250 + 251 + // Fallback: try exact match with original path (shouldn't normally match) 252 + if let Some(routes) = self.asset_to_routes.get(changed_file) { 253 + affected_routes.extend(routes.iter().cloned()); 254 + file_was_tracked = true; 255 + } 256 + 257 + // Check if this is a content file with direct file->route tracking 258 + if let Some(canonical) = &canonical_changed 259 + && let Some(routes) = self.content_file_to_routes.get(canonical) 260 + { 261 + affected_routes.extend(routes.iter().cloned()); 262 + file_was_tracked = true; 263 + } 264 + 265 + // Also check with original path for content files 266 + if let Some(routes) = self.content_file_to_routes.get(changed_file) { 267 + affected_routes.extend(routes.iter().cloned()); 268 + file_was_tracked = true; 269 + } 270 + 271 + // Directory prefix check: find all routes using assets within this directory. 272 + // This handles two cases: 273 + // 1. A directory was modified - rebuild all routes using assets in that dir 274 + // 2. A directory was renamed/deleted - the old path no longer exists but we 275 + // still need to rebuild routes that used assets under that path 276 + // 277 + // We do this check if: 278 + // - The path currently exists as a directory, OR 279 + // - The path doesn't exist (could be a deleted/renamed directory) 280 + let is_existing_directory = changed_file.is_dir(); 281 + let path_does_not_exist = !changed_file.exists(); 282 + 283 + if is_existing_directory || path_does_not_exist { 284 + // Use original path for prefix matching (canonical won't exist for deleted dirs) 285 + for (asset_path, routes) in &self.asset_to_routes { 286 + if asset_path.starts_with(changed_file) { 287 + affected_routes.extend(routes.iter().cloned()); 288 + file_was_tracked = true; 289 + } 290 + } 291 + // Also check source files for directory prefix 292 + for (source_path, routes) in &self.source_to_routes { 293 + if source_path.starts_with(changed_file) { 294 + affected_routes.extend(routes.iter().cloned()); 295 + file_was_tracked = true; 296 + } 297 + } 298 + // Also check content files for directory prefix 299 + for (content_path, routes) in &self.content_file_to_routes { 300 + if content_path.starts_with(changed_file) { 301 + affected_routes.extend(routes.iter().cloned()); 302 + file_was_tracked = true; 303 + } 304 + } 305 + } 306 + 307 + // Flag as untracked (triggering full rebuild) if: 308 + // 1. The file wasn't found in any mapping, AND 309 + // 2. It's not a currently-existing directory (new directories are OK to ignore) 310 + // 311 + // For non-existent paths that weren't matched: 312 + // - If the path has a file extension, treat it as a deleted file โ†’ full rebuild 313 + // - If the path has no extension, it might be a deleted directory โ†’ allow 314 + // (we already checked prefix matching above) 315 + // 316 + // This is conservative: we'd rather rebuild too much than too little. 317 + if !file_was_tracked && !is_existing_directory { 318 + if path_does_not_exist { 319 + // For deleted paths, check if it looks like a file (has extension) 320 + // If it has an extension, it was probably a file โ†’ trigger full rebuild 321 + // If no extension, it might have been a directory โ†’ don't trigger 322 + let has_extension = changed_file 323 + .extension() 324 + .map(|ext| !ext.is_empty()) 325 + .unwrap_or(false); 326 + 327 + if has_extension { 328 + has_untracked_file = true; 329 + } 330 + } else { 331 + // Path exists but wasn't tracked โ†’ definitely untracked file 332 + has_untracked_file = true; 333 + } 334 + } 335 + } 336 + 337 + if has_untracked_file { 338 + // Some files weren't tracked - caller should do a full rebuild 339 + None 340 + } else { 341 + Some(affected_routes) 342 + } 343 + } 344 + 345 + /// Clear all tracked data (for full rebuild) 346 + pub fn clear(&mut self) { 347 + self.asset_to_routes.clear(); 348 + self.source_to_routes.clear(); 349 + self.content_file_to_routes.clear(); 350 + self.content_file_to_source.clear(); 351 + self.bundler_inputs.clear(); 352 + } 353 + 354 + /// Clear the content file to routes mapping. 355 + /// This should be called before re-tracking content files after content sources are re-initialized. 356 + pub fn clear_content_file_mappings(&mut self) { 357 + self.content_file_to_routes.clear(); 358 + } 359 + 360 + /// Clear content file mappings for specific sources. 361 + /// This removes both file->routes and file->source mappings for files owned by the given sources. 362 + /// Called when selectively re-initializing specific content sources. 363 + pub fn clear_content_mappings_for_sources(&mut self, source_names: &FxHashSet<String>) { 364 + // Find all files that belong to the specified sources 365 + let files_to_remove: Vec<PathBuf> = self 366 + .content_file_to_source 367 + .iter() 368 + .filter(|(_, source)| source_names.contains(*source)) 369 + .map(|(path, _)| path.clone()) 370 + .collect(); 371 + 372 + // Remove file->source mappings only 373 + // We DON'T clear file->routes mappings here because: 374 + // 1. Routes not being rebuilt should keep their mappings 375 + // 2. Routes being rebuilt will have their mappings cleared separately 376 + // via clear_content_file_mappings_for_routes() 377 + for file in &files_to_remove { 378 + self.content_file_to_source.remove(file); 379 + } 380 + } 381 + 382 + /// Remove content file mappings for specific routes. 383 + /// This is used during incremental builds to clear only the mappings for routes 384 + /// that will be rebuilt, preserving mappings for routes that won't change. 385 + pub fn clear_content_file_mappings_for_routes(&mut self, routes: &FxHashSet<RouteIdentifier>) { 386 + for routes_set in self.content_file_to_routes.values_mut() { 387 + routes_set.retain(|route| !routes.contains(route)); 388 + } 389 + // Remove any entries that have no routes left 390 + self.content_file_to_routes 391 + .retain(|_, routes_set| !routes_set.is_empty()); 392 + } 393 + 394 + /// Check if a file path is a known content file. 395 + /// This is used to determine if a new file might be a content file. 396 + #[allow(dead_code)] // Used in tests and potentially useful for debugging 397 + pub fn is_known_content_file(&self, file_path: &Path) -> bool { 398 + if self.content_file_to_routes.contains_key(file_path) { 399 + return true; 400 + } 401 + 402 + // Try with canonicalized path 403 + if let Ok(canonical) = file_path.canonicalize() { 404 + return self.content_file_to_routes.contains_key(&canonical); 405 + } 406 + 407 + false 408 + } 409 + } 410 + 411 + #[cfg(test)] 412 + mod tests { 413 + use super::*; 414 + 415 + fn make_route(path: &str) -> RouteIdentifier { 416 + RouteIdentifier::base(path.to_string(), None) 417 + } 418 + 419 + #[test] 420 + fn test_get_affected_routes_exact_match() { 421 + let mut state = BuildState::new(); 422 + let asset_path = PathBuf::from("/project/src/assets/logo.png"); 423 + let route = make_route("/"); 424 + 425 + state.track_asset(asset_path.clone(), route.clone()); 426 + 427 + // Exact match should work and return Some 428 + let affected = state.get_affected_routes(&[asset_path]).unwrap(); 429 + assert_eq!(affected.len(), 1); 430 + assert!(affected.contains(&route)); 431 + } 432 + 433 + #[test] 434 + fn test_get_affected_routes_untracked_file() { 435 + use std::fs; 436 + use tempfile::TempDir; 437 + 438 + let mut state = BuildState::new(); 439 + 440 + // Create temp files 441 + let temp_dir = TempDir::new().unwrap(); 442 + let tracked_file = temp_dir.path().join("logo.png"); 443 + let untracked_file = temp_dir.path().join("other.png"); 444 + fs::write(&tracked_file, "tracked").unwrap(); 445 + fs::write(&untracked_file, "untracked").unwrap(); 446 + 447 + let route = make_route("/"); 448 + state.track_asset(tracked_file.clone(), route); 449 + 450 + // Untracked file that EXISTS should return None (triggers full rebuild) 451 + let affected = state.get_affected_routes(&[untracked_file]); 452 + assert!(affected.is_none()); 453 + } 454 + 455 + #[test] 456 + fn test_get_affected_routes_mixed_tracked_untracked() { 457 + use std::fs; 458 + use tempfile::TempDir; 459 + 460 + let mut state = BuildState::new(); 461 + 462 + // Create temp files 463 + let temp_dir = TempDir::new().unwrap(); 464 + let tracked_file = temp_dir.path().join("logo.png"); 465 + let untracked_file = temp_dir.path().join("other.png"); 466 + fs::write(&tracked_file, "tracked").unwrap(); 467 + fs::write(&untracked_file, "untracked").unwrap(); 468 + 469 + let route = make_route("/"); 470 + state.track_asset(tracked_file.canonicalize().unwrap(), route); 471 + 472 + // If any file is untracked, return None (even if some are tracked) 473 + let affected = state.get_affected_routes(&[tracked_file, untracked_file]); 474 + assert!(affected.is_none()); 475 + } 476 + 477 + #[test] 478 + fn test_get_affected_routes_deleted_directory() { 479 + let mut state = BuildState::new(); 480 + 481 + // Track assets under a directory path 482 + let asset1 = PathBuf::from("/project/src/assets/icons/logo.png"); 483 + let asset2 = PathBuf::from("/project/src/assets/icons/favicon.ico"); 484 + let asset3 = PathBuf::from("/project/src/assets/styles.css"); 485 + let route1 = make_route("/"); 486 + let route2 = make_route("/about"); 487 + 488 + state.track_asset(asset1, route1.clone()); 489 + state.track_asset(asset2, route1.clone()); 490 + state.track_asset(asset3, route2.clone()); 491 + 492 + // Simulate a deleted/renamed directory (path doesn't exist) 493 + // The "icons" directory was renamed, so the old path doesn't exist 494 + let deleted_dir = PathBuf::from("/project/src/assets/icons"); 495 + 496 + // Since the path doesn't exist, it should check prefix matching 497 + let affected = state.get_affected_routes(&[deleted_dir]).unwrap(); 498 + 499 + // Should find route1 (uses assets under /icons/) but not route2 500 + assert_eq!(affected.len(), 1); 501 + assert!(affected.contains(&route1)); 502 + } 503 + 504 + #[test] 505 + fn test_get_affected_routes_multiple_routes_same_asset() { 506 + let mut state = BuildState::new(); 507 + let asset_path = PathBuf::from("/project/src/assets/shared.css"); 508 + let route1 = make_route("/"); 509 + let route2 = make_route("/about"); 510 + 511 + state.track_asset(asset_path.clone(), route1.clone()); 512 + state.track_asset(asset_path.clone(), route2.clone()); 513 + 514 + let affected = state.get_affected_routes(&[asset_path]).unwrap(); 515 + assert_eq!(affected.len(), 2); 516 + assert!(affected.contains(&route1)); 517 + assert!(affected.contains(&route2)); 518 + } 519 + 520 + #[test] 521 + fn test_get_affected_routes_source_file() { 522 + let mut state = BuildState::new(); 523 + let source_path = PathBuf::from("/project/src/pages/index.rs"); 524 + let route1 = make_route("/"); 525 + let route2 = make_route("/about"); 526 + 527 + // Track routes to their source files 528 + state.track_source_file(source_path.clone(), route1.clone()); 529 + state.track_source_file(source_path.clone(), route2.clone()); 530 + 531 + // When the source file changes, both routes should be affected 532 + let affected = state.get_affected_routes(&[source_path]).unwrap(); 533 + assert_eq!(affected.len(), 2); 534 + assert!(affected.contains(&route1)); 535 + assert!(affected.contains(&route2)); 536 + } 537 + 538 + #[test] 539 + fn test_get_affected_routes_source_file_only_matching() { 540 + let mut state = BuildState::new(); 541 + let source_index = PathBuf::from("/project/src/pages/index.rs"); 542 + let source_about = PathBuf::from("/project/src/pages/about.rs"); 543 + let route_index = make_route("/"); 544 + let route_about = make_route("/about"); 545 + 546 + state.track_source_file(source_index.clone(), route_index.clone()); 547 + state.track_source_file(source_about.clone(), route_about.clone()); 548 + 549 + // Changing only index.rs should only affect the index route 550 + let affected = state.get_affected_routes(&[source_index]).unwrap(); 551 + assert_eq!(affected.len(), 1); 552 + assert!(affected.contains(&route_index)); 553 + assert!(!affected.contains(&route_about)); 554 + } 555 + 556 + #[test] 557 + fn test_clear_also_clears_source_files() { 558 + let mut state = BuildState::new(); 559 + let source_path = PathBuf::from("/project/src/pages/index.rs"); 560 + let asset_path = PathBuf::from("/project/src/assets/logo.png"); 561 + let route = make_route("/"); 562 + 563 + state.track_source_file(source_path.clone(), route.clone()); 564 + state.track_asset(asset_path.clone(), route.clone()); 565 + 566 + assert!(!state.source_to_routes.is_empty()); 567 + assert!(!state.asset_to_routes.is_empty()); 568 + 569 + state.clear(); 570 + 571 + assert!(state.source_to_routes.is_empty()); 572 + assert!(state.asset_to_routes.is_empty()); 573 + } 574 + 575 + #[test] 576 + fn test_get_affected_routes_new_directory_not_untracked() { 577 + use std::fs; 578 + use tempfile::TempDir; 579 + 580 + let mut state = BuildState::new(); 581 + 582 + // Create a temporary directory to simulate the "new directory" scenario 583 + let temp_dir = TempDir::new().unwrap(); 584 + let new_dir = temp_dir.path().join("new-folder"); 585 + fs::create_dir(&new_dir).unwrap(); 586 + 587 + // Track some asset under a different path 588 + let asset_path = PathBuf::from("/project/src/assets/logo.png"); 589 + let route = make_route("/"); 590 + state.track_asset(asset_path.clone(), route.clone()); 591 + 592 + // When a new directory appears (e.g., from renaming another folder), 593 + // it should NOT trigger a full rebuild (return None), even though 594 + // we don't have any assets tracked under it. 595 + let affected = state.get_affected_routes(&[new_dir]); 596 + 597 + // Should return Some (not None), meaning we don't trigger full rebuild 598 + // The set should be empty since no assets are under this new directory 599 + assert!( 600 + affected.is_some(), 601 + "New directory should not trigger full rebuild" 602 + ); 603 + assert!(affected.unwrap().is_empty()); 604 + } 605 + 606 + #[test] 607 + fn test_get_affected_routes_folder_rename_scenario() { 608 + use std::fs; 609 + use tempfile::TempDir; 610 + 611 + let mut state = BuildState::new(); 612 + 613 + // Create temp directories to simulate folder rename 614 + let temp_dir = TempDir::new().unwrap(); 615 + let new_dir = temp_dir.path().join("icons-renamed"); 616 + fs::create_dir(&new_dir).unwrap(); 617 + 618 + // Track assets under the OLD folder path (which no longer exists) 619 + let old_dir = PathBuf::from("/project/src/assets/icons"); 620 + let asset1 = PathBuf::from("/project/src/assets/icons/logo.png"); 621 + let route = make_route("/blog"); 622 + state.track_asset(asset1, route.clone()); 623 + 624 + // Simulate folder rename: old path doesn't exist, new path is a directory 625 + // Both paths are passed as "changed" 626 + let affected = state.get_affected_routes(&[old_dir, new_dir]); 627 + 628 + // Should return Some (not None) - we found the affected route via prefix matching 629 + // and the new directory doesn't trigger "untracked file" behavior 630 + assert!( 631 + affected.is_some(), 632 + "Folder rename should not trigger full rebuild" 633 + ); 634 + let routes = affected.unwrap(); 635 + assert_eq!(routes.len(), 1); 636 + assert!(routes.contains(&route)); 637 + } 638 + 639 + #[test] 640 + fn test_get_affected_routes_deleted_untracked_file() { 641 + let mut state = BuildState::new(); 642 + 643 + // Track some assets 644 + let tracked_asset = PathBuf::from("/project/src/assets/logo.png"); 645 + let route = make_route("/"); 646 + state.track_asset(tracked_asset, route); 647 + 648 + // Simulate a deleted file that was NEVER tracked 649 + // (e.g., a file used via include_str! that we don't know about) 650 + // This path doesn't exist and isn't in any mapping 651 + let deleted_untracked_file = PathBuf::from("/project/src/content/data.txt"); 652 + 653 + let affected = state.get_affected_routes(&[deleted_untracked_file]); 654 + 655 + // Since the deleted path has a file extension (.txt), we treat it as 656 + // a deleted file that might have been a dependency we don't track. 657 + // We should trigger a full rebuild (return None) to be safe. 658 + assert!( 659 + affected.is_none(), 660 + "Deleted untracked file with extension should trigger full rebuild" 661 + ); 662 + } 663 + 664 + #[test] 665 + fn test_get_affected_routes_deleted_untracked_directory() { 666 + let mut state = BuildState::new(); 667 + 668 + // Track some assets 669 + let tracked_asset = PathBuf::from("/project/src/assets/logo.png"); 670 + let route = make_route("/"); 671 + state.track_asset(tracked_asset, route); 672 + 673 + // Simulate a deleted directory that was NEVER tracked 674 + // This path doesn't exist, isn't in any mapping, and has no extension 675 + let deleted_untracked_dir = PathBuf::from("/project/src/content"); 676 + 677 + let affected = state.get_affected_routes(&[deleted_untracked_dir]); 678 + 679 + // Since the path has no extension, it might have been a directory. 680 + // We already did prefix matching (found nothing), so we allow this 681 + // without triggering a full rebuild. 682 + assert!( 683 + affected.is_some(), 684 + "Deleted path without extension (possible directory) should not trigger full rebuild" 685 + ); 686 + assert!(affected.unwrap().is_empty()); 687 + } 688 + 689 + #[test] 690 + fn test_get_affected_routes_deleted_tracked_file() { 691 + use std::fs; 692 + use tempfile::TempDir; 693 + 694 + let mut state = BuildState::new(); 695 + 696 + // Create a temp file, track it, then delete it 697 + let temp_dir = TempDir::new().unwrap(); 698 + let tracked_file = temp_dir.path().join("logo.png"); 699 + fs::write(&tracked_file, "content").unwrap(); 700 + 701 + let canonical_path = tracked_file.canonicalize().unwrap(); 702 + let route = make_route("/"); 703 + state.track_asset(canonical_path.clone(), route.clone()); 704 + 705 + // Now delete the file 706 + fs::remove_file(&tracked_file).unwrap(); 707 + 708 + // The file no longer exists, but its canonical path is still in our mapping 709 + // When we get the change event, notify gives us the original path 710 + let affected = state.get_affected_routes(std::slice::from_ref(&tracked_file)); 711 + 712 + // This SHOULD find the route because we track by canonical path 713 + // and the original path should match via the mapping lookup 714 + println!("Result for deleted tracked file: {:?}", affected); 715 + 716 + // The path doesn't exist anymore, so canonicalize() fails. 717 + // We fall back to prefix matching, but exact path matching on 718 + // the non-canonical path should still work if stored that way. 719 + // Let's check what actually happens... 720 + match affected { 721 + Some(routes) => { 722 + // If we found routes, great - the system works 723 + assert!( 724 + routes.contains(&route), 725 + "Should find the route for deleted tracked file" 726 + ); 727 + } 728 + None => { 729 + // If None, that means we triggered a full rebuild, which is also safe 730 + // This happens because the file doesn't exist and wasn't found in mappings 731 + println!("Deleted tracked file triggered full rebuild (safe behavior)"); 732 + } 733 + } 734 + } 735 + 736 + #[test] 737 + fn test_track_content_file() { 738 + let mut state = BuildState::new(); 739 + let route = make_route("/"); 740 + let content_file = PathBuf::from("/project/content/articles/hello.md"); 741 + 742 + state.track_content_file(content_file.clone(), route.clone()); 743 + 744 + assert_eq!(state.content_file_to_routes.len(), 1); 745 + assert!(state.content_file_to_routes.contains_key(&content_file)); 746 + assert!(state.content_file_to_routes[&content_file].contains(&route)); 747 + } 748 + 749 + #[test] 750 + fn test_track_content_file_multiple_routes() { 751 + let mut state = BuildState::new(); 752 + let route1 = make_route("/"); 753 + let route2 = make_route("/blog"); 754 + let content_file = PathBuf::from("/project/content/articles/hello.md"); 755 + 756 + state.track_content_file(content_file.clone(), route1.clone()); 757 + state.track_content_file(content_file.clone(), route2.clone()); 758 + 759 + assert_eq!(state.content_file_to_routes.len(), 1); 760 + assert_eq!(state.content_file_to_routes[&content_file].len(), 2); 761 + assert!(state.content_file_to_routes[&content_file].contains(&route1)); 762 + assert!(state.content_file_to_routes[&content_file].contains(&route2)); 763 + } 764 + 765 + #[test] 766 + fn test_track_content_file_multiple_files() { 767 + let mut state = BuildState::new(); 768 + let route = make_route("/"); 769 + let file1 = PathBuf::from("/project/content/articles/hello.md"); 770 + let file2 = PathBuf::from("/project/content/articles/world.md"); 771 + 772 + state.track_content_file(file1.clone(), route.clone()); 773 + state.track_content_file(file2.clone(), route.clone()); 774 + 775 + assert_eq!(state.content_file_to_routes.len(), 2); 776 + assert!(state.content_file_to_routes[&file1].contains(&route)); 777 + assert!(state.content_file_to_routes[&file2].contains(&route)); 778 + } 779 + 780 + #[test] 781 + fn test_clear_also_clears_content_files() { 782 + let mut state = BuildState::new(); 783 + let route = make_route("/"); 784 + let content_file = PathBuf::from("/project/content/articles/hello.md"); 785 + 786 + state.track_content_file(content_file, route); 787 + 788 + assert!(!state.content_file_to_routes.is_empty()); 789 + 790 + state.clear(); 791 + 792 + assert!(state.content_file_to_routes.is_empty()); 793 + } 794 + 795 + #[test] 796 + fn test_get_affected_routes_content_file() { 797 + let mut state = BuildState::new(); 798 + let route1 = make_route("/"); 799 + let route2 = make_route("/blog/[slug]"); 800 + let route3 = make_route("/about"); 801 + 802 + // Track content file -> route mappings directly 803 + let article1 = PathBuf::from("/project/content/articles/hello.md"); 804 + let article2 = PathBuf::from("/project/content/articles/world.md"); 805 + let page1 = PathBuf::from("/project/content/pages/about.md"); 806 + 807 + // Route "/" uses article1 and article2 808 + state.track_content_file(article1.clone(), route1.clone()); 809 + state.track_content_file(article2.clone(), route1.clone()); 810 + // Route "/blog/[slug]" uses only article1 811 + state.track_content_file(article1.clone(), route2.clone()); 812 + // Route "/about" uses page1 813 + state.track_content_file(page1.clone(), route3.clone()); 814 + 815 + // When article1 changes, only routes that used article1 should be affected 816 + let affected = state.get_affected_routes(&[article1]).unwrap(); 817 + assert_eq!(affected.len(), 2); 818 + assert!(affected.contains(&route1)); 819 + assert!(affected.contains(&route2)); 820 + assert!(!affected.contains(&route3)); 821 + 822 + // When article2 changes, only route1 should be affected (granular!) 823 + let affected = state.get_affected_routes(&[article2]).unwrap(); 824 + assert_eq!(affected.len(), 1); 825 + assert!(affected.contains(&route1)); 826 + assert!(!affected.contains(&route2)); 827 + assert!(!affected.contains(&route3)); 828 + 829 + // When page1 changes, only route3 should be affected 830 + let affected = state.get_affected_routes(&[page1]).unwrap(); 831 + assert_eq!(affected.len(), 1); 832 + assert!(affected.contains(&route3)); 833 + assert!(!affected.contains(&route1)); 834 + assert!(!affected.contains(&route2)); 835 + } 836 + 837 + #[test] 838 + fn test_get_affected_routes_content_file_multiple_files_changed() { 839 + let mut state = BuildState::new(); 840 + let route1 = make_route("/"); 841 + let route2 = make_route("/about"); 842 + 843 + // Track content files 844 + let article = PathBuf::from("/project/content/articles/hello.md"); 845 + let page = PathBuf::from("/project/content/pages/about.md"); 846 + 847 + state.track_content_file(article.clone(), route1.clone()); 848 + state.track_content_file(page.clone(), route2.clone()); 849 + 850 + // When both files change, both routes should be affected 851 + let affected = state.get_affected_routes(&[article, page]).unwrap(); 852 + assert_eq!(affected.len(), 2); 853 + assert!(affected.contains(&route1)); 854 + assert!(affected.contains(&route2)); 855 + } 856 + 857 + #[test] 858 + fn test_get_affected_routes_content_file_mixed_with_asset() { 859 + let mut state = BuildState::new(); 860 + let route1 = make_route("/"); 861 + let route2 = make_route("/about"); 862 + 863 + // Track a content file for route1 864 + let article = PathBuf::from("/project/content/articles/hello.md"); 865 + state.track_content_file(article.clone(), route1.clone()); 866 + 867 + // Track an asset used by route2 868 + let style = PathBuf::from("/project/src/styles.css"); 869 + state.track_asset(style.clone(), route2.clone()); 870 + 871 + // When both content file and asset change 872 + let affected = state.get_affected_routes(&[article, style]).unwrap(); 873 + assert_eq!(affected.len(), 2); 874 + assert!(affected.contains(&route1)); 875 + assert!(affected.contains(&route2)); 876 + } 877 + 878 + #[test] 879 + fn test_get_affected_routes_unknown_content_file() { 880 + let mut state = BuildState::new(); 881 + let route = make_route("/"); 882 + 883 + // Track a content file 884 + let article = PathBuf::from("/project/content/articles/hello.md"); 885 + state.track_content_file(article, route); 886 + 887 + // A new/unknown .md file that isn't tracked 888 + // This could be a newly created file 889 + let new_file = PathBuf::from("/project/content/articles/new-post.md"); 890 + 891 + // Should trigger full rebuild since it's an untracked file with extension 892 + let affected = state.get_affected_routes(&[new_file]); 893 + assert!( 894 + affected.is_none(), 895 + "New untracked content file should trigger full rebuild" 896 + ); 897 + } 898 + 899 + #[test] 900 + fn test_is_known_content_file() { 901 + let mut state = BuildState::new(); 902 + let route = make_route("/"); 903 + let content_file = PathBuf::from("/project/content/articles/hello.md"); 904 + 905 + state.track_content_file(content_file.clone(), route); 906 + 907 + assert!(state.is_known_content_file(&content_file)); 908 + assert!(!state.is_known_content_file(Path::new("/project/content/articles/unknown.md"))); 909 + } 910 + 911 + #[test] 912 + fn test_content_file_directory_prefix() { 913 + let mut state = BuildState::new(); 914 + let route = make_route("/"); 915 + 916 + // Track content files under a directory 917 + let article1 = PathBuf::from("/project/content/articles/hello.md"); 918 + let article2 = PathBuf::from("/project/content/articles/world.md"); 919 + state.track_content_file(article1, route.clone()); 920 + state.track_content_file(article2, route.clone()); 921 + 922 + // When the parent directory changes (e.g., renamed), should find affected routes 923 + let content_dir = PathBuf::from("/project/content/articles"); 924 + let affected = state.get_affected_routes(&[content_dir]).unwrap(); 925 + assert_eq!(affected.len(), 1); 926 + assert!(affected.contains(&route)); 927 + } 928 + 929 + #[test] 930 + fn test_clear_content_file_mappings_for_routes() { 931 + let mut state = BuildState::new(); 932 + let route1 = make_route("/articles"); 933 + let route2 = make_route("/articles/[slug]"); 934 + let route3 = make_route("/about"); 935 + 936 + // Article 1 is accessed by routes 1 and 2 937 + let article1 = PathBuf::from("/project/content/articles/hello.md"); 938 + state.track_content_file(article1.clone(), route1.clone()); 939 + state.track_content_file(article1.clone(), route2.clone()); 940 + 941 + // Article 2 is accessed by routes 1 and 2 942 + let article2 = PathBuf::from("/project/content/articles/world.md"); 943 + state.track_content_file(article2.clone(), route1.clone()); 944 + state.track_content_file(article2.clone(), route2.clone()); 945 + 946 + // Route 3 uses a different file 947 + let page = PathBuf::from("/project/content/pages/about.md"); 948 + state.track_content_file(page.clone(), route3.clone()); 949 + 950 + assert_eq!(state.content_file_to_routes.len(), 3); 951 + 952 + // Clear mappings only for route2 953 + let mut routes_to_clear = FxHashSet::default(); 954 + routes_to_clear.insert(route2.clone()); 955 + state.clear_content_file_mappings_for_routes(&routes_to_clear); 956 + 957 + // route2 should be removed from article1 and article2 mappings 958 + assert!(!state.content_file_to_routes[&article1].contains(&route2)); 959 + assert!(state.content_file_to_routes[&article1].contains(&route1)); 960 + 961 + assert!(!state.content_file_to_routes[&article2].contains(&route2)); 962 + assert!(state.content_file_to_routes[&article2].contains(&route1)); 963 + 964 + // route3's mapping should be unaffected 965 + assert!(state.content_file_to_routes[&page].contains(&route3)); 966 + } 967 + 968 + #[test] 969 + fn test_clear_content_file_mappings_for_routes_removes_empty_entries() { 970 + let mut state = BuildState::new(); 971 + let route1 = make_route("/articles/first"); 972 + let route2 = make_route("/articles/second"); 973 + 974 + // Route1 uses only article1 975 + let article1 = PathBuf::from("/project/content/articles/first.md"); 976 + state.track_content_file(article1.clone(), route1.clone()); 977 + 978 + // Route2 uses only article2 979 + let article2 = PathBuf::from("/project/content/articles/second.md"); 980 + state.track_content_file(article2.clone(), route2.clone()); 981 + 982 + assert_eq!(state.content_file_to_routes.len(), 2); 983 + 984 + // Clear mappings for route1 985 + let mut routes_to_clear = FxHashSet::default(); 986 + routes_to_clear.insert(route1); 987 + state.clear_content_file_mappings_for_routes(&routes_to_clear); 988 + 989 + // article1 entry should be completely removed (no routes left) 990 + assert!(!state.content_file_to_routes.contains_key(&article1)); 991 + 992 + // article2 entry should still exist 993 + assert!(state.content_file_to_routes.contains_key(&article2)); 994 + assert!(state.content_file_to_routes[&article2].contains(&route2)); 995 + } 996 + 997 + #[test] 998 + fn test_track_content_file_source() { 999 + let mut state = BuildState::new(); 1000 + let file = PathBuf::from("/project/content/articles/hello.md"); 1001 + 1002 + state.track_content_file_source(file.clone(), "articles".to_string()); 1003 + 1004 + assert_eq!(state.content_file_to_source.len(), 1); 1005 + assert_eq!( 1006 + state.content_file_to_source.get(&file), 1007 + Some(&"articles".to_string()) 1008 + ); 1009 + } 1010 + 1011 + #[test] 1012 + fn test_get_affected_content_sources_single_source() { 1013 + let mut state = BuildState::new(); 1014 + let article1 = PathBuf::from("/project/content/articles/hello.md"); 1015 + let article2 = PathBuf::from("/project/content/articles/world.md"); 1016 + 1017 + state.track_content_file_source(article1.clone(), "articles".to_string()); 1018 + state.track_content_file_source(article2.clone(), "articles".to_string()); 1019 + 1020 + // Change one article file 1021 + let affected = state.get_affected_content_sources(&[article1]).unwrap(); 1022 + assert_eq!(affected.len(), 1); 1023 + assert!(affected.contains("articles")); 1024 + } 1025 + 1026 + #[test] 1027 + fn test_get_affected_content_sources_multiple_sources() { 1028 + let mut state = BuildState::new(); 1029 + let article = PathBuf::from("/project/content/articles/hello.md"); 1030 + let page = PathBuf::from("/project/content/pages/about.md"); 1031 + 1032 + state.track_content_file_source(article.clone(), "articles".to_string()); 1033 + state.track_content_file_source(page.clone(), "pages".to_string()); 1034 + 1035 + // Change both files 1036 + let affected = state 1037 + .get_affected_content_sources(&[article, page]) 1038 + .unwrap(); 1039 + assert_eq!(affected.len(), 2); 1040 + assert!(affected.contains("articles")); 1041 + assert!(affected.contains("pages")); 1042 + } 1043 + 1044 + #[test] 1045 + fn test_get_affected_content_sources_unknown_file_returns_none() { 1046 + let mut state = BuildState::new(); 1047 + let article = PathBuf::from("/project/content/articles/hello.md"); 1048 + state.track_content_file_source(article, "articles".to_string()); 1049 + 1050 + // A new file that's not tracked 1051 + let new_file = PathBuf::from("/project/content/articles/new-post.md"); 1052 + 1053 + // Should return None (need to re-init all sources) 1054 + let affected = state.get_affected_content_sources(&[new_file]); 1055 + assert!(affected.is_none()); 1056 + } 1057 + 1058 + #[test] 1059 + fn test_get_affected_content_sources_ignores_non_content_files() { 1060 + let mut state = BuildState::new(); 1061 + let article = PathBuf::from("/project/content/articles/hello.md"); 1062 + state.track_content_file_source(article.clone(), "articles".to_string()); 1063 + 1064 + // A non-content file (e.g., .rs file) - should be ignored 1065 + let rust_file = PathBuf::from("/project/src/pages/index.rs"); 1066 + 1067 + // Should return empty set (no content sources affected) 1068 + let affected = state 1069 + .get_affected_content_sources(std::slice::from_ref(&rust_file)) 1070 + .unwrap(); 1071 + assert!(affected.is_empty()); 1072 + 1073 + // Mixed: content file + non-content file 1074 + let affected = state 1075 + .get_affected_content_sources(&[article, rust_file]) 1076 + .unwrap(); 1077 + assert_eq!(affected.len(), 1); 1078 + assert!(affected.contains("articles")); 1079 + } 1080 + 1081 + #[test] 1082 + fn test_clear_content_mappings_for_sources() { 1083 + let mut state = BuildState::new(); 1084 + let route1 = make_route("/articles"); 1085 + let route2 = make_route("/pages"); 1086 + 1087 + // Set up articles source 1088 + let article1 = PathBuf::from("/project/content/articles/hello.md"); 1089 + let article2 = PathBuf::from("/project/content/articles/world.md"); 1090 + state.track_content_file_source(article1.clone(), "articles".to_string()); 1091 + state.track_content_file_source(article2.clone(), "articles".to_string()); 1092 + state.track_content_file(article1.clone(), route1.clone()); 1093 + state.track_content_file(article2.clone(), route1.clone()); 1094 + 1095 + // Set up pages source 1096 + let page = PathBuf::from("/project/content/pages/about.md"); 1097 + state.track_content_file_source(page.clone(), "pages".to_string()); 1098 + state.track_content_file(page.clone(), route2.clone()); 1099 + 1100 + assert_eq!(state.content_file_to_source.len(), 3); 1101 + assert_eq!(state.content_file_to_routes.len(), 3); 1102 + 1103 + // Clear only the articles source 1104 + let mut sources_to_clear = FxHashSet::default(); 1105 + sources_to_clear.insert("articles".to_string()); 1106 + state.clear_content_mappings_for_sources(&sources_to_clear); 1107 + 1108 + // Articles source mappings should be removed 1109 + assert!(!state.content_file_to_source.contains_key(&article1)); 1110 + assert!(!state.content_file_to_source.contains_key(&article2)); 1111 + 1112 + // But routes mappings should be preserved (cleared separately per-route) 1113 + assert!(state.content_file_to_routes.contains_key(&article1)); 1114 + assert!(state.content_file_to_routes.contains_key(&article2)); 1115 + 1116 + // Pages should remain completely unchanged 1117 + assert!(state.content_file_to_source.contains_key(&page)); 1118 + assert!(state.content_file_to_routes.contains_key(&page)); 1119 + assert_eq!( 1120 + state.content_file_to_source.get(&page), 1121 + Some(&"pages".to_string()) 1122 + ); 1123 + } 1124 + 1125 + #[test] 1126 + fn test_clear_also_clears_content_file_to_source() { 1127 + let mut state = BuildState::new(); 1128 + let file = PathBuf::from("/project/content/articles/hello.md"); 1129 + state.track_content_file_source(file, "articles".to_string()); 1130 + 1131 + assert!(!state.content_file_to_source.is_empty()); 1132 + 1133 + state.clear(); 1134 + 1135 + assert!(state.content_file_to_source.is_empty()); 1136 + } 1137 + }
+696 -143
crates/maudit/src/build.rs
··· 14 self, HashAssetType, HashConfig, PrefetchPlugin, RouteAssets, Script, TailwindPlugin, 15 calculate_hash, image_cache::ImageCache, prefetch, 16 }, 17 - build::{images::process_image, options::PrefetchStrategy}, 18 - content::ContentSources, 19 is_dev, 20 logging::print_title, 21 route::{CachedRoute, DynamicRouteContext, FullRoute, InternalRoute, PageContext, PageParams}, ··· 26 use log::{debug, info, trace, warn}; 27 use pathdiff::diff_paths; 28 use rolldown::{Bundler, BundlerOptions, InputItem, ModuleType}; 29 use rustc_hash::{FxHashMap, FxHashSet}; 30 31 use crate::assets::Asset; ··· 35 pub mod images; 36 pub mod metadata; 37 pub mod options; 38 39 pub fn execute_build( 40 routes: &[&dyn FullRoute], 41 content_sources: &mut ContentSources, 42 options: &BuildOptions, 43 async_runtime: &tokio::runtime::Runtime, 44 ) -> Result<BuildOutput, Box<dyn std::error::Error>> { 45 - async_runtime.block_on(async { build(routes, content_sources, options).await }) 46 } 47 48 pub async fn build( 49 routes: &[&dyn FullRoute], 50 content_sources: &mut ContentSources, 51 options: &BuildOptions, 52 ) -> Result<BuildOutput, Box<dyn std::error::Error>> { 53 let build_start = Instant::now(); 54 let mut build_metadata = BuildOutput::new(build_start); ··· 56 // Create a directory for the output 57 trace!(target: "build", "Setting up required directories..."); 58 59 - let clean_up_handle = if options.clean_output_dir { 60 let old_dist_tmp_dir = { 61 let duration = SystemTime::now().duration_since(UNIX_EPOCH)?; 62 let num = (duration.as_secs() + duration.subsec_nanos() as u64) % 100000; ··· 73 }; 74 75 // Create the image cache early so it can be shared across routes 76 - let image_cache = ImageCache::with_cache_dir(&options.assets.image_cache_dir); 77 let _ = fs::create_dir_all(image_cache.get_cache_dir()); 78 79 // Create route_assets_options with the image cache ··· 83 84 let content_sources_start = Instant::now(); 85 print_title("initializing content sources"); 86 - content_sources.sources_mut().iter_mut().for_each(|source| { 87 - let source_start = Instant::now(); 88 - source.init(); 89 90 - info!(target: "content", "{} initialized in {}", source.get_name(), format_elapsed_time(source_start.elapsed(), &FormatElapsedTimeOptions::default())); 91 - }); 92 93 info!(target: "content", "{}", format!("Content sources initialized in {}", format_elapsed_time( 94 content_sources_start.elapsed(), 95 &FormatElapsedTimeOptions::default(), 96 )).bold()); 97 98 print_title("generating pages"); 99 let pages_start = Instant::now(); 100 ··· 182 183 // Static base route 184 if base_params.is_empty() { 185 - let mut route_assets = RouteAssets::with_default_assets( 186 - &route_assets_options, 187 - Some(image_cache.clone()), 188 - default_scripts.clone(), 189 - vec![], 190 - ); 191 192 - let params = PageParams::default(); 193 - let url = cached_route.url(&params); 194 195 - let result = route.build(&mut PageContext::from_static_route( 196 - content_sources, 197 - &mut route_assets, 198 - &url, 199 - &options.base_url, 200 - None, 201 - ))?; 202 203 - let file_path = cached_route.file_path(&params, &options.output_dir); 204 205 - write_route_file(&result, &file_path)?; 206 207 - info!(target: "pages", "{} -> {} {}", url, file_path.to_string_lossy().dimmed(), format_elapsed_time(route_start.elapsed(), &route_format_options)); 208 209 - build_pages_images.extend(route_assets.images); 210 - build_pages_scripts.extend(route_assets.scripts); 211 - build_pages_styles.extend(route_assets.styles); 212 213 - build_metadata.add_page( 214 - base_path.clone(), 215 - file_path.to_string_lossy().to_string(), 216 - None, 217 - ); 218 219 - add_sitemap_entry( 220 - &mut sitemap_entries, 221 - normalized_base_url, 222 - &url, 223 - base_path, 224 - &route.sitemap_metadata(), 225 - &options.sitemap, 226 - ); 227 228 - page_count += 1; 229 } else { 230 // Dynamic base route 231 let mut route_assets = RouteAssets::with_default_assets( ··· 249 250 // Build all pages for this route 251 for page in pages { 252 - let page_start = Instant::now(); 253 - let url = cached_route.url(&page.0); 254 - let file_path = cached_route.file_path(&page.0, &options.output_dir); 255 256 - let content = route.build(&mut PageContext::from_dynamic_route( 257 - &page, 258 - content_sources, 259 - &mut route_assets, 260 - &url, 261 - &options.base_url, 262 - None, 263 - ))?; 264 265 - write_route_file(&content, &file_path)?; 266 267 - info!(target: "pages", "โ”œโ”€ {} {}", file_path.to_string_lossy().dimmed(), format_elapsed_time(page_start.elapsed(), &route_format_options)); 268 269 - build_metadata.add_page( 270 - base_path.clone(), 271 - file_path.to_string_lossy().to_string(), 272 - Some(page.0.0.clone()), 273 - ); 274 275 - add_sitemap_entry( 276 - &mut sitemap_entries, 277 - normalized_base_url, 278 - &url, 279 - base_path, 280 - &route.sitemap_metadata(), 281 - &options.sitemap, 282 - ); 283 284 - page_count += 1; 285 } 286 } 287 ··· 298 299 if variant_params.is_empty() { 300 // Static variant 301 - let mut route_assets = RouteAssets::with_default_assets( 302 - &route_assets_options, 303 - Some(image_cache.clone()), 304 - default_scripts.clone(), 305 - vec![], 306 - ); 307 308 - let params = PageParams::default(); 309 - let url = cached_route.variant_url(&params, &variant_id)?; 310 - let file_path = 311 - cached_route.variant_file_path(&params, &options.output_dir, &variant_id)?; 312 313 - let result = route.build(&mut PageContext::from_static_route( 314 - content_sources, 315 - &mut route_assets, 316 - &url, 317 - &options.base_url, 318 - Some(variant_id.clone()), 319 - ))?; 320 321 - write_route_file(&result, &file_path)?; 322 323 - info!(target: "pages", "โ”œโ”€ {} {}", file_path.to_string_lossy().dimmed(), format_elapsed_time(variant_start.elapsed(), &route_format_options)); 324 325 - build_pages_images.extend(route_assets.images); 326 - build_pages_scripts.extend(route_assets.scripts); 327 - build_pages_styles.extend(route_assets.styles); 328 329 - build_metadata.add_page( 330 - variant_path.clone(), 331 - file_path.to_string_lossy().to_string(), 332 - None, 333 - ); 334 335 - add_sitemap_entry( 336 - &mut sitemap_entries, 337 - normalized_base_url, 338 - &url, 339 - &variant_path, 340 - &route.sitemap_metadata(), 341 - &options.sitemap, 342 - ); 343 344 - page_count += 1; 345 } else { 346 // Dynamic variant 347 let mut route_assets = RouteAssets::with_default_assets( ··· 364 365 // Build all pages for this variant group 366 for page in pages { 367 - let variant_page_start = Instant::now(); 368 - let url = cached_route.variant_url(&page.0, &variant_id)?; 369 - let file_path = cached_route.variant_file_path( 370 - &page.0, 371 - &options.output_dir, 372 - &variant_id, 373 - )?; 374 375 - let content = route.build(&mut PageContext::from_dynamic_route( 376 - &page, 377 - content_sources, 378 - &mut route_assets, 379 - &url, 380 - &options.base_url, 381 - Some(variant_id.clone()), 382 - ))?; 383 384 - write_route_file(&content, &file_path)?; 385 386 - info!(target: "pages", "โ”‚ โ”œโ”€ {} {}", file_path.to_string_lossy().dimmed(), format_elapsed_time(variant_page_start.elapsed(), &route_format_options)); 387 388 - build_metadata.add_page( 389 - variant_path.clone(), 390 - file_path.to_string_lossy().to_string(), 391 - Some(page.0.0.clone()), 392 - ); 393 394 - add_sitemap_entry( 395 - &mut sitemap_entries, 396 - normalized_base_url, 397 - &url, 398 - &variant_path, 399 - &route.sitemap_metadata(), 400 - &options.sitemap, 401 - ); 402 403 - page_count += 1; 404 } 405 } 406 ··· 420 fs::create_dir_all(&route_assets_options.output_assets_dir)?; 421 } 422 423 - if !build_pages_styles.is_empty() || !build_pages_scripts.is_empty() { 424 let assets_start = Instant::now(); 425 print_title("generating assets"); 426 ··· 438 }) 439 .collect::<Vec<InputItem>>(); 440 441 - let bundler_inputs = build_pages_scripts 442 .iter() 443 .map(|script| InputItem { 444 import: script.path().to_string_lossy().to_string(), ··· 453 .chain(css_inputs.into_iter()) 454 .collect::<Vec<InputItem>>(); 455 456 debug!( 457 target: "bundling", 458 "Bundler inputs: {:?}", ··· 462 .collect::<Vec<String>>() 463 ); 464 465 if !bundler_inputs.is_empty() { 466 let mut module_types_hashmap = FxHashMap::default(); 467 module_types_hashmap.insert("woff".to_string(), ModuleType::Asset); 468 module_types_hashmap.insert("woff2".to_string(), ModuleType::Asset); 469 470 let mut bundler = Bundler::with_plugins( 471 BundlerOptions { ··· 495 .collect::<Vec<PathBuf>>(), 496 }), 497 Arc::new(PrefetchPlugin {}), 498 ], 499 )?; 500 501 - let _result = bundler.write().await?; 502 503 - // TODO: Add outputted chunks to build_metadata 504 } 505 506 info!(target: "build", "{}", format!("Assets generated in {}", format_elapsed_time(assets_start.elapsed(), &section_format_options)).bold()); ··· 596 info!(target: "SKIP_FORMAT", "{}", ""); 597 info!(target: "build", "{}", format!("Build completed in {}", format_elapsed_time(build_start.elapsed(), &section_format_options)).bold()); 598 599 if let Some(clean_up_handle) = clean_up_handle { 600 clean_up_handle.await?; 601 } ··· 678 fs::create_dir_all(parent_dir)? 679 } 680 681 fs::write(file_path, content)?; 682 683 Ok(())
··· 14 self, HashAssetType, HashConfig, PrefetchPlugin, RouteAssets, Script, TailwindPlugin, 15 calculate_hash, image_cache::ImageCache, prefetch, 16 }, 17 + build::{ 18 + images::process_image, 19 + options::PrefetchStrategy, 20 + state::{BuildState, RouteIdentifier}, 21 + }, 22 + content::{ContentSources, finish_tracking_content_files, start_tracking_content_files}, 23 is_dev, 24 logging::print_title, 25 route::{CachedRoute, DynamicRouteContext, FullRoute, InternalRoute, PageContext, PageParams}, ··· 30 use log::{debug, info, trace, warn}; 31 use pathdiff::diff_paths; 32 use rolldown::{Bundler, BundlerOptions, InputItem, ModuleType}; 33 + use rolldown_common::Output; 34 + use rolldown_plugin_replace::ReplacePlugin; 35 use rustc_hash::{FxHashMap, FxHashSet}; 36 37 use crate::assets::Asset; ··· 41 pub mod images; 42 pub mod metadata; 43 pub mod options; 44 + pub mod state; 45 + 46 + /// Helper to check if a route should be rebuilt during incremental builds. 47 + /// Returns `true` for full builds (when `routes_to_rebuild` is `None`). 48 + fn should_rebuild_route( 49 + route_id: Option<&RouteIdentifier>, 50 + routes_to_rebuild: &Option<FxHashSet<RouteIdentifier>>, 51 + ) -> bool { 52 + match routes_to_rebuild { 53 + Some(set) => { 54 + // Incremental build - need route_id to check 55 + let route_id = route_id.expect("route_id required for incremental builds"); 56 + let result = set.contains(route_id); 57 + if !result { 58 + trace!(target: "build", "Skipping route {:?} (not in rebuild set)", route_id); 59 + } 60 + result 61 + } 62 + None => true, // Full build - always rebuild 63 + } 64 + } 65 + 66 + /// Helper to track all assets and source files used by a route. 67 + /// Only performs work when incremental builds are enabled and route_id is provided. 68 + fn track_route_assets( 69 + build_state: &mut BuildState, 70 + route_id: Option<&RouteIdentifier>, 71 + route_assets: &RouteAssets, 72 + ) { 73 + // Skip tracking entirely when route_id is not provided (incremental disabled) 74 + let Some(route_id) = route_id else { 75 + return; 76 + }; 77 + 78 + // Track images 79 + for image in &route_assets.images { 80 + if let Ok(canonical) = image.path().canonicalize() { 81 + build_state.track_asset(canonical, route_id.clone()); 82 + } 83 + } 84 + 85 + // Track scripts 86 + for script in &route_assets.scripts { 87 + if let Ok(canonical) = script.path().canonicalize() { 88 + build_state.track_asset(canonical, route_id.clone()); 89 + } 90 + } 91 + 92 + // Track styles 93 + for style in &route_assets.styles { 94 + if let Ok(canonical) = style.path().canonicalize() { 95 + build_state.track_asset(canonical, route_id.clone()); 96 + } 97 + } 98 + } 99 + 100 + /// Helper to track the source file where a route is defined. 101 + /// Only performs work when incremental builds are enabled and route_id is provided. 102 + fn track_route_source_file( 103 + build_state: &mut BuildState, 104 + route_id: Option<&RouteIdentifier>, 105 + source_file: &str, 106 + ) { 107 + // Skip tracking entirely when route_id is not provided (incremental disabled) 108 + let Some(route_id) = route_id else { 109 + return; 110 + }; 111 + 112 + // The file!() macro returns a path relative to the cargo workspace root. 113 + // We need to canonicalize it to match against changed file paths (which are absolute). 114 + let source_path = PathBuf::from(source_file); 115 + 116 + // Try direct canonicalization first (works if CWD is workspace root) 117 + if let Ok(canonical) = source_path.canonicalize() { 118 + build_state.track_source_file(canonical, route_id.clone()); 119 + return; 120 + } 121 + 122 + // The file!() macro path is relative to the workspace root at compile time. 123 + // At runtime, we're typically running from the package directory. 124 + // Try to find the file by walking up from CWD until we find it. 125 + if let Ok(cwd) = std::env::current_dir() { 126 + let mut current = cwd.as_path(); 127 + loop { 128 + let candidate = current.join(&source_path); 129 + if let Ok(canonical) = candidate.canonicalize() { 130 + build_state.track_source_file(canonical, route_id.clone()); 131 + return; 132 + } 133 + match current.parent() { 134 + Some(parent) => current = parent, 135 + None => break, 136 + } 137 + } 138 + } 139 + 140 + // Last resort: store the relative path (won't match absolute changed files) 141 + debug!(target: "build", "Could not canonicalize source file path: {}", source_file); 142 + build_state.track_source_file(source_path, route_id.clone()); 143 + } 144 + 145 + /// Helper to track content files accessed during page rendering. 146 + /// Only performs work when incremental builds are enabled and route_id is provided. 147 + /// This should be called after `finish_tracking_content_files()` to get the accessed files. 148 + fn track_route_content_files( 149 + build_state: &mut BuildState, 150 + route_id: Option<&RouteIdentifier>, 151 + accessed_files: Option<FxHashSet<PathBuf>>, 152 + ) { 153 + // Skip tracking entirely when route_id is not provided (incremental disabled) 154 + let Some(route_id) = route_id else { 155 + return; 156 + }; 157 + 158 + // Skip if no files were tracked 159 + let Some(files) = accessed_files else { 160 + return; 161 + }; 162 + 163 + for file_path in files { 164 + build_state.track_content_file(file_path, route_id.clone()); 165 + } 166 + } 167 168 pub fn execute_build( 169 routes: &[&dyn FullRoute], 170 content_sources: &mut ContentSources, 171 options: &BuildOptions, 172 + changed_files: Option<&[PathBuf]>, 173 async_runtime: &tokio::runtime::Runtime, 174 ) -> Result<BuildOutput, Box<dyn std::error::Error>> { 175 + async_runtime.block_on(async { build(routes, content_sources, options, changed_files).await }) 176 } 177 178 pub async fn build( 179 routes: &[&dyn FullRoute], 180 content_sources: &mut ContentSources, 181 options: &BuildOptions, 182 + changed_files: Option<&[PathBuf]>, 183 ) -> Result<BuildOutput, Box<dyn std::error::Error>> { 184 let build_start = Instant::now(); 185 let mut build_metadata = BuildOutput::new(build_start); ··· 187 // Create a directory for the output 188 trace!(target: "build", "Setting up required directories..."); 189 190 + // Use cache directory from options 191 + let build_cache_dir = &options.cache_dir; 192 + 193 + // Load build state for incremental builds (only if incremental is enabled) 194 + let mut build_state = if options.incremental { 195 + BuildState::load(build_cache_dir).unwrap_or_else(|e| { 196 + debug!(target: "build", "Failed to load build state: {}", e); 197 + BuildState::new() 198 + }) 199 + } else { 200 + BuildState::new() 201 + }; 202 + 203 + debug!(target: "build", "Loaded build state with {} asset mappings, {} source mappings, {} content file mappings", build_state.asset_to_routes.len(), build_state.source_to_routes.len(), build_state.content_file_to_routes.len()); 204 + debug!(target: "build", "options.incremental: {}, changed_files.is_some(): {}", options.incremental, changed_files.is_some()); 205 + 206 + // Determine if this is an incremental build 207 + // We need either asset mappings OR source file mappings to do incremental builds 208 + let has_build_state = 209 + !build_state.asset_to_routes.is_empty() || !build_state.source_to_routes.is_empty(); 210 + let is_incremental = options.incremental && changed_files.is_some() && has_build_state; 211 + 212 + let routes_to_rebuild = if is_incremental { 213 + let changed = changed_files.unwrap(); 214 + info!(target: "build", "Incremental build: {} files changed", changed.len()); 215 + info!(target: "build", "Changed files: {:?}", changed); 216 + 217 + info!(target: "build", "Build state has {} asset mappings, {} source mappings, {} content file mappings", build_state.asset_to_routes.len(), build_state.source_to_routes.len(), build_state.content_file_to_routes.len()); 218 + 219 + match build_state.get_affected_routes(changed) { 220 + Some(affected) => { 221 + info!(target: "build", "Rebuilding {} affected routes", affected.len()); 222 + info!(target: "build", "Affected routes: {:?}", affected); 223 + Some(affected) 224 + } 225 + None => { 226 + // Some changed files weren't tracked (e.g., include_str! dependencies) 227 + // Fall back to full rebuild to ensure correctness 228 + info!(target: "build", "Untracked files changed, falling back to full rebuild"); 229 + build_state.clear(); 230 + None 231 + } 232 + } 233 + } else { 234 + if changed_files.is_some() { 235 + info!(target: "build", "Full build (first run after recompilation)"); 236 + } 237 + // Full build - clear old state 238 + build_state.clear(); 239 + None 240 + }; 241 + 242 + // Check if we should rebundle during incremental builds 243 + // Rebundle if a changed file is either: 244 + // 1. A direct bundler input (entry point) 245 + // 2. A transitive dependency tracked in asset_to_routes (any file the bundler processed) 246 + let should_rebundle = if is_incremental && !build_state.bundler_inputs.is_empty() { 247 + let changed = changed_files.unwrap(); 248 + let should = changed.iter().any(|changed_file| { 249 + // Check if it's a direct bundler input 250 + let is_bundler_input = build_state.bundler_inputs.iter().any(|bundler_input| { 251 + if let (Ok(changed_canonical), Ok(bundler_canonical)) = ( 252 + changed_file.canonicalize(), 253 + PathBuf::from(bundler_input).canonicalize(), 254 + ) { 255 + changed_canonical == bundler_canonical 256 + } else { 257 + false 258 + } 259 + }); 260 + 261 + if is_bundler_input { 262 + return true; 263 + } 264 + 265 + // Check if it's a transitive dependency tracked by the bundler 266 + // (JS/TS modules, CSS files, or assets like images/fonts referenced via url()) 267 + if let Ok(canonical) = changed_file.canonicalize() { 268 + return build_state.asset_to_routes.contains_key(&canonical); 269 + } 270 + 271 + false 272 + }); 273 + 274 + if should { 275 + info!(target: "build", "Rebundling needed: changed file affects bundled assets"); 276 + } else { 277 + info!(target: "build", "Skipping bundler: no changed files affect bundled assets"); 278 + } 279 + 280 + should 281 + } else { 282 + // Not incremental or no previous bundler inputs 283 + false 284 + }; 285 + 286 + let clean_up_handle = if options.clean_output_dir && !is_incremental { 287 let old_dist_tmp_dir = { 288 let duration = SystemTime::now().duration_since(UNIX_EPOCH)?; 289 let num = (duration.as_secs() + duration.subsec_nanos() as u64) % 100000; ··· 300 }; 301 302 // Create the image cache early so it can be shared across routes 303 + let image_cache = ImageCache::with_cache_dir(options.assets_cache_dir()); 304 let _ = fs::create_dir_all(image_cache.get_cache_dir()); 305 306 // Create route_assets_options with the image cache ··· 310 311 let content_sources_start = Instant::now(); 312 print_title("initializing content sources"); 313 + 314 + // Determine which content sources need to be initialized 315 + // For incremental builds with specific routes to rebuild, only re-init sources whose files have changed 316 + // If routes_to_rebuild is None (full rebuild), always init all sources 317 + let sources_to_init: Option<FxHashSet<String>> = if routes_to_rebuild.is_some() { 318 + if let Some(changed) = changed_files { 319 + build_state.get_affected_content_sources(changed) 320 + } else { 321 + None // Full init 322 + } 323 + } else { 324 + None // Full init (routes_to_rebuild is None means full rebuild) 325 + }; 326 + 327 + // Initialize content sources (all or selective) 328 + let initialized_sources: Vec<String> = match &sources_to_init { 329 + Some(source_names) if !source_names.is_empty() => { 330 + info!(target: "content", "Selectively initializing {} content source(s): {:?}", source_names.len(), source_names); 331 + 332 + // Clear mappings for sources being re-initialized before init 333 + build_state.clear_content_mappings_for_sources(source_names); 334 + 335 + // Initialize only the affected sources 336 + let mut initialized = Vec::new(); 337 + for source in content_sources.sources_mut() { 338 + if source_names.contains(source.get_name()) { 339 + let source_start = Instant::now(); 340 + source.init(); 341 + info!(target: "content", "{} initialized in {}", source.get_name(), format_elapsed_time(source_start.elapsed(), &FormatElapsedTimeOptions::default())); 342 + initialized.push(source.get_name().to_string()); 343 + } else { 344 + info!(target: "content", "{} (unchanged, skipped)", source.get_name()); 345 + } 346 + } 347 + initialized 348 + } 349 + Some(_) => { 350 + // Empty set means no content files changed, skip all initialization 351 + info!(target: "content", "No content files changed, skipping content source initialization"); 352 + Vec::new() 353 + } 354 + None => { 355 + // Full initialization (first build, unknown files, or non-incremental) 356 + info!(target: "content", "Initializing all content sources"); 357 + 358 + // Clear all content mappings for full init 359 + build_state.clear_content_file_mappings(); 360 + build_state.content_file_to_source.clear(); 361 + 362 + let mut initialized = Vec::new(); 363 + for source in content_sources.sources_mut() { 364 + let source_start = Instant::now(); 365 + source.init(); 366 + info!(target: "content", "{} initialized in {}", source.get_name(), format_elapsed_time(source_start.elapsed(), &FormatElapsedTimeOptions::default())); 367 + initialized.push(source.get_name().to_string()); 368 + } 369 + initialized 370 + } 371 + }; 372 373 + // Track file->source mappings for all initialized sources 374 + for source in content_sources.sources() { 375 + if initialized_sources.contains(&source.get_name().to_string()) { 376 + let source_name = source.get_name().to_string(); 377 + for file_path in source.get_entry_file_paths() { 378 + build_state.track_content_file_source(file_path, source_name.clone()); 379 + } 380 + } 381 + } 382 383 info!(target: "content", "{}", format!("Content sources initialized in {}", format_elapsed_time( 384 content_sources_start.elapsed(), 385 &FormatElapsedTimeOptions::default(), 386 )).bold()); 387 388 + // Clear content file->routes mappings for routes being rebuilt 389 + // (so they get fresh tracking during this build) 390 + if let Some(ref routes) = routes_to_rebuild { 391 + build_state.clear_content_file_mappings_for_routes(routes); 392 + } 393 + 394 print_title("generating pages"); 395 let pages_start = Instant::now(); 396 ··· 478 479 // Static base route 480 if base_params.is_empty() { 481 + // Only create RouteIdentifier when incremental builds are enabled 482 + let route_id = if options.incremental { 483 + Some(RouteIdentifier::base(base_path.clone(), None)) 484 + } else { 485 + None 486 + }; 487 488 + // Check if we need to rebuild this route 489 + if should_rebuild_route(route_id.as_ref(), &routes_to_rebuild) { 490 + let mut route_assets = RouteAssets::with_default_assets( 491 + &route_assets_options, 492 + Some(image_cache.clone()), 493 + default_scripts.clone(), 494 + vec![], 495 + ); 496 497 + let params = PageParams::default(); 498 + let url = cached_route.url(&params); 499 + 500 + // Start tracking content file access for incremental builds 501 + if options.incremental { 502 + start_tracking_content_files(); 503 + } 504 + 505 + let result = route.build(&mut PageContext::from_static_route( 506 + content_sources, 507 + &mut route_assets, 508 + &url, 509 + &options.base_url, 510 + None, 511 + ))?; 512 + 513 + // Finish tracking and record accessed content files 514 + let accessed_files = if options.incremental { 515 + finish_tracking_content_files() 516 + } else { 517 + None 518 + }; 519 + 520 + let file_path = cached_route.file_path(&params, &options.output_dir); 521 522 + write_route_file(&result, &file_path)?; 523 524 + info!(target: "pages", "{} -> {} {}", url, file_path.to_string_lossy().dimmed(), format_elapsed_time(route_start.elapsed(), &route_format_options)); 525 526 + // Track assets, source file, and content files for this route 527 + track_route_assets(&mut build_state, route_id.as_ref(), &route_assets); 528 + track_route_source_file(&mut build_state, route_id.as_ref(), route.source_file()); 529 + track_route_content_files(&mut build_state, route_id.as_ref(), accessed_files); 530 531 + build_pages_images.extend(route_assets.images); 532 + build_pages_scripts.extend(route_assets.scripts); 533 + build_pages_styles.extend(route_assets.styles); 534 535 + build_metadata.add_page( 536 + base_path.clone(), 537 + file_path.to_string_lossy().to_string(), 538 + None, 539 + ); 540 541 + add_sitemap_entry( 542 + &mut sitemap_entries, 543 + normalized_base_url, 544 + &url, 545 + base_path, 546 + &route.sitemap_metadata(), 547 + &options.sitemap, 548 + ); 549 550 + page_count += 1; 551 + } else { 552 + trace!(target: "build", "Skipping unchanged route: {}", base_path); 553 + } 554 } else { 555 // Dynamic base route 556 let mut route_assets = RouteAssets::with_default_assets( ··· 574 575 // Build all pages for this route 576 for page in pages { 577 + // Only create RouteIdentifier when incremental builds are enabled 578 + let route_id = if options.incremental { 579 + Some(RouteIdentifier::base(base_path.clone(), Some(page.0.0.clone()))) 580 + } else { 581 + None 582 + }; 583 + 584 + // Check if we need to rebuild this specific page 585 + if should_rebuild_route(route_id.as_ref(), &routes_to_rebuild) { 586 + let page_start = Instant::now(); 587 + let url = cached_route.url(&page.0); 588 + let file_path = cached_route.file_path(&page.0, &options.output_dir); 589 + 590 + // Start tracking content file access for incremental builds 591 + if options.incremental { 592 + start_tracking_content_files(); 593 + } 594 + 595 + let content = route.build(&mut PageContext::from_dynamic_route( 596 + &page, 597 + content_sources, 598 + &mut route_assets, 599 + &url, 600 + &options.base_url, 601 + None, 602 + ))?; 603 + 604 + // Finish tracking and record accessed content files 605 + let accessed_files = if options.incremental { 606 + finish_tracking_content_files() 607 + } else { 608 + None 609 + }; 610 611 + write_route_file(&content, &file_path)?; 612 613 + info!(target: "pages", "โ”œโ”€ {} {}", file_path.to_string_lossy().dimmed(), format_elapsed_time(page_start.elapsed(), &route_format_options)); 614 615 + // Track assets, source file, and content files for this page 616 + track_route_assets(&mut build_state, route_id.as_ref(), &route_assets); 617 + track_route_source_file(&mut build_state, route_id.as_ref(), route.source_file()); 618 + track_route_content_files(&mut build_state, route_id.as_ref(), accessed_files); 619 620 + build_metadata.add_page( 621 + base_path.clone(), 622 + file_path.to_string_lossy().to_string(), 623 + Some(page.0.0.clone()), 624 + ); 625 626 + add_sitemap_entry( 627 + &mut sitemap_entries, 628 + normalized_base_url, 629 + &url, 630 + base_path, 631 + &route.sitemap_metadata(), 632 + &options.sitemap, 633 + ); 634 635 + page_count += 1; 636 + } else { 637 + trace!(target: "build", "Skipping unchanged page: {} with params {:?}", base_path, page.0.0); 638 + } 639 } 640 } 641 ··· 652 653 if variant_params.is_empty() { 654 // Static variant 655 + // Only create RouteIdentifier when incremental builds are enabled 656 + let route_id = if options.incremental { 657 + Some(RouteIdentifier::variant(variant_id.clone(), variant_path.clone(), None)) 658 + } else { 659 + None 660 + }; 661 662 + // Check if we need to rebuild this variant 663 + if should_rebuild_route(route_id.as_ref(), &routes_to_rebuild) { 664 + let mut route_assets = RouteAssets::with_default_assets( 665 + &route_assets_options, 666 + Some(image_cache.clone()), 667 + default_scripts.clone(), 668 + vec![], 669 + ); 670 671 + let params = PageParams::default(); 672 + let url = cached_route.variant_url(&params, &variant_id)?; 673 + let file_path = cached_route.variant_file_path( 674 + &params, 675 + &options.output_dir, 676 + &variant_id, 677 + )?; 678 + 679 + // Start tracking content file access for incremental builds 680 + if options.incremental { 681 + start_tracking_content_files(); 682 + } 683 + 684 + let result = route.build(&mut PageContext::from_static_route( 685 + content_sources, 686 + &mut route_assets, 687 + &url, 688 + &options.base_url, 689 + Some(variant_id.clone()), 690 + ))?; 691 + 692 + // Finish tracking and record accessed content files 693 + let accessed_files = if options.incremental { 694 + finish_tracking_content_files() 695 + } else { 696 + None 697 + }; 698 699 + write_route_file(&result, &file_path)?; 700 701 + info!(target: "pages", "โ”œโ”€ {} {}", file_path.to_string_lossy().dimmed(), format_elapsed_time(variant_start.elapsed(), &route_format_options)); 702 703 + // Track assets, source file, and content files for this variant 704 + track_route_assets(&mut build_state, route_id.as_ref(), &route_assets); 705 + track_route_source_file(&mut build_state, route_id.as_ref(), route.source_file()); 706 + track_route_content_files(&mut build_state, route_id.as_ref(), accessed_files); 707 708 + build_pages_images.extend(route_assets.images); 709 + build_pages_scripts.extend(route_assets.scripts); 710 + build_pages_styles.extend(route_assets.styles); 711 + 712 + build_metadata.add_page( 713 + variant_path.clone(), 714 + file_path.to_string_lossy().to_string(), 715 + None, 716 + ); 717 718 + add_sitemap_entry( 719 + &mut sitemap_entries, 720 + normalized_base_url, 721 + &url, 722 + &variant_path, 723 + &route.sitemap_metadata(), 724 + &options.sitemap, 725 + ); 726 727 + page_count += 1; 728 + } else { 729 + trace!(target: "build", "Skipping unchanged variant: {}", variant_path); 730 + } 731 } else { 732 // Dynamic variant 733 let mut route_assets = RouteAssets::with_default_assets( ··· 750 751 // Build all pages for this variant group 752 for page in pages { 753 + // Only create RouteIdentifier when incremental builds are enabled 754 + let route_id = if options.incremental { 755 + Some(RouteIdentifier::variant( 756 + variant_id.clone(), 757 + variant_path.clone(), 758 + Some(page.0.0.clone()), 759 + )) 760 + } else { 761 + None 762 + }; 763 + 764 + // Check if we need to rebuild this specific variant page 765 + if should_rebuild_route(route_id.as_ref(), &routes_to_rebuild) { 766 + let variant_page_start = Instant::now(); 767 + let url = cached_route.variant_url(&page.0, &variant_id)?; 768 + let file_path = cached_route.variant_file_path( 769 + &page.0, 770 + &options.output_dir, 771 + &variant_id, 772 + )?; 773 + 774 + // Start tracking content file access for incremental builds 775 + if options.incremental { 776 + start_tracking_content_files(); 777 + } 778 + 779 + let content = route.build(&mut PageContext::from_dynamic_route( 780 + &page, 781 + content_sources, 782 + &mut route_assets, 783 + &url, 784 + &options.base_url, 785 + Some(variant_id.clone()), 786 + ))?; 787 + 788 + // Finish tracking and record accessed content files 789 + let accessed_files = if options.incremental { 790 + finish_tracking_content_files() 791 + } else { 792 + None 793 + }; 794 795 + write_route_file(&content, &file_path)?; 796 797 + info!(target: "pages", "โ”‚ โ”œโ”€ {} {}", file_path.to_string_lossy().dimmed(), format_elapsed_time(variant_page_start.elapsed(), &route_format_options)); 798 799 + // Track assets, source file, and content files for this variant page 800 + track_route_assets(&mut build_state, route_id.as_ref(), &route_assets); 801 + track_route_source_file(&mut build_state, route_id.as_ref(), route.source_file()); 802 + track_route_content_files(&mut build_state, route_id.as_ref(), accessed_files); 803 804 + build_metadata.add_page( 805 + variant_path.clone(), 806 + file_path.to_string_lossy().to_string(), 807 + Some(page.0.0.clone()), 808 + ); 809 810 + add_sitemap_entry( 811 + &mut sitemap_entries, 812 + normalized_base_url, 813 + &url, 814 + &variant_path, 815 + &route.sitemap_metadata(), 816 + &options.sitemap, 817 + ); 818 819 + page_count += 1; 820 + } else { 821 + trace!(target: "build", "Skipping unchanged variant page: {} with params {:?}", variant_path, page.0.0); 822 + } 823 } 824 } 825 ··· 839 fs::create_dir_all(&route_assets_options.output_assets_dir)?; 840 } 841 842 + if !build_pages_styles.is_empty() 843 + || !build_pages_scripts.is_empty() 844 + || (is_incremental && should_rebundle) 845 + { 846 let assets_start = Instant::now(); 847 print_title("generating assets"); 848 ··· 860 }) 861 .collect::<Vec<InputItem>>(); 862 863 + let mut bundler_inputs = build_pages_scripts 864 .iter() 865 .map(|script| InputItem { 866 import: script.path().to_string_lossy().to_string(), ··· 875 .chain(css_inputs.into_iter()) 876 .collect::<Vec<InputItem>>(); 877 878 + // During incremental builds, merge with previous bundler inputs 879 + // to ensure we bundle all assets, not just from rebuilt routes 880 + if is_incremental && !build_state.bundler_inputs.is_empty() { 881 + debug!(target: "bundling", "Merging with {} previous bundler inputs", build_state.bundler_inputs.len()); 882 + 883 + let current_imports: FxHashSet<String> = bundler_inputs 884 + .iter() 885 + .map(|input| input.import.clone()) 886 + .collect(); 887 + 888 + // Add previous inputs that aren't in the current set 889 + for prev_input in &build_state.bundler_inputs { 890 + if !current_imports.contains(prev_input) { 891 + bundler_inputs.push(InputItem { 892 + import: prev_input.clone(), 893 + name: Some( 894 + PathBuf::from(prev_input) 895 + .file_stem() 896 + .unwrap_or_default() 897 + .to_string_lossy() 898 + .to_string(), 899 + ), 900 + }); 901 + } 902 + } 903 + } 904 + 905 debug!( 906 target: "bundling", 907 "Bundler inputs: {:?}", ··· 911 .collect::<Vec<String>>() 912 ); 913 914 + // Store bundler inputs in build state for next incremental build 915 + if options.incremental { 916 + build_state.bundler_inputs = bundler_inputs 917 + .iter() 918 + .map(|input| input.import.clone()) 919 + .collect(); 920 + } 921 + 922 if !bundler_inputs.is_empty() { 923 let mut module_types_hashmap = FxHashMap::default(); 924 + // Fonts 925 module_types_hashmap.insert("woff".to_string(), ModuleType::Asset); 926 module_types_hashmap.insert("woff2".to_string(), ModuleType::Asset); 927 + module_types_hashmap.insert("ttf".to_string(), ModuleType::Asset); 928 + module_types_hashmap.insert("otf".to_string(), ModuleType::Asset); 929 + module_types_hashmap.insert("eot".to_string(), ModuleType::Asset); 930 + // Images 931 + module_types_hashmap.insert("png".to_string(), ModuleType::Asset); 932 + module_types_hashmap.insert("jpg".to_string(), ModuleType::Asset); 933 + module_types_hashmap.insert("jpeg".to_string(), ModuleType::Asset); 934 + module_types_hashmap.insert("gif".to_string(), ModuleType::Asset); 935 + module_types_hashmap.insert("svg".to_string(), ModuleType::Asset); 936 + module_types_hashmap.insert("webp".to_string(), ModuleType::Asset); 937 + module_types_hashmap.insert("avif".to_string(), ModuleType::Asset); 938 + module_types_hashmap.insert("ico".to_string(), ModuleType::Asset); 939 940 let mut bundler = Bundler::with_plugins( 941 BundlerOptions { ··· 965 .collect::<Vec<PathBuf>>(), 966 }), 967 Arc::new(PrefetchPlugin {}), 968 + Arc::new(ReplacePlugin::new(FxHashMap::default())?), 969 ], 970 )?; 971 972 + let result = bundler.write().await?; 973 974 + // Track transitive dependencies from bundler output 975 + // For each chunk, map all its modules to the routes that use the entry point 976 + // For assets (images, fonts via CSS url()), map them to all routes using any entry point 977 + if options.incremental { 978 + // First, collect all routes that use any bundler entry point 979 + let mut all_bundler_routes: FxHashSet<RouteIdentifier> = FxHashSet::default(); 980 + 981 + for output in &result.assets { 982 + if let Output::Chunk(chunk) = output { 983 + // Get the entry point for this chunk 984 + if let Some(facade_module_id) = &chunk.facade_module_id { 985 + // Try to find routes using this entry point 986 + let entry_path = PathBuf::from(facade_module_id.as_str()); 987 + let canonical_entry = entry_path.canonicalize().ok(); 988 + 989 + // Look up routes for this entry point 990 + let routes = canonical_entry 991 + .as_ref() 992 + .and_then(|p| build_state.asset_to_routes.get(p)) 993 + .cloned(); 994 + 995 + if let Some(routes) = routes { 996 + // Collect routes for asset tracking later 997 + all_bundler_routes.extend(routes.iter().cloned()); 998 + 999 + // Register all modules in this chunk as dependencies for those routes 1000 + let mut transitive_count = 0; 1001 + for module_id in &chunk.module_ids { 1002 + let module_path = PathBuf::from(module_id.as_str()); 1003 + if let Ok(canonical_module) = module_path.canonicalize() { 1004 + // Skip the entry point itself (already tracked) 1005 + if Some(&canonical_module) != canonical_entry.as_ref() { 1006 + for route in &routes { 1007 + build_state.track_asset( 1008 + canonical_module.clone(), 1009 + route.clone(), 1010 + ); 1011 + } 1012 + transitive_count += 1; 1013 + } 1014 + } 1015 + } 1016 + if transitive_count > 0 { 1017 + debug!(target: "build", "Tracked {} transitive dependencies for {}", transitive_count, facade_module_id); 1018 + } 1019 + } 1020 + } 1021 + } 1022 + } 1023 + 1024 + // Now track Output::Asset items (images, fonts, etc. referenced via CSS url() or JS imports) 1025 + // These are mapped to all routes that use any bundler entry point 1026 + if !all_bundler_routes.is_empty() { 1027 + let mut asset_count = 0; 1028 + for output in &result.assets { 1029 + if let Output::Asset(asset) = output { 1030 + for original_file in &asset.original_file_names { 1031 + let asset_path = PathBuf::from(original_file); 1032 + if let Ok(canonical_asset) = asset_path.canonicalize() { 1033 + for route in &all_bundler_routes { 1034 + build_state 1035 + .track_asset(canonical_asset.clone(), route.clone()); 1036 + } 1037 + asset_count += 1; 1038 + } 1039 + } 1040 + } 1041 + } 1042 + if asset_count > 0 { 1043 + debug!(target: "build", "Tracked {} bundler assets for {} routes", asset_count, all_bundler_routes.len()); 1044 + } 1045 + } 1046 + } 1047 } 1048 1049 info!(target: "build", "{}", format!("Assets generated in {}", format_elapsed_time(assets_start.elapsed(), &section_format_options)).bold()); ··· 1139 info!(target: "SKIP_FORMAT", "{}", ""); 1140 info!(target: "build", "{}", format!("Build completed in {}", format_elapsed_time(build_start.elapsed(), &section_format_options)).bold()); 1141 1142 + // Save build state for next incremental build (only if incremental is enabled) 1143 + if options.incremental { 1144 + if let Err(e) = build_state.save(build_cache_dir) { 1145 + warn!(target: "build", "Failed to save build state: {}", e); 1146 + } else { 1147 + debug!(target: "build", "Build state saved to {}", build_cache_dir.join("build_state.json").display()); 1148 + } 1149 + } 1150 + 1151 if let Some(clean_up_handle) = clean_up_handle { 1152 clean_up_handle.await?; 1153 } ··· 1230 fs::create_dir_all(parent_dir)? 1231 } 1232 1233 + trace!(target: "build", "Writing HTML file: {}", file_path.display()); 1234 fs::write(file_path, content)?; 1235 1236 Ok(())
+3
crates/maudit/src/content/markdown/components.rs
··· 48 ShortcutUnknown, 49 Autolink, 50 Email, 51 } 52 53 impl From<pulldown_cmark::LinkType> for LinkType { ··· 62 pulldown_cmark::LinkType::ShortcutUnknown => LinkType::ShortcutUnknown, 63 pulldown_cmark::LinkType::Autolink => LinkType::Autolink, 64 pulldown_cmark::LinkType::Email => LinkType::Email, 65 } 66 } 67 } ··· 84 LinkType::ShortcutUnknown => "shortcut_unknown", 85 LinkType::Autolink => "autolink", 86 LinkType::Email => "email", 87 } 88 } 89 }
··· 48 ShortcutUnknown, 49 Autolink, 50 Email, 51 + WikiLink(bool), 52 } 53 54 impl From<pulldown_cmark::LinkType> for LinkType { ··· 63 pulldown_cmark::LinkType::ShortcutUnknown => LinkType::ShortcutUnknown, 64 pulldown_cmark::LinkType::Autolink => LinkType::Autolink, 65 pulldown_cmark::LinkType::Email => LinkType::Email, 66 + pulldown_cmark::LinkType::WikiLink { has_pothole } => LinkType::WikiLink(has_pothole), 67 } 68 } 69 } ··· 86 LinkType::ShortcutUnknown => "shortcut_unknown", 87 LinkType::Autolink => "autolink", 88 LinkType::Email => "email", 89 + LinkType::WikiLink(_) => "wikilink", 90 } 91 } 92 }
+110 -6
crates/maudit/src/content.rs
··· 1 //! Core functions and structs to define the content sources of your website. 2 //! 3 //! Content sources represent the content of your website, such as articles, blog posts, etc. Then, content sources can be passed to [`coronate()`](crate::coronate), through the [`content_sources!`](crate::content_sources) macro, to be loaded. 4 - use std::{any::Any, path::PathBuf, sync::Arc}; 5 6 - use rustc_hash::FxHashMap; 7 8 mod highlight; 9 pub mod markdown; ··· 25 }; 26 27 pub use highlight::{HighlightOptions, highlight_code}; 28 29 /// Helps implement a struct as a Markdown content entry. 30 /// ··· 302 } 303 } 304 305 pub fn get_untyped_source(&self, name: &str) -> &ContentSource<Untyped> { 306 self.get_source::<Untyped>(name) 307 } ··· 337 /// A source of content such as articles, blog posts, etc. 338 pub struct ContentSource<T = Untyped> { 339 pub name: String, 340 - pub entries: Vec<Arc<EntryInner<T>>>, 341 pub(crate) init_method: ContentSourceInitMethod<T>, 342 } 343 ··· 354 } 355 356 pub fn get_entry(&self, id: &str) -> &Entry<T> { 357 - self.entries 358 .iter() 359 .find(|entry| entry.id == id) 360 - .unwrap_or_else(|| panic!("Entry with id '{}' not found", id)) 361 } 362 363 pub fn get_entry_safe(&self, id: &str) -> Option<&Entry<T>> { 364 - self.entries.iter().find(|entry| entry.id == id) 365 } 366 367 pub fn into_params<P>(&self, cb: impl FnMut(&Entry<T>) -> P) -> Vec<P> 368 where 369 P: Into<PageParams>, 370 { 371 self.entries.iter().map(cb).collect() 372 } 373 ··· 378 where 379 Params: Into<PageParams>, 380 { 381 self.entries.iter().map(cb).collect() 382 } 383 } 384 385 #[doc(hidden)] ··· 389 fn init(&mut self); 390 fn get_name(&self) -> &str; 391 fn as_any(&self) -> &dyn Any; // Used for type checking at runtime 392 } 393 394 impl<T: 'static + Sync + Send> ContentSourceInternal for ContentSource<T> { ··· 400 } 401 fn as_any(&self) -> &dyn Any { 402 self 403 } 404 }
··· 1 //! Core functions and structs to define the content sources of your website. 2 //! 3 //! Content sources represent the content of your website, such as articles, blog posts, etc. Then, content sources can be passed to [`coronate()`](crate::coronate), through the [`content_sources!`](crate::content_sources) macro, to be loaded. 4 + use std::{ 5 + any::Any, 6 + cell::RefCell, 7 + path::{Path, PathBuf}, 8 + sync::Arc, 9 + }; 10 11 + use rustc_hash::{FxHashMap, FxHashSet}; 12 13 mod highlight; 14 pub mod markdown; ··· 30 }; 31 32 pub use highlight::{HighlightOptions, highlight_code}; 33 + 34 + // Thread-local storage for tracking content file access during page rendering. 35 + // This allows us to transparently track which content files a page uses 36 + // without requiring changes to user code. 37 + thread_local! { 38 + static ACCESSED_CONTENT_FILES: RefCell<Option<FxHashSet<PathBuf>>> = const { RefCell::new(None) }; 39 + } 40 + 41 + /// Start tracking content file access for a page render. 42 + /// Call this before rendering a page, then call `finish_tracking_content_files()` 43 + /// after rendering to get the set of accessed content files. 44 + pub(crate) fn start_tracking_content_files() { 45 + ACCESSED_CONTENT_FILES.with(|cell| { 46 + *cell.borrow_mut() = Some(FxHashSet::default()); 47 + }); 48 + } 49 + 50 + /// Finish tracking content file access and return the set of accessed files. 51 + /// Returns `None` if tracking was not started. 52 + pub(crate) fn finish_tracking_content_files() -> Option<FxHashSet<PathBuf>> { 53 + ACCESSED_CONTENT_FILES.with(|cell| cell.borrow_mut().take()) 54 + } 55 + 56 + /// Record that a content file was accessed. 57 + /// This is called internally when entries are accessed. 58 + fn track_content_file_access(file_path: &Path) { 59 + ACCESSED_CONTENT_FILES.with(|cell| { 60 + if let Some(ref mut set) = *cell.borrow_mut() { 61 + set.insert(file_path.to_path_buf()); 62 + } 63 + }); 64 + } 65 66 /// Helps implement a struct as a Markdown content entry. 67 /// ··· 339 } 340 } 341 342 + /// Initialize only the content sources with the given names. 343 + /// Sources not in the set are left untouched (their entries remain as-is). 344 + /// Returns the names of sources that were actually initialized. 345 + pub fn init_sources(&mut self, source_names: &rustc_hash::FxHashSet<String>) -> Vec<String> { 346 + let mut initialized = Vec::new(); 347 + for source in &mut self.0 { 348 + if source_names.contains(source.get_name()) { 349 + source.init(); 350 + initialized.push(source.get_name().to_string()); 351 + } 352 + } 353 + initialized 354 + } 355 + 356 pub fn get_untyped_source(&self, name: &str) -> &ContentSource<Untyped> { 357 self.get_source::<Untyped>(name) 358 } ··· 388 /// A source of content such as articles, blog posts, etc. 389 pub struct ContentSource<T = Untyped> { 390 pub name: String, 391 + entries: Vec<Arc<EntryInner<T>>>, 392 pub(crate) init_method: ContentSourceInitMethod<T>, 393 } 394 ··· 405 } 406 407 pub fn get_entry(&self, id: &str) -> &Entry<T> { 408 + let entry = self 409 + .entries 410 .iter() 411 .find(|entry| entry.id == id) 412 + .unwrap_or_else(|| panic!("Entry with id '{}' not found", id)); 413 + 414 + // Track file access for incremental builds 415 + if let Some(ref file_path) = entry.file_path { 416 + track_content_file_access(file_path); 417 + } 418 + 419 + entry 420 } 421 422 pub fn get_entry_safe(&self, id: &str) -> Option<&Entry<T>> { 423 + let entry = self.entries.iter().find(|entry| entry.id == id); 424 + 425 + // Track file access for incremental builds 426 + if let Some(entry) = &entry 427 + && let Some(ref file_path) = entry.file_path 428 + { 429 + track_content_file_access(file_path); 430 + } 431 + 432 + entry 433 } 434 435 pub fn into_params<P>(&self, cb: impl FnMut(&Entry<T>) -> P) -> Vec<P> 436 where 437 P: Into<PageParams>, 438 { 439 + // Track all entries accessed for incremental builds 440 + for entry in &self.entries { 441 + if let Some(ref file_path) = entry.file_path { 442 + track_content_file_access(file_path); 443 + } 444 + } 445 self.entries.iter().map(cb).collect() 446 } 447 ··· 452 where 453 Params: Into<PageParams>, 454 { 455 + // Track all entries accessed for incremental builds 456 + for entry in &self.entries { 457 + if let Some(ref file_path) = entry.file_path { 458 + track_content_file_access(file_path); 459 + } 460 + } 461 self.entries.iter().map(cb).collect() 462 } 463 + 464 + /// Get all entries, tracking access for incremental builds. 465 + /// 466 + /// This returns a slice of all entries in the content source. 467 + /// You can use standard slice methods like `.iter()`, `.len()`, `.is_empty()`, etc. 468 + pub fn entries(&self) -> &[Entry<T>] { 469 + // Track all entries accessed for incremental builds 470 + for entry in &self.entries { 471 + if let Some(ref file_path) = entry.file_path { 472 + track_content_file_access(file_path); 473 + } 474 + } 475 + &self.entries 476 + } 477 } 478 479 #[doc(hidden)] ··· 483 fn init(&mut self); 484 fn get_name(&self) -> &str; 485 fn as_any(&self) -> &dyn Any; // Used for type checking at runtime 486 + 487 + /// Get all file paths for entries in this content source. 488 + /// Used for incremental builds to map content files to their source. 489 + fn get_entry_file_paths(&self) -> Vec<PathBuf>; 490 } 491 492 impl<T: 'static + Sync + Send> ContentSourceInternal for ContentSource<T> { ··· 498 } 499 fn as_any(&self) -> &dyn Any { 500 self 501 + } 502 + fn get_entry_file_paths(&self) -> Vec<PathBuf> { 503 + self.entries 504 + .iter() 505 + .filter_map(|entry| entry.file_path.clone()) 506 + .collect() 507 } 508 }
+6
crates/maudit/src/errors.rs
··· 53 #[source] 54 source: std::io::Error, 55 }, 56 } 57 58 #[derive(Error, Debug)]
··· 53 #[source] 54 source: std::io::Error, 55 }, 56 + #[error("Failed to load image for placeholder generation: {path}")] 57 + ImageLoadFailed { 58 + path: PathBuf, 59 + #[source] 60 + source: image::ImageError, 61 + }, 62 } 63 64 #[derive(Error, Debug)]
+22 -3
crates/maudit/src/lib.rs
··· 54 // Internal modules 55 mod logging; 56 57 - use std::env; 58 59 use build::execute_build; 60 use content::ContentSources; 61 use logging::init_logging; 62 use route::FullRoute; 63 64 /// Returns whether Maudit is running in development mode (through `maudit dev`). 65 /// 66 /// This can be useful to conditionally enable features or logging that should only be active during development. 67 /// Oftentimes, this is used to disable some expensive operations that would slow down build times during development. 68 pub fn is_dev() -> bool { 69 - env::var("MAUDIT_DEV").map(|v| v == "true").unwrap_or(false) 70 } 71 72 #[macro_export] ··· 212 .enable_all() 213 .build()?; 214 215 - execute_build(routes, &mut content_sources, &options, &async_runtime) 216 }
··· 54 // Internal modules 55 mod logging; 56 57 + use std::sync::LazyLock; 58 + use std::{env, path::PathBuf}; 59 60 use build::execute_build; 61 use content::ContentSources; 62 use logging::init_logging; 63 use route::FullRoute; 64 65 + static IS_DEV: LazyLock<bool> = LazyLock::new(|| { 66 + std::env::var("MAUDIT_DEV") 67 + .map(|v| v == "true") 68 + .unwrap_or(false) 69 + }); 70 + 71 /// Returns whether Maudit is running in development mode (through `maudit dev`). 72 /// 73 /// This can be useful to conditionally enable features or logging that should only be active during development. 74 /// Oftentimes, this is used to disable some expensive operations that would slow down build times during development. 75 pub fn is_dev() -> bool { 76 + *IS_DEV 77 } 78 79 #[macro_export] ··· 219 .enable_all() 220 .build()?; 221 222 + // Check for changed files from environment variable (set by CLI in dev mode) 223 + let changed_files = env::var("MAUDIT_CHANGED_FILES") 224 + .ok() 225 + .and_then(|s| serde_json::from_str::<Vec<String>>(&s).ok()) 226 + .map(|paths| paths.into_iter().map(PathBuf::from).collect::<Vec<_>>()); 227 + 228 + execute_build( 229 + routes, 230 + &mut content_sources, 231 + &options, 232 + changed_files.as_deref(), 233 + &async_runtime, 234 + ) 235 }
+6 -2
crates/maudit/src/logging.rs
··· 29 30 let _ = Builder::from_env(logging_env) 31 .format(|buf, record| { 32 - if std::env::args().any(|arg| arg == "--quiet") || std::env::var("MAUDIT_QUIET").is_ok() 33 - { 34 return Ok(()); 35 } 36
··· 29 30 let _ = Builder::from_env(logging_env) 31 .format(|buf, record| { 32 + if std::env::args().any(|arg| arg == "--quiet") { 33 + return Ok(()); 34 + } 35 + 36 + // In quiet mode, only show build target logs (for debugging incremental builds) 37 + if std::env::var("MAUDIT_QUIET").is_ok() && record.target() != "build" { 38 return Ok(()); 39 } 40
+18 -5
crates/maudit/src/route.rs
··· 9 use std::any::Any; 10 use std::path::{Path, PathBuf}; 11 12 - use lol_html::{RewriteStrSettings, element, rewrite_str}; 13 14 /// The result of a page render, can be either text, raw bytes, or an error. 15 /// ··· 282 /// impl Route for Index { 283 /// fn render(&self, ctx: &mut PageContext) -> impl Into<RenderResult> { 284 /// let logo = ctx.assets.add_image("logo.png")?; 285 - /// let last_entries = &ctx.content.get_source::<ArticleContent>("articles").entries; 286 /// 287 /// Ok(html! { 288 /// main { ··· 504 pub trait InternalRoute { 505 fn route_raw(&self) -> Option<String>; 506 507 fn variants(&self) -> Vec<(String, String)> { 508 vec![] 509 } ··· 796 self.inner.route_raw() 797 } 798 799 fn variants(&self) -> Vec<(String, String)> { 800 self.inner.variants() 801 } ··· 957 //! use maudit::route::prelude::*; 958 //! ``` 959 pub use super::{ 960 - CachedRoute, DynamicRouteContext, FullRoute, Page, PageContext, PageParams, Pages, 961 - PaginatedContentPage, PaginationPage, RenderResult, Route, RouteExt, paginate, redirect, 962 }; 963 pub use crate::assets::{ 964 Asset, Image, ImageFormat, ImageOptions, ImagePlaceholder, RenderWithAlt, Script, Style, 965 StyleOptions, 966 }; 967 pub use crate::content::{ContentContext, ContentEntry, Entry, EntryInner, MarkdownContent}; 968 - pub use maudit_macros::{Params, route}; 969 } 970 971 #[cfg(test)] ··· 982 impl InternalRoute for TestPage { 983 fn route_raw(&self) -> Option<String> { 984 Some(self.route.clone()) 985 } 986 } 987
··· 9 use std::any::Any; 10 use std::path::{Path, PathBuf}; 11 12 + use lol_html::{element, rewrite_str, RewriteStrSettings}; 13 14 /// The result of a page render, can be either text, raw bytes, or an error. 15 /// ··· 282 /// impl Route for Index { 283 /// fn render(&self, ctx: &mut PageContext) -> impl Into<RenderResult> { 284 /// let logo = ctx.assets.add_image("logo.png")?; 285 + /// let last_entries = ctx.content.get_source::<ArticleContent>("articles").entries(); 286 /// 287 /// Ok(html! { 288 /// main { ··· 504 pub trait InternalRoute { 505 fn route_raw(&self) -> Option<String>; 506 507 + /// Returns the source file path where this route is defined. 508 + /// This is used for incremental builds to track which routes are affected 509 + /// when a source file changes. 510 + fn source_file(&self) -> &'static str; 511 + 512 fn variants(&self) -> Vec<(String, String)> { 513 vec![] 514 } ··· 801 self.inner.route_raw() 802 } 803 804 + fn source_file(&self) -> &'static str { 805 + self.inner.source_file() 806 + } 807 + 808 fn variants(&self) -> Vec<(String, String)> { 809 self.inner.variants() 810 } ··· 966 //! use maudit::route::prelude::*; 967 //! ``` 968 pub use super::{ 969 + paginate, redirect, CachedRoute, DynamicRouteContext, FullRoute, Page, PageContext, 970 + PageParams, Pages, PaginatedContentPage, PaginationPage, RenderResult, Route, RouteExt, 971 }; 972 pub use crate::assets::{ 973 Asset, Image, ImageFormat, ImageOptions, ImagePlaceholder, RenderWithAlt, Script, Style, 974 StyleOptions, 975 }; 976 pub use crate::content::{ContentContext, ContentEntry, Entry, EntryInner, MarkdownContent}; 977 + pub use maudit_macros::{route, Params}; 978 } 979 980 #[cfg(test)] ··· 991 impl InternalRoute for TestPage { 992 fn route_raw(&self) -> Option<String> { 993 Some(self.route.clone()) 994 + } 995 + 996 + fn source_file(&self) -> &'static str { 997 + file!() 998 } 999 } 1000
+17 -1
crates/maudit/src/routing.rs
··· 56 57 #[cfg(test)] 58 mod tests { 59 - use crate::routing::{ParameterDef, extract_params_from_raw_route}; 60 61 #[test] 62 fn test_extract_params() { ··· 123 }]; 124 125 assert_eq!(extract_params_from_raw_route(input), expected); 126 } 127 }
··· 56 57 #[cfg(test)] 58 mod tests { 59 + use crate::routing::{ParameterDef, extract_params_from_raw_route, guess_if_route_is_endpoint}; 60 61 #[test] 62 fn test_extract_params() { ··· 123 }]; 124 125 assert_eq!(extract_params_from_raw_route(input), expected); 126 + } 127 + 128 + #[test] 129 + fn test_guess_if_route_is_endpoint() { 130 + // Routes with file extensions should be detected as endpoints 131 + assert!(guess_if_route_is_endpoint("/api/data.json")); 132 + assert!(guess_if_route_is_endpoint("/feed.xml")); 133 + assert!(guess_if_route_is_endpoint("/sitemap.xml")); 134 + assert!(guess_if_route_is_endpoint("/robots.txt")); 135 + assert!(guess_if_route_is_endpoint("/path/to/file.tar.gz")); 136 + assert!(guess_if_route_is_endpoint("/api/users/[id].json")); 137 + 138 + assert!(!guess_if_route_is_endpoint("/")); 139 + assert!(!guess_if_route_is_endpoint("/articles")); 140 + assert!(!guess_if_route_is_endpoint("/articles/[slug]")); 141 + assert!(!guess_if_route_is_endpoint("/blog/posts/[year]/[month]")); 142 } 143 }
+20 -14
crates/maudit-cli/Cargo.toml
··· 11 path = "src/main.rs" 12 13 [dependencies] 14 - chrono = "0.4.39" 15 - colored = "2.2.0" 16 - clap = { version = "4.5.23", features = ["derive"] } 17 tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal", "process"] } 18 - axum = { version = "0.8.6", features = ["ws"] } 19 futures = "0.3" 20 - tower-http = { version = "0.6.6", features = ["fs", "trace"] } 21 tracing = "0.1" 22 tracing-subscriber = { version = "=0.3.19", features = ["env-filter", "chrono"] } 23 notify = "8.2.0" 24 - notify-debouncer-full = "0.6.0" 25 - inquire = "0.7.5" 26 - rand = "0.9.0" 27 spinach = "3" 28 - ureq = "3.0.5" 29 - tar = "0.4.43" 30 - toml_edit = "0.22.23" 31 - local-ip-address = "0.6.3" 32 - flate2 = "1.0.35" 33 quanta = "0.12.6" 34 serde_json = "1.0" 35 tokio-util = "0.7" 36 - cargo_metadata = "0.23.0"
··· 11 path = "src/main.rs" 12 13 [dependencies] 14 + chrono = "0.4.43" 15 + colored = "3.1.1" 16 + clap = { version = "4.5.54", features = ["derive"] } 17 tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal", "process"] } 18 + axum = { version = "0.8.8", features = ["ws"] } 19 futures = "0.3" 20 + tower-http = { version = "0.6.8", features = ["fs", "trace"] } 21 tracing = "0.1" 22 tracing-subscriber = { version = "=0.3.19", features = ["env-filter", "chrono"] } 23 notify = "8.2.0" 24 + notify-debouncer-full = "0.7.0" 25 + inquire = "0.9.2" 26 + rand = "0.9.2" 27 spinach = "3" 28 + ureq = "3.1.4" 29 + tar = "0.4.44" 30 + toml_edit = "0.24.0" 31 + toml = "0.8" 32 + local-ip-address = "0.6.9" 33 + flate2 = "1.1.8" 34 quanta = "0.12.6" 35 serde_json = "1.0" 36 tokio-util = "0.7" 37 + cargo_metadata = "0.23.1" 38 + depinfo = "0.7.3" 39 + 40 + [dev-dependencies] 41 + tempfile = "3.24.0" 42 + tokio = { version = "1", features = ["macros", "rt-multi-thread", "test-util"] }
+521 -149
crates/maudit-cli/src/dev/build.rs
··· 1 use cargo_metadata::Message; 2 use quanta::Instant; 3 - use server::{StatusType, WebSocketMessage, update_status}; 4 use std::sync::Arc; 5 use tokio::process::Command; 6 - use tokio::sync::broadcast; 7 use tokio_util::sync::CancellationToken; 8 - use tracing::{debug, error, info}; 9 10 use crate::{ 11 - dev::server, 12 logging::{FormatElapsedTimeOptions, format_elapsed_time}, 13 }; 14 15 #[derive(Clone)] 16 pub struct BuildManager { 17 - current_cancel: Arc<tokio::sync::RwLock<Option<CancellationToken>>>, 18 - build_semaphore: Arc<tokio::sync::Semaphore>, 19 - websocket_tx: broadcast::Sender<WebSocketMessage>, 20 - current_status: Arc<tokio::sync::RwLock<Option<server::PersistentStatus>>>, 21 } 22 23 impl BuildManager { 24 - pub fn new(websocket_tx: broadcast::Sender<WebSocketMessage>) -> Self { 25 Self { 26 - current_cancel: Arc::new(tokio::sync::RwLock::new(None)), 27 - build_semaphore: Arc::new(tokio::sync::Semaphore::new(1)), // Only one build at a time 28 - websocket_tx, 29 - current_status: Arc::new(tokio::sync::RwLock::new(None)), 30 } 31 } 32 33 - /// Get a reference to the current status for use with the web server 34 - pub fn current_status(&self) -> Arc<tokio::sync::RwLock<Option<server::PersistentStatus>>> { 35 - self.current_status.clone() 36 } 37 38 - /// Do initial build that can be cancelled (but isn't stored as current build) 39 - pub async fn do_initial_build(&self) -> Result<bool, Box<dyn std::error::Error>> { 40 - self.internal_build(true).await 41 } 42 43 - /// Start a new build, cancelling any previous one 44 - pub async fn start_build(&self) -> Result<bool, Box<dyn std::error::Error>> { 45 - self.internal_build(false).await 46 } 47 48 - /// Internal build method that handles both initial and regular builds 49 - async fn internal_build(&self, is_initial: bool) -> Result<bool, Box<dyn std::error::Error>> { 50 // Cancel any existing build immediately 51 let cancel = CancellationToken::new(); 52 { 53 - let mut current_cancel = self.current_cancel.write().await; 54 if let Some(old_cancel) = current_cancel.replace(cancel.clone()) { 55 old_cancel.cancel(); 56 } 57 } 58 59 // Acquire semaphore to ensure only one build runs at a time 60 - // This prevents resource conflicts if cancellation fails 61 - let _ = self.build_semaphore.acquire().await?; 62 63 - // Notify that build is starting 64 - update_status( 65 - &self.websocket_tx, 66 - self.current_status.clone(), 67 - StatusType::Info, 68 - "Building...", 69 - ) 70 - .await; 71 72 let mut child = Command::new("cargo") 73 .args([ ··· 76 "--message-format", 77 "json-diagnostic-rendered-ansi", 78 ]) 79 - .envs([ 80 - ("MAUDIT_DEV", "true"), 81 - ("MAUDIT_QUIET", "true"), 82 - ("CARGO_TERM_COLOR", "always"), 83 - ]) 84 .stdout(std::process::Stdio::piped()) 85 .stderr(std::process::Stdio::piped()) 86 .spawn()?; 87 88 - // Take the stderr stream for manual handling 89 - let mut stdout = child.stdout.take().unwrap(); 90 - let mut stderr = child.stderr.take().unwrap(); 91 92 - let websocket_tx = self.websocket_tx.clone(); 93 - let current_status = self.current_status.clone(); 94 let build_start_time = Instant::now(); 95 96 - // Create a channel to get the build result back 97 - let (result_tx, mut result_rx) = tokio::sync::mpsc::channel::<bool>(1); 98 99 - // Spawn watcher task to monitor the child process 100 - tokio::spawn(async move { 101 - let output_future = async { 102 - // Read stdout concurrently with waiting for process to finish 103 - let stdout_task = tokio::spawn(async move { 104 - let mut out = Vec::new(); 105 - tokio::io::copy(&mut stdout, &mut out).await.unwrap_or(0); 106 107 - let mut rendered_messages: Vec<String> = Vec::new(); 108 109 - // Ideally we'd stream things as they come, but I can't figure it out 110 - for message in cargo_metadata::Message::parse_stream( 111 - String::from_utf8_lossy(&out).to_string().as_bytes(), 112 - ) { 113 - match message { 114 - Err(e) => { 115 - error!(name: "build", "Failed to parse cargo message: {}", e); 116 - continue; 117 - } 118 - Ok(message) => { 119 - match message { 120 - // Compiler wants to tell us something 121 - Message::CompilerMessage(msg) => { 122 - // TODO: For now, just send through the rendered messages, but in the future let's send 123 - // structured messages to the frontend so we can do better formatting 124 - if let Some(rendered) = &msg.message.rendered { 125 - info!("{}", rendered); 126 - rendered_messages.push(rendered.to_string()); 127 - } 128 - } 129 - // Random text came in, just log it 130 - Message::TextLine(msg) => { 131 - info!("{}", msg); 132 - } 133 - _ => {} 134 - } 135 - } 136 } 137 } 138 139 - (out, rendered_messages) 140 - }); 141 142 - let stderr_task = tokio::spawn(async move { 143 - let mut err = Vec::new(); 144 - tokio::io::copy(&mut stderr, &mut err).await.unwrap_or(0); 145 146 - err 147 - }); 148 149 - let status = child.wait().await?; 150 - let stdout_data = stdout_task.await.unwrap_or_default(); 151 - let stderr_data = stderr_task.await.unwrap_or_default(); 152 153 - Ok::<(std::process::Output, Vec<String>), Box<dyn std::error::Error + Send + Sync>>( 154 - ( 155 - std::process::Output { 156 - status, 157 - stdout: stdout_data.0, 158 - stderr: stderr_data, 159 - }, 160 - stdout_data.1, 161 - ), 162 - ) 163 }; 164 165 - tokio::select! { 166 - _ = cancel.cancelled() => { 167 - debug!(name: "build", "Build cancelled"); 168 - let _ = child.kill().await; 169 - update_status(&websocket_tx, current_status, StatusType::Info, "Build cancelled").await; 170 - let _ = result_tx.send(false).await; // Build failed due to cancellation 171 - } 172 - res = output_future => { 173 - let duration = build_start_time.elapsed(); 174 - let formatted_elapsed_time = format_elapsed_time( 175 - duration, 176 - &FormatElapsedTimeOptions::default_dev(), 177 - ); 178 179 - let success = match res { 180 - Ok(output) => { 181 - let (output, rendered_messages) = output; 182 - if output.status.success() { 183 - let build_type = if is_initial { "Initial build" } else { "Rebuild" }; 184 - info!(name: "build", "{} finished {}", build_type, formatted_elapsed_time); 185 - update_status(&websocket_tx, current_status, StatusType::Success, "Build finished successfully").await; 186 - true 187 - } else { 188 - let stderr = String::from_utf8_lossy(&output.stderr).to_string(); 189 - println!("{}", stderr); // Raw stderr sometimes has something to say whenever cargo fails, even if the errors messages are actually in stdout 190 - let build_type = if is_initial { "Initial build" } else { "Rebuild" }; 191 - error!(name: "build", "{} failed with errors {}", build_type, formatted_elapsed_time); 192 - if is_initial { 193 - error!(name: "build", "Initial build needs to succeed before we can start the dev server"); 194 - update_status(&websocket_tx, current_status, StatusType::Error, "Initial build failed - fix errors and save to retry").await; 195 - } else { 196 - update_status(&websocket_tx, current_status, StatusType::Error, &rendered_messages.join("\n")).await; 197 - } 198 - false 199 - } 200 - } 201 - Err(e) => { 202 - error!(name: "build", "Failed to wait for build: {}", e); 203 - update_status(&websocket_tx, current_status, StatusType::Error, &format!("Failed to wait for build: {}", e)).await; 204 - false 205 - } 206 - }; 207 - let _ = result_tx.send(success).await; 208 - } 209 } 210 - }); 211 212 - // Wait for the build result 213 - let success = result_rx.recv().await.unwrap_or(false); 214 - Ok(success) 215 } 216 }
··· 1 use cargo_metadata::Message; 2 use quanta::Instant; 3 + use std::path::PathBuf; 4 use std::sync::Arc; 5 use tokio::process::Command; 6 + use tokio::sync::RwLock; 7 use tokio_util::sync::CancellationToken; 8 + use tracing::{debug, error, info, warn}; 9 10 use crate::{ 11 + dev::server::{StatusManager, StatusType}, 12 logging::{FormatElapsedTimeOptions, format_elapsed_time}, 13 }; 14 15 + use super::dep_tracker::{DependencyTracker, find_target_dir}; 16 + 17 + /// Internal state shared across all BuildManager handles. 18 + struct BuildManagerState { 19 + current_cancel: RwLock<Option<CancellationToken>>, 20 + build_semaphore: tokio::sync::Semaphore, 21 + status_manager: StatusManager, 22 + dep_tracker: RwLock<Option<DependencyTracker>>, 23 + binary_path: RwLock<Option<PathBuf>>, 24 + // Cached values computed once at startup 25 + target_dir: Option<PathBuf>, 26 + binary_name: Option<String>, 27 + } 28 + 29 + /// Manages cargo build processes with cancellation support. 30 + /// Cheap to clone - all clones share the same underlying state. 31 #[derive(Clone)] 32 pub struct BuildManager { 33 + state: Arc<BuildManagerState>, 34 } 35 36 impl BuildManager { 37 + pub fn new(status_manager: StatusManager) -> Self { 38 + // Try to determine target directory and binary name at startup 39 + let target_dir = find_target_dir().ok(); 40 + let binary_name = Self::get_binary_name_from_cargo_toml().ok(); 41 + 42 + if let Some(ref name) = binary_name { 43 + debug!(name: "build", "Detected binary name at startup: {}", name); 44 + } 45 + if let Some(ref dir) = target_dir { 46 + debug!(name: "build", "Using target directory: {:?}", dir); 47 + } 48 + 49 Self { 50 + state: Arc::new(BuildManagerState { 51 + current_cancel: RwLock::new(None), 52 + build_semaphore: tokio::sync::Semaphore::new(1), 53 + status_manager, 54 + dep_tracker: RwLock::new(None), 55 + binary_path: RwLock::new(None), 56 + target_dir, 57 + binary_name, 58 + }), 59 } 60 } 61 62 + /// Check if the given paths require recompilation based on dependency tracking. 63 + /// Returns true if recompilation is needed, false if we can just rerun the binary. 64 + pub async fn needs_recompile(&self, changed_paths: &[PathBuf]) -> bool { 65 + let dep_tracker = self.state.dep_tracker.read().await; 66 + 67 + if let Some(tracker) = dep_tracker.as_ref() 68 + && tracker.has_dependencies() 69 + { 70 + let needs_recompile = tracker.needs_recompile(changed_paths); 71 + if !needs_recompile { 72 + debug!(name: "build", "Changed files are not dependencies, rerun binary without recompile"); 73 + } 74 + return needs_recompile; 75 + } 76 + 77 + // If we don't have a dependency tracker yet, always recompile 78 + true 79 } 80 81 + /// Rerun the binary without recompiling. 82 + pub async fn rerun_binary( 83 + &self, 84 + changed_paths: &[PathBuf], 85 + ) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> { 86 + // Get binary path with limited lock scope 87 + let path = { 88 + let guard = self.state.binary_path.read().await; 89 + match guard.as_ref() { 90 + Some(p) if p.exists() => p.clone(), 91 + Some(p) => { 92 + warn!(name: "build", "Binary at {:?} no longer exists, falling back to full rebuild", p); 93 + return self.start_build(Some(changed_paths)).await; 94 + } 95 + None => { 96 + warn!(name: "build", "No binary path available, falling back to full rebuild"); 97 + return self.start_build(Some(changed_paths)).await; 98 + } 99 + } 100 + }; 101 + 102 + // Log that we're doing an incremental build 103 + debug!(name: "build", "Incremental build: {} files changed", changed_paths.len()); 104 + debug!(name: "build", "Changed files: {:?}", changed_paths); 105 + debug!(name: "build", "Rerunning binary without recompilation..."); 106 + 107 + self.state 108 + .status_manager 109 + .update(StatusType::Info, "Rerunning...") 110 + .await; 111 + 112 + let build_start_time = Instant::now(); 113 + 114 + // Serialize changed paths to JSON for the binary 115 + let changed_files_json = serde_json::to_string(changed_paths)?; 116 + 117 + let child = Command::new(&path) 118 + .envs([ 119 + ("MAUDIT_DEV", "true"), 120 + ("MAUDIT_QUIET", "true"), 121 + ("MAUDIT_CHANGED_FILES", changed_files_json.as_str()), 122 + ]) 123 + .stdout(std::process::Stdio::piped()) 124 + .stderr(std::process::Stdio::piped()) 125 + .spawn()?; 126 + 127 + let output = child.wait_with_output().await?; 128 + 129 + let duration = build_start_time.elapsed(); 130 + let formatted_elapsed_time = 131 + format_elapsed_time(duration, &FormatElapsedTimeOptions::default_dev()); 132 + 133 + if output.status.success() { 134 + if std::env::var("MAUDIT_SHOW_BINARY_OUTPUT").is_ok() { 135 + let stdout = String::from_utf8_lossy(&output.stdout); 136 + let stderr = String::from_utf8_lossy(&output.stderr); 137 + for line in stdout.lines().chain(stderr.lines()) { 138 + if !line.trim().is_empty() { 139 + info!(name: "build", "{}", line); 140 + } 141 + } 142 + } 143 + info!(name: "build", "Binary rerun finished {}", formatted_elapsed_time); 144 + self.state 145 + .status_manager 146 + .update(StatusType::Success, "Binary rerun finished successfully") 147 + .await; 148 + Ok(true) 149 + } else { 150 + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); 151 + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); 152 + error!(name: "build", "Binary rerun failed {}\nstdout: {}\nstderr: {}", 153 + formatted_elapsed_time, stdout, stderr); 154 + self.state 155 + .status_manager 156 + .update( 157 + StatusType::Error, 158 + &format!("Binary rerun failed:\n{}\n{}", stdout, stderr), 159 + ) 160 + .await; 161 + Ok(false) 162 + } 163 } 164 165 + /// Do initial build that can be cancelled. 166 + pub async fn do_initial_build(&self) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> { 167 + self.internal_build(true, None).await 168 + } 169 + 170 + /// Start a new build, cancelling any previous one. 171 + /// If changed_paths is provided, they will be passed to the binary for incremental builds. 172 + pub async fn start_build( 173 + &self, 174 + changed_paths: Option<&[PathBuf]>, 175 + ) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> { 176 + self.internal_build(false, changed_paths).await 177 } 178 179 + async fn internal_build( 180 + &self, 181 + is_initial: bool, 182 + changed_paths: Option<&[PathBuf]>, 183 + ) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> { 184 // Cancel any existing build immediately 185 let cancel = CancellationToken::new(); 186 { 187 + let mut current_cancel = self.state.current_cancel.write().await; 188 if let Some(old_cancel) = current_cancel.replace(cancel.clone()) { 189 old_cancel.cancel(); 190 } 191 } 192 193 // Acquire semaphore to ensure only one build runs at a time 194 + let _permit = self.state.build_semaphore.acquire().await?; 195 196 + self.state 197 + .status_manager 198 + .update(StatusType::Info, "Building...") 199 + .await; 200 + 201 + // Build environment variables 202 + let mut envs: Vec<(&str, String)> = vec![ 203 + ("MAUDIT_DEV", "true".to_string()), 204 + ("MAUDIT_QUIET", "true".to_string()), 205 + ("CARGO_TERM_COLOR", "always".to_string()), 206 + ]; 207 + 208 + // Add changed files if provided (for incremental builds after recompilation) 209 + if let Some(paths) = changed_paths 210 + && let Ok(json) = serde_json::to_string(paths) { 211 + debug!(name: "build", "Passing MAUDIT_CHANGED_FILES to cargo: {}", json); 212 + envs.push(("MAUDIT_CHANGED_FILES", json)); 213 + } 214 215 let mut child = Command::new("cargo") 216 .args([ ··· 219 "--message-format", 220 "json-diagnostic-rendered-ansi", 221 ]) 222 + .envs(envs.iter().map(|(k, v)| (*k, v.as_str()))) 223 .stdout(std::process::Stdio::piped()) 224 .stderr(std::process::Stdio::piped()) 225 .spawn()?; 226 227 + // Take stdout/stderr before select! so we can use them in the completion branch 228 + // while still being able to kill the child in the cancellation branch 229 + let stdout = child.stdout.take().unwrap(); 230 + let stderr = child.stderr.take().unwrap(); 231 232 let build_start_time = Instant::now(); 233 234 + tokio::select! { 235 + _ = cancel.cancelled() => { 236 + debug!(name: "build", "Build cancelled"); 237 + let _ = child.kill().await; 238 + self.state.status_manager.update(StatusType::Info, "Build cancelled").await; 239 + Ok(false) 240 + } 241 + result = self.run_build_to_completion(&mut child, stdout, stderr, is_initial, build_start_time) => { 242 + result 243 + } 244 + } 245 + } 246 247 + /// Run the cargo build process to completion and handle the output. 248 + async fn run_build_to_completion( 249 + &self, 250 + child: &mut tokio::process::Child, 251 + mut stdout: tokio::process::ChildStdout, 252 + mut stderr: tokio::process::ChildStderr, 253 + is_initial: bool, 254 + build_start_time: Instant, 255 + ) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> { 256 + // Read stdout and stderr concurrently 257 + let stdout_task = tokio::spawn(async move { 258 + let mut out = Vec::new(); 259 + tokio::io::copy(&mut stdout, &mut out).await.unwrap_or(0); 260 261 + let mut rendered_messages: Vec<String> = Vec::new(); 262 263 + for message in cargo_metadata::Message::parse_stream( 264 + String::from_utf8_lossy(&out).to_string().as_bytes(), 265 + ) { 266 + match message { 267 + Err(e) => { 268 + error!(name: "build", "Failed to parse cargo message: {}", e); 269 + } 270 + Ok(Message::CompilerMessage(msg)) => { 271 + if let Some(rendered) = &msg.message.rendered { 272 + info!("{}", rendered); 273 + rendered_messages.push(rendered.to_string()); 274 } 275 } 276 + Ok(Message::TextLine(msg)) => { 277 + info!("{}", msg); 278 + } 279 + _ => {} 280 + } 281 + } 282 283 + (out, rendered_messages) 284 + }); 285 286 + let stderr_task = tokio::spawn(async move { 287 + let mut err = Vec::new(); 288 + tokio::io::copy(&mut stderr, &mut err).await.unwrap_or(0); 289 + err 290 + }); 291 292 + let status = child.wait().await?; 293 + let (_stdout_bytes, rendered_messages) = stdout_task.await.unwrap_or_default(); 294 + let stderr_bytes = stderr_task.await.unwrap_or_default(); 295 296 + let duration = build_start_time.elapsed(); 297 + let formatted_elapsed_time = 298 + format_elapsed_time(duration, &FormatElapsedTimeOptions::default_dev()); 299 300 + if status.success() { 301 + let build_type = if is_initial { 302 + "Initial build" 303 + } else { 304 + "Rebuild" 305 }; 306 + info!(name: "build", "{} finished {}", build_type, formatted_elapsed_time); 307 + self.state 308 + .status_manager 309 + .update(StatusType::Success, "Build finished successfully") 310 + .await; 311 312 + self.update_dependency_tracker().await; 313 314 + Ok(true) 315 + } else { 316 + let stderr_str = String::from_utf8_lossy(&stderr_bytes).to_string(); 317 + // Raw stderr sometimes has something to say whenever cargo fails 318 + println!("{}", stderr_str); 319 + 320 + let build_type = if is_initial { 321 + "Initial build" 322 + } else { 323 + "Rebuild" 324 + }; 325 + error!(name: "build", "{} failed with errors {}", build_type, formatted_elapsed_time); 326 + 327 + if is_initial { 328 + error!(name: "build", "Initial build needs to succeed before we can start the dev server"); 329 + self.state 330 + .status_manager 331 + .update( 332 + StatusType::Error, 333 + "Initial build failed - fix errors and save to retry", 334 + ) 335 + .await; 336 + } else { 337 + self.state 338 + .status_manager 339 + .update(StatusType::Error, &rendered_messages.join("\n")) 340 + .await; 341 } 342 + 343 + Ok(false) 344 + } 345 + } 346 + 347 + /// Update the dependency tracker after a successful build. 348 + async fn update_dependency_tracker(&self) { 349 + let Some(ref name) = self.state.binary_name else { 350 + debug!(name: "build", "No binary name available, skipping dependency tracker update"); 351 + return; 352 + }; 353 + 354 + let Some(ref target) = self.state.target_dir else { 355 + debug!(name: "build", "No target directory available, skipping dependency tracker update"); 356 + return; 357 + }; 358 + 359 + // Update binary path 360 + let bin_path = target.join(name); 361 + if bin_path.exists() { 362 + *self.state.binary_path.write().await = Some(bin_path.clone()); 363 + debug!(name: "build", "Binary path set to: {:?}", bin_path); 364 + } else { 365 + debug!(name: "build", "Binary not found at expected path: {:?}", bin_path); 366 + } 367 + 368 + // Reload the dependency tracker from the .d file 369 + match DependencyTracker::load_from_binary_name(name) { 370 + Ok(tracker) => { 371 + debug!(name: "build", "Loaded {} dependencies for tracking", tracker.get_dependencies().len()); 372 + *self.state.dep_tracker.write().await = Some(tracker); 373 + } 374 + Err(e) => { 375 + debug!(name: "build", "Could not load dependency tracker: {}", e); 376 + } 377 + } 378 + } 379 + 380 + fn get_binary_name_from_cargo_toml() -> Result<String, Box<dyn std::error::Error + Send + Sync>> 381 + { 382 + let cargo_toml_path = PathBuf::from("Cargo.toml"); 383 + if !cargo_toml_path.exists() { 384 + return Err("Cargo.toml not found in current directory".into()); 385 + } 386 + 387 + let cargo_toml_content = std::fs::read_to_string(&cargo_toml_path)?; 388 + let cargo_toml: toml::Value = toml::from_str(&cargo_toml_content)?; 389 + 390 + if let Some(package_name) = cargo_toml 391 + .get("package") 392 + .and_then(|p| p.get("name")) 393 + .and_then(|n| n.as_str()) 394 + { 395 + // Check if there's a [[bin]] section with a different name 396 + if let Some(bins) = cargo_toml.get("bin").and_then(|b| b.as_array()) 397 + && let Some(first_bin) = bins.first() 398 + && let Some(bin_name) = first_bin.get("name").and_then(|n| n.as_str()) 399 + { 400 + return Ok(bin_name.to_string()); 401 + } 402 + 403 + return Ok(package_name.to_string()); 404 + } 405 + 406 + Err("Could not find package name in Cargo.toml".into()) 407 + } 408 + 409 + /// Set the dependency tracker directly (for testing). 410 + #[cfg(test)] 411 + pub(crate) async fn set_dep_tracker(&self, tracker: Option<DependencyTracker>) { 412 + *self.state.dep_tracker.write().await = tracker; 413 + } 414 + 415 + /// Set the binary path directly (for testing). 416 + #[cfg(test)] 417 + pub(crate) async fn set_binary_path(&self, path: Option<PathBuf>) { 418 + *self.state.binary_path.write().await = path; 419 + } 420 + 421 + /// Get the current binary path (for testing). 422 + #[cfg(test)] 423 + pub(crate) async fn get_binary_path(&self) -> Option<PathBuf> { 424 + self.state.binary_path.read().await.clone() 425 + } 426 + 427 + /// Create a BuildManager with custom target_dir and binary_name (for testing). 428 + #[cfg(test)] 429 + pub(crate) fn new_with_config( 430 + status_manager: StatusManager, 431 + target_dir: Option<PathBuf>, 432 + binary_name: Option<String>, 433 + ) -> Self { 434 + Self { 435 + state: Arc::new(BuildManagerState { 436 + current_cancel: RwLock::new(None), 437 + build_semaphore: tokio::sync::Semaphore::new(1), 438 + status_manager, 439 + dep_tracker: RwLock::new(None), 440 + binary_path: RwLock::new(None), 441 + target_dir, 442 + binary_name, 443 + }), 444 + } 445 + } 446 + } 447 + 448 + #[cfg(test)] 449 + mod tests { 450 + use super::*; 451 + use std::collections::HashMap; 452 + use std::time::SystemTime; 453 + use tempfile::TempDir; 454 + 455 + fn create_test_manager() -> BuildManager { 456 + let status_manager = StatusManager::new(); 457 + BuildManager::new_with_config(status_manager, None, None) 458 + } 459 + 460 + fn create_test_manager_with_config( 461 + target_dir: Option<PathBuf>, 462 + binary_name: Option<String>, 463 + ) -> BuildManager { 464 + let status_manager = StatusManager::new(); 465 + BuildManager::new_with_config(status_manager, target_dir, binary_name) 466 + } 467 + 468 + #[tokio::test] 469 + async fn test_build_manager_clone_shares_state() { 470 + let manager1 = create_test_manager(); 471 + let manager2 = manager1.clone(); 472 + 473 + // Set binary path via one clone 474 + let test_path = PathBuf::from("/test/path"); 475 + manager1.set_binary_path(Some(test_path.clone())).await; 476 + 477 + // Should be visible via the other clone 478 + assert_eq!(manager2.get_binary_path().await, Some(test_path)); 479 + } 480 + 481 + #[tokio::test] 482 + async fn test_needs_recompile_without_tracker() { 483 + let manager = create_test_manager(); 484 + 485 + // Without a dependency tracker, should always return true 486 + let changed = vec![PathBuf::from("src/main.rs")]; 487 + assert!(manager.needs_recompile(&changed).await); 488 + } 489 + 490 + #[tokio::test] 491 + async fn test_needs_recompile_with_empty_tracker() { 492 + let manager = create_test_manager(); 493 + 494 + // Set an empty tracker (no dependencies) 495 + let tracker = DependencyTracker::new(); 496 + manager.set_dep_tracker(Some(tracker)).await; 497 + 498 + // Empty tracker has no dependencies, so has_dependencies() returns false 499 + // This means we should still return true (recompile needed) 500 + let changed = vec![PathBuf::from("src/main.rs")]; 501 + assert!(manager.needs_recompile(&changed).await); 502 + } 503 + 504 + #[tokio::test] 505 + async fn test_needs_recompile_with_matching_dependency() { 506 + let manager = create_test_manager(); 507 + 508 + // Create a tracker with some dependencies 509 + let temp_dir = TempDir::new().unwrap(); 510 + let dep_file = temp_dir.path().join("src/lib.rs"); 511 + std::fs::create_dir_all(dep_file.parent().unwrap()).unwrap(); 512 + std::fs::write(&dep_file, "// test").unwrap(); 513 + 514 + // Get canonical path and current mod time 515 + let canonical_path = dep_file.canonicalize().unwrap(); 516 + let old_time = SystemTime::UNIX_EPOCH; // Very old time 517 + 518 + let mut tracker = DependencyTracker::new(); 519 + tracker.dependencies = HashMap::from([(canonical_path, old_time)]); 520 + 521 + manager.set_dep_tracker(Some(tracker)).await; 522 + 523 + // Changed file IS a dependency and is newer - should need recompile 524 + let changed = vec![dep_file]; 525 + assert!(manager.needs_recompile(&changed).await); 526 + } 527 + 528 + #[tokio::test] 529 + async fn test_needs_recompile_with_non_matching_file() { 530 + let manager = create_test_manager(); 531 + 532 + // Create a tracker with some dependencies 533 + let temp_dir = TempDir::new().unwrap(); 534 + let dep_file = temp_dir.path().join("src/lib.rs"); 535 + std::fs::create_dir_all(dep_file.parent().unwrap()).unwrap(); 536 + std::fs::write(&dep_file, "// test").unwrap(); 537 + 538 + let canonical_path = dep_file.canonicalize().unwrap(); 539 + let mod_time = std::fs::metadata(&dep_file).unwrap().modified().unwrap(); 540 + 541 + let mut tracker = DependencyTracker::new(); 542 + tracker.dependencies = HashMap::from([(canonical_path, mod_time)]); 543 + 544 + manager.set_dep_tracker(Some(tracker)).await; 545 + 546 + // Changed file is NOT a dependency (different file) 547 + let other_file = temp_dir.path().join("assets/style.css"); 548 + std::fs::create_dir_all(other_file.parent().unwrap()).unwrap(); 549 + std::fs::write(&other_file, "/* css */").unwrap(); 550 + 551 + let changed = vec![other_file]; 552 + assert!(!manager.needs_recompile(&changed).await); 553 + } 554 + 555 + #[tokio::test] 556 + async fn test_update_dependency_tracker_with_config_missing_binary() { 557 + let temp_dir = TempDir::new().unwrap(); 558 + let manager = create_test_manager_with_config( 559 + Some(temp_dir.path().to_path_buf()), 560 + Some("nonexistent-binary".to_string()), 561 + ); 562 + 563 + // Binary doesn't exist, so binary_path should not be set 564 + manager.update_dependency_tracker().await; 565 + 566 + assert!(manager.get_binary_path().await.is_none()); 567 + } 568 + 569 + #[tokio::test] 570 + async fn test_update_dependency_tracker_with_existing_binary() { 571 + let temp_dir = TempDir::new().unwrap(); 572 + let binary_name = "test-binary"; 573 + let binary_path = temp_dir.path().join(binary_name); 574 575 + // Create a fake binary file 576 + std::fs::write(&binary_path, "fake binary").unwrap(); 577 + 578 + let manager = create_test_manager_with_config( 579 + Some(temp_dir.path().to_path_buf()), 580 + Some(binary_name.to_string()), 581 + ); 582 + 583 + manager.update_dependency_tracker().await; 584 + 585 + // Binary path should be set 586 + assert_eq!(manager.get_binary_path().await, Some(binary_path)); 587 } 588 }
+279
crates/maudit-cli/src/dev/dep_tracker.rs
···
··· 1 + use depinfo::RustcDepInfo; 2 + use std::collections::HashMap; 3 + use std::fs; 4 + use std::path::{Path, PathBuf}; 5 + use std::time::SystemTime; 6 + use tracing::{debug, warn}; 7 + 8 + /// Tracks dependencies from .d files to determine if recompilation is needed 9 + #[derive(Debug, Clone)] 10 + pub struct DependencyTracker { 11 + /// Path to the .d file 12 + pub(crate) d_file_path: Option<PathBuf>, 13 + /// Map of dependency paths to their last modification times 14 + pub(crate) dependencies: HashMap<PathBuf, SystemTime>, 15 + } 16 + 17 + /// Find the target directory using multiple strategies 18 + /// 19 + /// This function tries multiple approaches to locate the target directory: 20 + /// 1. CARGO_TARGET_DIR / CARGO_BUILD_TARGET_DIR environment variables 21 + /// 2. Local ./target/debug directory 22 + /// 3. Workspace root target/debug directory (walking up to find [workspace]) 23 + /// 4. Fallback to relative "target/debug" path 24 + pub fn find_target_dir() -> Result<PathBuf, std::io::Error> { 25 + // 1. Check CARGO_TARGET_DIR and CARGO_BUILD_TARGET_DIR environment variables 26 + for env_var in ["CARGO_TARGET_DIR", "CARGO_BUILD_TARGET_DIR"] { 27 + if let Ok(target_dir) = std::env::var(env_var) { 28 + // Try with /debug appended 29 + let path = PathBuf::from(&target_dir).join("debug"); 30 + if path.exists() { 31 + debug!("Using target directory from {}: {:?}", env_var, path); 32 + return Ok(path); 33 + } 34 + // If the env var points directly to debug or release 35 + let path_no_debug = PathBuf::from(&target_dir); 36 + if path_no_debug.exists() 37 + && (path_no_debug.ends_with("debug") || path_no_debug.ends_with("release")) 38 + { 39 + debug!( 40 + "Using target directory from {} (direct): {:?}", 41 + env_var, path_no_debug 42 + ); 43 + return Ok(path_no_debug); 44 + } 45 + } 46 + } 47 + 48 + // 2. Look for target directory in current directory 49 + let local_target = PathBuf::from("target/debug"); 50 + if local_target.exists() { 51 + debug!("Using local target directory: {:?}", local_target); 52 + return Ok(local_target); 53 + } 54 + 55 + // 3. Try to find workspace root by looking for Cargo.toml with [workspace] 56 + let mut current = std::env::current_dir()?; 57 + loop { 58 + let cargo_toml = current.join("Cargo.toml"); 59 + if cargo_toml.exists() 60 + && let Ok(content) = fs::read_to_string(&cargo_toml) 61 + && content.contains("[workspace]") 62 + { 63 + let workspace_target = current.join("target").join("debug"); 64 + if workspace_target.exists() { 65 + debug!("Using workspace target directory: {:?}", workspace_target); 66 + return Ok(workspace_target); 67 + } 68 + } 69 + 70 + // Move up to parent directory 71 + if !current.pop() { 72 + break; 73 + } 74 + } 75 + 76 + // 4. Final fallback to relative path 77 + debug!("Falling back to relative target/debug path"); 78 + Ok(PathBuf::from("target/debug")) 79 + } 80 + 81 + impl DependencyTracker { 82 + #[allow(dead_code)] 83 + pub fn new() -> Self { 84 + Self { 85 + d_file_path: None, 86 + dependencies: HashMap::new(), 87 + } 88 + } 89 + 90 + /// Locate and load the .d file for the current binary 91 + /// The .d file is typically at target/debug/<binary-name>.d 92 + pub fn load_from_binary_name(binary_name: &str) -> Result<Self, std::io::Error> { 93 + let target_dir = find_target_dir()?; 94 + let d_file_path = target_dir.join(format!("{}.d", binary_name)); 95 + 96 + if !d_file_path.exists() { 97 + return Err(std::io::Error::new( 98 + std::io::ErrorKind::NotFound, 99 + format!(".d file not found at {:?}", d_file_path), 100 + )); 101 + } 102 + 103 + let mut tracker = Self { 104 + d_file_path: Some(d_file_path.clone()), 105 + dependencies: HashMap::new(), 106 + }; 107 + 108 + tracker.reload_dependencies()?; 109 + Ok(tracker) 110 + } 111 + 112 + /// Reload dependencies from the .d file using the depinfo crate 113 + pub fn reload_dependencies(&mut self) -> Result<(), std::io::Error> { 114 + let Some(d_file_path) = &self.d_file_path else { 115 + return Err(std::io::Error::new( 116 + std::io::ErrorKind::NotFound, 117 + "No .d file path set", 118 + )); 119 + }; 120 + 121 + let dep_info = RustcDepInfo::from_file(d_file_path).map_err(|e| { 122 + warn!("Failed to parse .d file at {:?}: {}", d_file_path, e); 123 + std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()) 124 + })?; 125 + 126 + // Clear old dependencies and load new ones with their modification times 127 + self.dependencies.clear(); 128 + 129 + for dep_path in dep_info.files { 130 + match fs::metadata(&dep_path) { 131 + Ok(metadata) => { 132 + if let Ok(modified) = metadata.modified() { 133 + self.dependencies.insert(dep_path.clone(), modified); 134 + debug!("Tracking dependency: {:?}", dep_path); 135 + } 136 + } 137 + Err(e) => { 138 + // Dependency file doesn't exist or can't be read - this is okay, 139 + // it might have been deleted or moved 140 + debug!("Could not read dependency {:?}: {}", dep_path, e); 141 + } 142 + } 143 + } 144 + 145 + debug!( 146 + "Loaded {} dependencies from {:?}", 147 + self.dependencies.len(), 148 + d_file_path 149 + ); 150 + Ok(()) 151 + } 152 + 153 + /// Check if any of the given paths require recompilation 154 + /// Returns true if any path is a tracked dependency that has been modified 155 + pub fn needs_recompile(&self, changed_paths: &[PathBuf]) -> bool { 156 + for changed_path in changed_paths { 157 + // Normalize the changed path to handle relative vs absolute paths 158 + let changed_path_canonical = changed_path.canonicalize().ok(); 159 + 160 + for (dep_path, last_modified) in &self.dependencies { 161 + // Try to match both exact path and canonical path 162 + let matches = changed_path == dep_path 163 + || changed_path_canonical.as_ref() == Some(dep_path) 164 + || dep_path.canonicalize().ok().as_ref() == changed_path_canonical.as_ref(); 165 + 166 + if matches { 167 + // Check if the file was modified after we last tracked it 168 + if let Ok(metadata) = fs::metadata(changed_path) { 169 + if let Ok(current_modified) = metadata.modified() 170 + && current_modified > *last_modified 171 + { 172 + debug!( 173 + "Dependency {:?} was modified, recompile needed", 174 + changed_path 175 + ); 176 + return true; 177 + } 178 + } else { 179 + // File was deleted or can't be read, assume recompile is needed 180 + debug!( 181 + "Dependency {:?} no longer exists, recompile needed", 182 + changed_path 183 + ); 184 + return true; 185 + } 186 + } 187 + } 188 + } 189 + 190 + false 191 + } 192 + 193 + /// Get the list of tracked dependency paths 194 + pub fn get_dependencies(&self) -> Vec<&Path> { 195 + self.dependencies.keys().map(|p| p.as_path()).collect() 196 + } 197 + 198 + /// Check if we have any dependencies loaded 199 + pub fn has_dependencies(&self) -> bool { 200 + !self.dependencies.is_empty() 201 + } 202 + } 203 + 204 + #[cfg(test)] 205 + mod tests { 206 + use super::*; 207 + use std::fs; 208 + use std::io::Write; 209 + use tempfile::TempDir; 210 + 211 + #[test] 212 + fn test_parse_d_file() { 213 + let temp_dir = TempDir::new().unwrap(); 214 + let d_file_path = temp_dir.path().join("test.d"); 215 + 216 + // Create a mock .d file 217 + let mut d_file = fs::File::create(&d_file_path).unwrap(); 218 + writeln!( 219 + d_file, 220 + "/path/to/target: /path/to/dep1.rs /path/to/dep2.rs \\" 221 + ) 222 + .unwrap(); 223 + writeln!(d_file, " /path/to/dep3.rs").unwrap(); 224 + 225 + // Create a tracker and point it to our test file 226 + let mut tracker = DependencyTracker::new(); 227 + tracker.d_file_path = Some(d_file_path); 228 + 229 + // This will fail to load the actual files, but we can check the parsing logic 230 + let _ = tracker.reload_dependencies(); 231 + 232 + // We won't have any dependencies because the files don't exist, 233 + // but we've verified the parsing doesn't crash 234 + } 235 + 236 + #[test] 237 + fn test_parse_d_file_with_spaces() { 238 + let temp_dir = TempDir::new().unwrap(); 239 + let d_file_path = temp_dir.path().join("test_spaces.d"); 240 + 241 + // Create actual test files with spaces in names 242 + let dep_with_space = temp_dir.path().join("my file.rs"); 243 + fs::write(&dep_with_space, "// test").unwrap(); 244 + 245 + let normal_dep = temp_dir.path().join("normal.rs"); 246 + fs::write(&normal_dep, "// test").unwrap(); 247 + 248 + // Create a mock .d file with escaped spaces (Make format) 249 + let mut d_file = fs::File::create(&d_file_path).unwrap(); 250 + writeln!( 251 + d_file, 252 + "/path/to/target: {} {}", 253 + dep_with_space.to_str().unwrap().replace(' ', "\\ "), 254 + normal_dep.to_str().unwrap() 255 + ) 256 + .unwrap(); 257 + 258 + let mut tracker = DependencyTracker::new(); 259 + tracker.d_file_path = Some(d_file_path); 260 + 261 + // Load dependencies 262 + tracker.reload_dependencies().unwrap(); 263 + 264 + // Should have successfully parsed both files 265 + assert!(tracker.has_dependencies()); 266 + let deps = tracker.get_dependencies(); 267 + assert_eq!(deps.len(), 2); 268 + assert!( 269 + deps.iter() 270 + .any(|p| p.to_str().unwrap().contains("my file.rs")), 271 + "Should contain file with space" 272 + ); 273 + assert!( 274 + deps.iter() 275 + .any(|p| p.to_str().unwrap().contains("normal.rs")), 276 + "Should contain normal file" 277 + ); 278 + } 279 + }
+223 -64
crates/maudit-cli/src/dev/server.rs
··· 64 pub message: String, 65 } 66 67 #[derive(Clone)] 68 - struct AppState { 69 tx: broadcast::Sender<WebSocketMessage>, 70 current_status: Arc<RwLock<Option<PersistentStatus>>>, 71 } 72 73 fn inject_live_reload_script(html_content: &str, socket_addr: SocketAddr, host: bool) -> String { ··· 93 94 pub async fn start_dev_web_server( 95 start_time: Instant, 96 - tx: broadcast::Sender<WebSocketMessage>, 97 host: bool, 98 port: Option<u16>, 99 initial_error: Option<String>, 100 - current_status: Arc<RwLock<Option<PersistentStatus>>>, 101 ) { 102 // TODO: The dist dir should be configurable 103 let dist_dir = "dist"; 104 105 // Send initial error if present 106 if let Some(error) = initial_error { 107 - let _ = tx.send(WebSocketMessage { 108 data: json!({ 109 "type": StatusType::Error.to_string(), 110 "message": error ··· 172 .on_response(CustomOnResponse), 173 ) 174 .with_state(AppState { 175 - tx: tx.clone(), 176 - current_status: current_status.clone(), 177 }); 178 179 log_server_start( ··· 192 .unwrap(); 193 } 194 195 - pub async fn update_status( 196 - tx: &broadcast::Sender<WebSocketMessage>, 197 - current_status: Arc<RwLock<Option<PersistentStatus>>>, 198 - status_type: StatusType, 199 - message: &str, 200 - ) { 201 - // Only store persistent states (Success clears errors, Error stores the error) 202 - let persistent_status = match status_type { 203 - StatusType::Success => None, // Clear any error state 204 - StatusType::Error => Some(PersistentStatus { 205 - status_type: StatusType::Error, 206 - message: message.to_string(), 207 - }), 208 - // Everything else just keeps the current state 209 - _ => { 210 - let status = current_status.read().await; 211 - status.clone() // Keep existing persistent state 212 - } 213 - }; 214 - 215 - // Update the stored status 216 - { 217 - let mut status = current_status.write().await; 218 - *status = persistent_status; 219 - } 220 - 221 - // Send the message to all connected clients 222 - let _ = tx.send(WebSocketMessage { 223 - data: json!({ 224 - "type": status_type.to_string(), 225 - "message": message 226 - }) 227 - .to_string(), 228 - }); 229 - } 230 - 231 async fn add_dev_client_script( 232 req: Request, 233 next: Next, ··· 311 debug!("`{addr} connected."); 312 // finalize the upgrade process by returning upgrade callback. 313 // we can customize the callback by sending additional info such as address. 314 - ws.on_upgrade(move |socket| handle_socket(socket, addr, state.tx, state.current_status)) 315 } 316 317 - async fn handle_socket( 318 - socket: WebSocket, 319 - who: SocketAddr, 320 - tx: broadcast::Sender<WebSocketMessage>, 321 - current_status: Arc<RwLock<Option<PersistentStatus>>>, 322 - ) { 323 let (mut sender, mut receiver) = socket.split(); 324 325 // Send current persistent status to new connection if there is one 326 - { 327 - let status = current_status.read().await; 328 - if let Some(persistent_status) = status.as_ref() { 329 - let _ = sender 330 - .send(Message::Text( 331 - json!({ 332 - "type": persistent_status.status_type.to_string(), 333 - "message": persistent_status.message 334 - }) 335 - .to_string() 336 - .into(), 337 - )) 338 - .await; 339 - } 340 } 341 342 - let mut rx = tx.subscribe(); 343 344 tokio::select! { 345 _ = async { ··· 387 _ = terminate => {}, 388 } 389 }
··· 64 pub message: String, 65 } 66 67 + /// Manages status updates and WebSocket broadcasting. 68 + /// Cheap to clone - all clones share the same underlying state. 69 #[derive(Clone)] 70 + pub struct StatusManager { 71 tx: broadcast::Sender<WebSocketMessage>, 72 current_status: Arc<RwLock<Option<PersistentStatus>>>, 73 + } 74 + 75 + impl StatusManager { 76 + pub fn new() -> Self { 77 + let (tx, _) = broadcast::channel::<WebSocketMessage>(100); 78 + Self { 79 + tx, 80 + current_status: Arc::new(RwLock::new(None)), 81 + } 82 + } 83 + 84 + /// Update the status and broadcast to all connected WebSocket clients. 85 + pub async fn update(&self, status_type: StatusType, message: &str) { 86 + // Only store persistent states (Success clears errors, Error stores the error) 87 + let persistent_status = match status_type { 88 + StatusType::Success => None, // Clear any error state 89 + StatusType::Error => Some(PersistentStatus { 90 + status_type: StatusType::Error, 91 + message: message.to_string(), 92 + }), 93 + // Everything else just keeps the current state 94 + _ => { 95 + let status = self.current_status.read().await; 96 + status.clone() // Keep existing persistent state 97 + } 98 + }; 99 + 100 + // Update the stored status 101 + { 102 + let mut status = self.current_status.write().await; 103 + *status = persistent_status; 104 + } 105 + 106 + // Send the message to all connected clients 107 + let _ = self.tx.send(WebSocketMessage { 108 + data: json!({ 109 + "type": status_type.to_string(), 110 + "message": message 111 + }) 112 + .to_string(), 113 + }); 114 + } 115 + 116 + /// Subscribe to WebSocket messages (for new connections). 117 + pub fn subscribe(&self) -> broadcast::Receiver<WebSocketMessage> { 118 + self.tx.subscribe() 119 + } 120 + 121 + /// Get the current persistent status (for new connections). 122 + pub async fn get_current(&self) -> Option<PersistentStatus> { 123 + self.current_status.read().await.clone() 124 + } 125 + 126 + /// Send a raw WebSocket message (for initial errors, etc.). 127 + pub fn send_raw(&self, message: WebSocketMessage) { 128 + let _ = self.tx.send(message); 129 + } 130 + } 131 + 132 + impl Default for StatusManager { 133 + fn default() -> Self { 134 + Self::new() 135 + } 136 + } 137 + 138 + #[derive(Clone)] 139 + struct AppState { 140 + status_manager: StatusManager, 141 } 142 143 fn inject_live_reload_script(html_content: &str, socket_addr: SocketAddr, host: bool) -> String { ··· 163 164 pub async fn start_dev_web_server( 165 start_time: Instant, 166 + status_manager: StatusManager, 167 host: bool, 168 port: Option<u16>, 169 initial_error: Option<String>, 170 ) { 171 // TODO: The dist dir should be configurable 172 let dist_dir = "dist"; 173 174 // Send initial error if present 175 if let Some(error) = initial_error { 176 + status_manager.send_raw(WebSocketMessage { 177 data: json!({ 178 "type": StatusType::Error.to_string(), 179 "message": error ··· 241 .on_response(CustomOnResponse), 242 ) 243 .with_state(AppState { 244 + status_manager: status_manager.clone(), 245 }); 246 247 log_server_start( ··· 260 .unwrap(); 261 } 262 263 async fn add_dev_client_script( 264 req: Request, 265 next: Next, ··· 343 debug!("`{addr} connected."); 344 // finalize the upgrade process by returning upgrade callback. 345 // we can customize the callback by sending additional info such as address. 346 + ws.on_upgrade(move |socket| handle_socket(socket, addr, state.status_manager)) 347 } 348 349 + async fn handle_socket(socket: WebSocket, who: SocketAddr, status_manager: StatusManager) { 350 let (mut sender, mut receiver) = socket.split(); 351 352 // Send current persistent status to new connection if there is one 353 + if let Some(persistent_status) = status_manager.get_current().await { 354 + let _ = sender 355 + .send(Message::Text( 356 + json!({ 357 + "type": persistent_status.status_type.to_string(), 358 + "message": persistent_status.message 359 + }) 360 + .to_string() 361 + .into(), 362 + )) 363 + .await; 364 } 365 366 + let mut rx = status_manager.subscribe(); 367 368 tokio::select! { 369 _ = async { ··· 411 _ = terminate => {}, 412 } 413 } 414 + 415 + #[cfg(test)] 416 + mod tests { 417 + use super::*; 418 + 419 + #[tokio::test] 420 + async fn test_status_manager_update_error_persists() { 421 + let manager = StatusManager::new(); 422 + 423 + manager 424 + .update(StatusType::Error, "Something went wrong") 425 + .await; 426 + 427 + let status = manager.get_current().await; 428 + assert!(status.is_some()); 429 + let status = status.unwrap(); 430 + assert!(matches!(status.status_type, StatusType::Error)); 431 + assert_eq!(status.message, "Something went wrong"); 432 + } 433 + 434 + #[tokio::test] 435 + async fn test_status_manager_update_success_clears_error() { 436 + let manager = StatusManager::new(); 437 + 438 + // First set an error 439 + manager.update(StatusType::Error, "Build failed").await; 440 + assert!(manager.get_current().await.is_some()); 441 + 442 + // Then send success - should clear the error 443 + manager.update(StatusType::Success, "Build succeeded").await; 444 + assert!(manager.get_current().await.is_none()); 445 + } 446 + 447 + #[tokio::test] 448 + async fn test_status_manager_update_info_preserves_state() { 449 + let manager = StatusManager::new(); 450 + 451 + // Set an error 452 + manager.update(StatusType::Error, "Build failed").await; 453 + let original_status = manager.get_current().await; 454 + assert!(original_status.is_some()); 455 + 456 + // Send info - should preserve the error state 457 + manager.update(StatusType::Info, "Building...").await; 458 + let status = manager.get_current().await; 459 + assert!(status.is_some()); 460 + assert_eq!(status.unwrap().message, "Build failed"); 461 + } 462 + 463 + #[tokio::test] 464 + async fn test_status_manager_update_info_when_no_error() { 465 + let manager = StatusManager::new(); 466 + 467 + // No prior state 468 + assert!(manager.get_current().await.is_none()); 469 + 470 + // Send info - should remain None 471 + manager.update(StatusType::Info, "Building...").await; 472 + assert!(manager.get_current().await.is_none()); 473 + } 474 + 475 + #[tokio::test] 476 + async fn test_status_manager_subscribe_receives_messages() { 477 + let manager = StatusManager::new(); 478 + let mut rx = manager.subscribe(); 479 + 480 + manager.update(StatusType::Info, "Hello").await; 481 + 482 + let msg = rx.try_recv(); 483 + assert!(msg.is_ok()); 484 + let msg = msg.unwrap(); 485 + assert!(msg.data.contains("Hello")); 486 + assert!(msg.data.contains("info")); 487 + } 488 + 489 + #[tokio::test] 490 + async fn test_status_manager_multiple_subscribers() { 491 + let manager = StatusManager::new(); 492 + let mut rx1 = manager.subscribe(); 493 + let mut rx2 = manager.subscribe(); 494 + 495 + manager.update(StatusType::Success, "Done").await; 496 + 497 + // Both subscribers should receive the message 498 + assert!(rx1.try_recv().is_ok()); 499 + assert!(rx2.try_recv().is_ok()); 500 + } 501 + 502 + #[tokio::test] 503 + async fn test_status_manager_send_raw() { 504 + let manager = StatusManager::new(); 505 + let mut rx = manager.subscribe(); 506 + 507 + manager.send_raw(WebSocketMessage { 508 + data: r#"{"custom": "message"}"#.to_string(), 509 + }); 510 + 511 + let msg = rx.try_recv(); 512 + assert!(msg.is_ok()); 513 + assert_eq!(msg.unwrap().data, r#"{"custom": "message"}"#); 514 + } 515 + 516 + #[tokio::test] 517 + async fn test_status_manager_clone_shares_state() { 518 + let manager1 = StatusManager::new(); 519 + let manager2 = manager1.clone(); 520 + 521 + // Update via one clone 522 + manager1 523 + .update(StatusType::Error, "Error from clone 1") 524 + .await; 525 + 526 + // Should be visible via the other clone 527 + let status = manager2.get_current().await; 528 + assert!(status.is_some()); 529 + assert_eq!(status.unwrap().message, "Error from clone 1"); 530 + } 531 + 532 + #[tokio::test] 533 + async fn test_status_manager_clone_shares_broadcast() { 534 + let manager1 = StatusManager::new(); 535 + let manager2 = manager1.clone(); 536 + 537 + // Subscribe via one clone 538 + let mut rx = manager2.subscribe(); 539 + 540 + // Send via the other clone 541 + manager1.update(StatusType::Info, "From clone 1").await; 542 + 543 + // Should receive the message 544 + let msg = rx.try_recv(); 545 + assert!(msg.is_ok()); 546 + assert!(msg.unwrap().data.contains("From clone 1")); 547 + } 548 + }
+66 -27
crates/maudit-cli/src/dev.rs
··· 1 pub(crate) mod server; 2 3 mod build; 4 mod filterer; 5 6 use notify::{ ··· 9 }; 10 use notify_debouncer_full::{DebounceEventResult, DebouncedEvent, new_debouncer}; 11 use quanta::Instant; 12 - use server::WebSocketMessage; 13 - use std::{fs, path::Path}; 14 - use tokio::{ 15 - signal, 16 - sync::{broadcast, mpsc::channel}, 17 - task::JoinHandle, 18 }; 19 use tracing::{error, info}; 20 21 use crate::dev::build::BuildManager; 22 23 - pub async fn start_dev_env(cwd: &str, host: bool, port: Option<u16>) -> Result<(), Box<dyn std::error::Error>> { 24 let start_time = Instant::now(); 25 info!(name: "dev", "Preparing dev environmentโ€ฆ"); 26 27 - let (sender_websocket, _) = broadcast::channel::<WebSocketMessage>(100); 28 29 - // Create build manager (it will create its own status state internally) 30 - let build_manager = BuildManager::new(sender_websocket.clone()); 31 32 // Do initial build 33 info!(name: "build", "Doing initial buildโ€ฆ"); ··· 48 .collect::<Vec<_>>(); 49 50 let mut debouncer = new_debouncer( 51 - std::time::Duration::from_millis(100), 52 None, 53 move |result: DebounceEventResult| { 54 tx.blocking_send(result).unwrap_or(()); ··· 73 info!(name: "dev", "Starting web server..."); 74 web_server_thread = Some(tokio::spawn(server::start_dev_web_server( 75 start_time, 76 - sender_websocket.clone(), 77 host, 78 port, 79 None, 80 - build_manager.current_status(), 81 ))); 82 } 83 84 // Clone build manager for the file watcher task 85 let build_manager_watcher = build_manager.clone(); 86 - let sender_websocket_watcher = sender_websocket.clone(); 87 88 let file_watcher_task = tokio::spawn(async move { 89 let mut dev_server_started = initial_build_success; ··· 147 dev_server_handle = 148 Some(tokio::spawn(server::start_dev_web_server( 149 start_time, 150 - sender_websocket_watcher.clone(), 151 host, 152 port, 153 None, 154 - build_manager_watcher.current_status(), 155 ))); 156 } 157 Ok(false) => { ··· 162 } 163 } 164 } else { 165 - // Normal rebuild - spawn in background so file watcher can continue 166 - info!(name: "watch", "Files changed, rebuilding..."); 167 - let build_manager_clone = build_manager_watcher.clone(); 168 - tokio::spawn(async move { 169 - match build_manager_clone.start_build().await { 170 - Ok(_) => { 171 - // Build completed (success or failure already logged) 172 } 173 - Err(e) => { 174 - error!(name: "build", "Failed to start build: {}", e); 175 } 176 - } 177 - }); 178 } 179 } 180 }
··· 1 pub(crate) mod server; 2 3 mod build; 4 + mod dep_tracker; 5 mod filterer; 6 7 use notify::{ ··· 10 }; 11 use notify_debouncer_full::{DebounceEventResult, DebouncedEvent, new_debouncer}; 12 use quanta::Instant; 13 + use server::StatusManager; 14 + use std::{ 15 + fs, 16 + path::{Path, PathBuf}, 17 }; 18 + use tokio::{signal, sync::mpsc::channel, task::JoinHandle}; 19 use tracing::{error, info}; 20 21 use crate::dev::build::BuildManager; 22 23 + pub async fn start_dev_env( 24 + cwd: &str, 25 + host: bool, 26 + port: Option<u16>, 27 + ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { 28 let start_time = Instant::now(); 29 info!(name: "dev", "Preparing dev environmentโ€ฆ"); 30 31 + // Create status manager (handles WebSocket communication) 32 + let status_manager = StatusManager::new(); 33 34 + // Create build manager 35 + let build_manager = BuildManager::new(status_manager.clone()); 36 37 // Do initial build 38 info!(name: "build", "Doing initial buildโ€ฆ"); ··· 53 .collect::<Vec<_>>(); 54 55 let mut debouncer = new_debouncer( 56 + std::time::Duration::from_millis(200), // Longer debounce to better batch rapid file changes 57 None, 58 move |result: DebounceEventResult| { 59 tx.blocking_send(result).unwrap_or(()); ··· 78 info!(name: "dev", "Starting web server..."); 79 web_server_thread = Some(tokio::spawn(server::start_dev_web_server( 80 start_time, 81 + status_manager.clone(), 82 host, 83 port, 84 None, 85 ))); 86 } 87 88 // Clone build manager for the file watcher task 89 let build_manager_watcher = build_manager.clone(); 90 + let status_manager_watcher = status_manager.clone(); 91 92 let file_watcher_task = tokio::spawn(async move { 93 let mut dev_server_started = initial_build_success; ··· 151 dev_server_handle = 152 Some(tokio::spawn(server::start_dev_web_server( 153 start_time, 154 + status_manager_watcher.clone(), 155 host, 156 port, 157 None, 158 ))); 159 } 160 Ok(false) => { ··· 165 } 166 } 167 } else { 168 + // Normal rebuild - check if we need full recompilation or just rerun 169 + // Only collect paths from events that actually trigger a rebuild 170 + let mut changed_paths: Vec<PathBuf> = events.iter() 171 + .filter(|e| should_rebuild_for_event(e)) 172 + .flat_map(|e| e.paths.iter().cloned()) 173 + .collect(); 174 + 175 + // Deduplicate paths 176 + changed_paths.sort(); 177 + changed_paths.dedup(); 178 + 179 + if changed_paths.is_empty() { 180 + // No file changes, only directory changes - skip rebuild 181 + continue; 182 + } 183 + 184 + let needs_recompile = build_manager_watcher.needs_recompile(&changed_paths).await; 185 + 186 + if needs_recompile { 187 + // Need to recompile - spawn in background so file watcher can continue 188 + info!(name: "watch", "Files changed, rebuilding..."); 189 + let build_manager_clone = build_manager_watcher.clone(); 190 + let changed_paths_clone = changed_paths.clone(); 191 + tokio::spawn(async move { 192 + match build_manager_clone.start_build(Some(&changed_paths_clone)).await { 193 + Ok(_) => { 194 + // Build completed (success or failure already logged) 195 + } 196 + Err(e) => { 197 + error!(name: "build", "Failed to start build: {}", e); 198 + } 199 } 200 + }); 201 + } else { 202 + // Just rerun the binary without recompiling 203 + info!(name: "watch", "Non-dependency files changed, rerunning binary..."); 204 + let build_manager_clone = build_manager_watcher.clone(); 205 + let changed_paths_clone = changed_paths.clone(); 206 + tokio::spawn(async move { 207 + match build_manager_clone.rerun_binary(&changed_paths_clone).await { 208 + Ok(_) => { 209 + // Rerun completed (success or failure already logged) 210 + } 211 + Err(e) => { 212 + error!(name: "build", "Failed to rerun binary: {}", e); 213 + } 214 } 215 + }); 216 + } 217 } 218 } 219 }
+4
crates/maudit-macros/src/lib.rs
··· 330 impl maudit::route::InternalRoute for #struct_name { 331 #route_raw_impl 332 333 #variant_method 334 335 #sitemap_method
··· 330 impl maudit::route::InternalRoute for #struct_name { 331 #route_raw_impl 332 333 + fn source_file(&self) -> &'static str { 334 + file!() 335 + } 336 + 337 #variant_method 338 339 #sitemap_method
+3 -3
crates/oubli/src/archetypes/blog.rs
··· 1 //! Blog archetype. 2 //! Represents a markdown blog archetype, with an index page and individual entry pages. 3 use crate::layouts::layout; 4 - use maud::{Markup, html}; 5 use maudit::content::markdown_entry; 6 - use maudit::route::FullRoute; 7 use maudit::route::prelude::*; 8 9 pub fn blog_index_content<T: FullRoute>( 10 route: impl FullRoute, ··· 18 19 let markup = html! { 20 main { 21 - @for entry in &blog_entries.entries { 22 a href=(route.url(&BlogEntryParams { entry: entry.id.clone() }.into())) { 23 h2 { (entry.data(ctx).title) } 24 p { (entry.data(ctx).description) }
··· 1 //! Blog archetype. 2 //! Represents a markdown blog archetype, with an index page and individual entry pages. 3 use crate::layouts::layout; 4 + use maud::{html, Markup}; 5 use maudit::content::markdown_entry; 6 use maudit::route::prelude::*; 7 + use maudit::route::FullRoute; 8 9 pub fn blog_index_content<T: FullRoute>( 10 route: impl FullRoute, ··· 18 19 let markup = html! { 20 main { 21 + @for entry in blog_entries.entries() { 22 a href=(route.url(&BlogEntryParams { entry: entry.id.clone() }.into())) { 23 h2 { (entry.data(ctx).title) } 24 p { (entry.data(ctx).description) }
+3
e2e/README.md
··· 13 ## Running Tests 14 15 The tests will automatically: 16 1. Build the prefetch.js bundle (via `cargo xtask build-maudit-js`) 17 2. Start the Maudit dev server on the test fixture site 18 3. Run the tests ··· 46 ## Features Tested 47 48 ### Basic Prefetch 49 - Creating link elements with `rel="prefetch"` 50 - Preventing duplicate prefetches 51 - Skipping current page prefetch 52 - Blocking cross-origin prefetches 53 54 ### Prerendering (Chromium only) 55 - Creating `<script type="speculationrules">` elements 56 - Different eagerness levels (immediate, eager, moderate, conservative) 57 - Fallback to link prefetch on non-Chromium browsers
··· 13 ## Running Tests 14 15 The tests will automatically: 16 + 17 1. Build the prefetch.js bundle (via `cargo xtask build-maudit-js`) 18 2. Start the Maudit dev server on the test fixture site 19 3. Run the tests ··· 47 ## Features Tested 48 49 ### Basic Prefetch 50 + 51 - Creating link elements with `rel="prefetch"` 52 - Preventing duplicate prefetches 53 - Skipping current page prefetch 54 - Blocking cross-origin prefetches 55 56 ### Prerendering (Chromium only) 57 + 58 - Creating `<script type="speculationrules">` elements 59 - Different eagerness levels (immediate, eager, moderate, conservative) 60 - Fallback to link prefetch on non-Chromium browsers
+9
e2e/fixtures/hot-reload/Cargo.toml
···
··· 1 + [package] 2 + name = "fixtures-hot-reload" 3 + version = "0.1.0" 4 + edition = "2024" 5 + publish = false 6 + 7 + [dependencies] 8 + maudit.workspace = true 9 + maud.workspace = true
+1
e2e/fixtures/hot-reload/data.txt
···
··· 1 + Test data
+14
e2e/fixtures/hot-reload/src/main.rs
···
··· 1 + use maudit::{BuildOptions, BuildOutput, content_sources, coronate, routes}; 2 + 3 + mod pages { 4 + mod index; 5 + pub use index::Index; 6 + } 7 + 8 + fn main() -> Result<BuildOutput, Box<dyn std::error::Error>> { 9 + coronate( 10 + routes![pages::Index], 11 + content_sources![], 12 + BuildOptions::default(), 13 + ) 14 + }
+30
e2e/fixtures/hot-reload/src/pages/index.rs
···
··· 1 + use maud::html; 2 + use maudit::route::prelude::*; 3 + 4 + #[route("/")] 5 + pub struct Index; 6 + 7 + impl Route for Index { 8 + fn render(&self, _ctx: &mut PageContext) -> impl Into<RenderResult> { 9 + Ok(html! { 10 + html { 11 + head { 12 + title { "Hot Reload Test" } 13 + } 14 + body { 15 + h1 id="title" { "Original Title" } 16 + div id="content" { 17 + p id="message" { "Original message" } 18 + ul id="list" { 19 + li { "Item 1" } 20 + li { "Item 2" } 21 + } 22 + } 23 + footer { 24 + p { "Footer content" } 25 + } 26 + } 27 + } 28 + }) 29 + } 30 + }
+10
e2e/fixtures/incremental-build/Cargo.toml
···
··· 1 + [package] 2 + name = "fixtures-incremental-build" 3 + version = "0.1.0" 4 + edition = "2024" 5 + publish = false 6 + 7 + [dependencies] 8 + maudit.workspace = true 9 + maud.workspace = true 10 + serde.workspace = true
+8
e2e/fixtures/incremental-build/content/articles/first-post.md
···
··· 1 + --- 2 + title: "First Post" 3 + description: "This is the first post" 4 + --- 5 + 6 + # First Post 7 + 8 + This is the content of the first post.
+8
e2e/fixtures/incremental-build/content/articles/second-post.md
···
··· 1 + --- 2 + title: "Second Post" 3 + description: "This is the second post" 4 + --- 5 + 6 + # Second Post 7 + 8 + This is the content of the second post.
+8
e2e/fixtures/incremental-build/content/articles/third-post.md
···
··· 1 + --- 2 + title: "Third Post" 3 + description: "This is the third post" 4 + --- 5 + 6 + # Third Post 7 + 8 + This is the content of the third post.
+3
e2e/fixtures/incremental-build/src/assets/about-content.txt
···
··· 1 + Learn more about us 2 + 3 + <!-- test-1-init -->
+2
e2e/fixtures/incremental-build/src/assets/about.js
···
··· 1 + // About script 2 + console.log("About script loaded");
e2e/fixtures/incremental-build/src/assets/bg.png

This is a binary file and will not be displayed.

+10
e2e/fixtures/incremental-build/src/assets/blog.css
···
··· 1 + /* Blog styles */ 2 + .blog-post { 3 + margin: 20px; 4 + } 5 + 6 + /* Background image referenced via url() - tests CSS asset dependency tracking */ 7 + .blog-header { 8 + background-image: url("./bg.png"); 9 + background-size: cover; 10 + }
+8
e2e/fixtures/incremental-build/src/assets/icons/blog-icon.css
···
··· 1 + /* Blog icon styles */ 2 + .blog-icon { 3 + width: 24px; 4 + height: 24px; 5 + display: inline-block; 6 + } 7 + 8 + /* init */
e2e/fixtures/incremental-build/src/assets/logo.png

This is a binary file and will not be displayed.

+5
e2e/fixtures/incremental-build/src/assets/main.js
···
··· 1 + // Main script 2 + import { greet } from "./utils.js"; 3 + 4 + console.log("Main script loaded"); 5 + console.log(greet("World"));
+13
e2e/fixtures/incremental-build/src/assets/styles.css
···
··· 1 + /* Main styles */ 2 + body { 3 + font-family: sans-serif; 4 + } 5 + /* test7 */ 6 + /* test */ 7 + /* test2 */ 8 + /* test4 */ 9 + /* change1 */ 10 + /* change1 */ 11 + /* change1 */ 12 + /* change1 */ 13 + /* change1 */
e2e/fixtures/incremental-build/src/assets/team.png

This is a binary file and will not be displayed.

+4
e2e/fixtures/incremental-build/src/assets/utils.js
···
··· 1 + // Utility functions 2 + export function greet(name) { 3 + return `Hello, ${name}!`; 4 + }
+12
e2e/fixtures/incremental-build/src/content.rs
···
··· 1 + use maudit::content::{glob_markdown, markdown_entry}; 2 + 3 + #[markdown_entry] 4 + #[derive(Debug, Clone)] 5 + pub struct ArticleContent { 6 + pub title: String, 7 + pub description: String, 8 + } 9 + 10 + pub fn load_articles() -> Vec<maudit::content::Entry<ArticleContent>> { 11 + glob_markdown("content/articles/*.md") 12 + }
+20
e2e/fixtures/incremental-build/src/main.rs
···
··· 1 + use maudit::{BuildOptions, BuildOutput, content_sources, coronate, routes}; 2 + 3 + mod content; 4 + mod pages; 5 + 6 + fn main() -> Result<BuildOutput, Box<dyn std::error::Error>> { 7 + coronate( 8 + routes![ 9 + pages::index::Index, 10 + pages::about::About, 11 + pages::blog::Blog, 12 + pages::articles::Articles, 13 + pages::article::Article 14 + ], 15 + content_sources![ 16 + "articles" => content::load_articles() 17 + ], 18 + BuildOptions::default(), 19 + ) 20 + }
+42
e2e/fixtures/incremental-build/src/pages/about.rs
···
··· 1 + use maud::html; 2 + use maudit::route::prelude::*; 3 + use std::time::{SystemTime, UNIX_EPOCH}; 4 + 5 + use super::helpers; 6 + 7 + // Include content from external file - this creates a compile-time dependency 8 + const ABOUT_CONTENT: &str = include_str!("../assets/about-content.txt"); 9 + 10 + #[route("/about")] 11 + pub struct About; 12 + 13 + impl Route for About { 14 + fn render(&self, ctx: &mut PageContext) -> impl Into<RenderResult> { 15 + let _image = ctx.assets.add_image("src/assets/team.png"); 16 + let _script = ctx.assets.add_script("src/assets/about.js"); 17 + // Shared style with index page (for testing shared assets) 18 + let _style = ctx.assets.add_style("src/assets/styles.css"); 19 + 20 + // Use shared helper function 21 + let greeting = helpers::get_greeting(); 22 + 23 + // Generate a unique build ID - uses nanoseconds for uniqueness 24 + let build_id = SystemTime::now() 25 + .duration_since(UNIX_EPOCH) 26 + .map(|d| d.as_nanos().to_string()) 27 + .unwrap_or_else(|_| "0".to_string()); 28 + 29 + html! { 30 + html { 31 + head { 32 + title { "About Page" } 33 + } 34 + body data-build-id=(build_id) { 35 + h1 id="title" { "About Us" } 36 + p id="greeting" { (greeting) } 37 + p id="content" { (ABOUT_CONTENT.trim()) } 38 + } 39 + } 40 + } 41 + } 42 + }
+56
e2e/fixtures/incremental-build/src/pages/article.rs
···
··· 1 + use maud::html; 2 + use maudit::route::prelude::*; 3 + use std::time::{SystemTime, UNIX_EPOCH}; 4 + 5 + use crate::content::ArticleContent; 6 + 7 + /// Dynamic route for individual articles - uses `get_entry()` which tracks only the accessed file 8 + #[route("/articles/[slug]")] 9 + pub struct Article; 10 + 11 + #[derive(Params, Clone)] 12 + pub struct ArticleParams { 13 + slug: String, 14 + } 15 + 16 + impl Route<ArticleParams> for Article { 17 + fn pages(&self, ctx: &mut DynamicRouteContext) -> Pages<ArticleParams> { 18 + let articles = ctx.content.get_source::<ArticleContent>("articles"); 19 + 20 + // into_pages tracks all files (for generating the list of pages) 21 + articles.into_pages(|entry| { 22 + Page::from_params(ArticleParams { 23 + slug: entry.id.clone(), 24 + }) 25 + }) 26 + } 27 + 28 + fn render(&self, ctx: &mut PageContext) -> impl Into<RenderResult> { 29 + let slug = ctx.params::<ArticleParams>().slug.clone(); 30 + let articles = ctx.content.get_source::<ArticleContent>("articles"); 31 + 32 + // get_entry tracks only THIS specific file 33 + let article = articles.get_entry(&slug); 34 + 35 + // Generate a unique build ID - uses nanoseconds for uniqueness 36 + let build_id = SystemTime::now() 37 + .duration_since(UNIX_EPOCH) 38 + .map(|d| d.as_nanos().to_string()) 39 + .unwrap_or_else(|_| "0".to_string()); 40 + 41 + html! { 42 + html { 43 + head { 44 + title { (article.data(ctx).title) } 45 + } 46 + body data-build-id=(build_id) { 47 + h1 id="title" { (article.data(ctx).title) } 48 + p id="description" { (article.data(ctx).description) } 49 + div id="content" { 50 + (maud::PreEscaped(article.render(ctx))) 51 + } 52 + } 53 + } 54 + } 55 + } 56 + }
+46
e2e/fixtures/incremental-build/src/pages/articles.rs
···
··· 1 + use maud::html; 2 + use maudit::route::prelude::*; 3 + use std::time::{SystemTime, UNIX_EPOCH}; 4 + 5 + use crate::content::ArticleContent; 6 + 7 + /// Route that lists all articles - uses `entries()` which tracks all content files 8 + #[route("/articles")] 9 + pub struct Articles; 10 + 11 + impl Route for Articles { 12 + fn render(&self, ctx: &mut PageContext) -> impl Into<RenderResult> { 13 + let articles = ctx.content.get_source::<ArticleContent>("articles"); 14 + 15 + // Using entries() tracks ALL content files in the source 16 + let article_list: Vec<_> = articles.entries().iter().collect(); 17 + 18 + // Generate a unique build ID - uses nanoseconds for uniqueness 19 + let build_id = SystemTime::now() 20 + .duration_since(UNIX_EPOCH) 21 + .map(|d| d.as_nanos().to_string()) 22 + .unwrap_or_else(|_| "0".to_string()); 23 + 24 + html! { 25 + html { 26 + head { 27 + title { "Articles" } 28 + } 29 + body data-build-id=(build_id) { 30 + h1 id="title" { "Articles" } 31 + ul id="article-list" { 32 + @for article in article_list { 33 + li { 34 + a href=(format!("/articles/{}", article.id)) { 35 + (article.data(ctx).title) 36 + } 37 + " - " 38 + (article.data(ctx).description) 39 + } 40 + } 41 + } 42 + } 43 + } 44 + } 45 + } 46 + }
+31
e2e/fixtures/incremental-build/src/pages/blog.rs
···
··· 1 + use maud::html; 2 + use maudit::route::prelude::*; 3 + use std::time::{SystemTime, UNIX_EPOCH}; 4 + 5 + #[route("/blog")] 6 + pub struct Blog; 7 + 8 + impl Route for Blog { 9 + fn render(&self, ctx: &mut PageContext) -> impl Into<RenderResult> { 10 + let _style = ctx.assets.add_style("src/assets/blog.css"); 11 + let _icon_style = ctx.assets.add_style("src/assets/icons/blog-icon.css"); 12 + 13 + // Generate a unique build ID - uses nanoseconds for uniqueness 14 + let build_id = SystemTime::now() 15 + .duration_since(UNIX_EPOCH) 16 + .map(|d| d.as_nanos().to_string()) 17 + .unwrap_or_else(|_| "0".to_string()); 18 + 19 + html! { 20 + html { 21 + head { 22 + title { "Blog Page" } 23 + } 24 + body data-build-id=(build_id) { 25 + h1 id="title" { "Blog" } 26 + p id="content" { "Read our latest posts" } 27 + } 28 + } 29 + } 30 + } 31 + }
+3
e2e/fixtures/incremental-build/src/pages/helpers.rs
···
··· 1 + pub fn get_greeting() -> &'static str { 2 + "Welcome to our site!" 3 + }
+32
e2e/fixtures/incremental-build/src/pages/index.rs
···
··· 1 + use maud::html; 2 + use maudit::route::prelude::*; 3 + use std::time::{SystemTime, UNIX_EPOCH}; 4 + 5 + #[route("/")] 6 + pub struct Index; 7 + 8 + impl Route for Index { 9 + fn render(&self, ctx: &mut PageContext) -> impl Into<RenderResult> { 10 + let _image = ctx.assets.add_image("src/assets/logo.png"); 11 + let _script = ctx.assets.add_script("src/assets/main.js"); 12 + let _style = ctx.assets.add_style("src/assets/styles.css"); 13 + 14 + // Generate a unique build ID - uses nanoseconds for uniqueness 15 + let build_id = SystemTime::now() 16 + .duration_since(UNIX_EPOCH) 17 + .map(|d| d.as_nanos().to_string()) 18 + .unwrap_or_else(|_| "0".to_string()); 19 + 20 + html! { 21 + html { 22 + head { 23 + title { "Home Page" } 24 + } 25 + body data-build-id=(build_id) { 26 + h1 id="title" { "Home Page" } 27 + p id="content" { "Welcome to the home page" } 28 + } 29 + } 30 + } 31 + } 32 + }
+6
e2e/fixtures/incremental-build/src/pages/mod.rs
···
··· 1 + pub mod about; 2 + pub mod article; 3 + pub mod articles; 4 + pub mod blog; 5 + pub mod helpers; 6 + pub mod index;
+2 -1
e2e/fixtures/prefetch-prerender/Cargo.toml
··· 1 [package] 2 - name = "prefetch-prerender" 3 version = "0.1.0" 4 edition = "2024" 5 6 [dependencies] 7 maudit.workspace = true
··· 1 [package] 2 + name = "fixtures-prefetch-prerender" 3 version = "0.1.0" 4 edition = "2024" 5 + publish = false 6 7 [dependencies] 8 maudit.workspace = true
+1 -1
e2e/fixtures/prefetch-prerender/src/main.rs
··· 1 - use maudit::{content_sources, coronate, routes, BuildOptions, BuildOutput}; 2 3 mod pages { 4 mod about;
··· 1 + use maudit::{BuildOptions, BuildOutput, content_sources, coronate, routes}; 2 3 mod pages { 4 mod about;
+166
e2e/tests/hot-reload.spec.ts
···
··· 1 + import { expect } from "@playwright/test"; 2 + import { createTestWithFixture } from "./test-utils"; 3 + import { readFileSync, writeFileSync } from "node:fs"; 4 + import { resolve, dirname } from "node:path"; 5 + import { fileURLToPath } from "node:url"; 6 + 7 + const __filename = fileURLToPath(import.meta.url); 8 + const __dirname = dirname(__filename); 9 + 10 + // Create test instance with hot-reload fixture 11 + const test = createTestWithFixture("hot-reload"); 12 + 13 + test.describe.configure({ mode: "serial" }); 14 + 15 + /** 16 + * Wait for dev server to complete a build/rerun by polling logs 17 + */ 18 + async function waitForBuildComplete(devServer: any, timeoutMs = 20000): Promise<string[]> { 19 + const startTime = Date.now(); 20 + 21 + while (Date.now() - startTime < timeoutMs) { 22 + const logs = devServer.getLogs(100); 23 + const logsText = logs.join("\n").toLowerCase(); 24 + 25 + // Look for completion messages 26 + if ( 27 + logsText.includes("finished") || 28 + logsText.includes("rerun finished") || 29 + logsText.includes("build finished") 30 + ) { 31 + return logs; 32 + } 33 + 34 + // Wait 100ms before checking again 35 + await new Promise((resolve) => setTimeout(resolve, 100)); 36 + } 37 + 38 + throw new Error(`Build did not complete within ${timeoutMs}ms`); 39 + } 40 + 41 + test.describe("Hot Reload", () => { 42 + // Increase timeout for these tests since they involve compilation 43 + test.setTimeout(60000); 44 + 45 + const fixturePath = resolve(__dirname, "..", "fixtures", "hot-reload"); 46 + const indexPath = resolve(fixturePath, "src", "pages", "index.rs"); 47 + const mainPath = resolve(fixturePath, "src", "main.rs"); 48 + const dataPath = resolve(fixturePath, "data.txt"); 49 + let originalIndexContent: string; 50 + let originalMainContent: string; 51 + let originalDataContent: string; 52 + 53 + test.beforeAll(async () => { 54 + // Save original content 55 + originalIndexContent = readFileSync(indexPath, "utf-8"); 56 + originalMainContent = readFileSync(mainPath, "utf-8"); 57 + originalDataContent = readFileSync(dataPath, "utf-8"); 58 + 59 + // Ensure files are in original state 60 + writeFileSync(indexPath, originalIndexContent, "utf-8"); 61 + writeFileSync(mainPath, originalMainContent, "utf-8"); 62 + writeFileSync(dataPath, originalDataContent, "utf-8"); 63 + }); 64 + 65 + test.afterEach(async ({ devServer }) => { 66 + // Restore original content after each test 67 + writeFileSync(indexPath, originalIndexContent, "utf-8"); 68 + writeFileSync(mainPath, originalMainContent, "utf-8"); 69 + writeFileSync(dataPath, originalDataContent, "utf-8"); 70 + 71 + // Only wait for build if devServer is available (startup might have failed) 72 + if (devServer) { 73 + try { 74 + devServer.clearLogs(); 75 + await waitForBuildComplete(devServer); 76 + } catch (error) { 77 + console.warn("Failed to wait for build completion in afterEach:", error); 78 + } 79 + } 80 + }); 81 + 82 + test.afterAll(async () => { 83 + // Restore original content 84 + writeFileSync(indexPath, originalIndexContent, "utf-8"); 85 + writeFileSync(mainPath, originalMainContent, "utf-8"); 86 + writeFileSync(dataPath, originalDataContent, "utf-8"); 87 + }); 88 + 89 + test("should recompile when Rust code changes (dependencies)", async ({ page, devServer }) => { 90 + await page.goto(devServer.url); 91 + 92 + // Verify initial content 93 + await expect(page.locator("#title")).toHaveText("Original Title"); 94 + 95 + // Clear logs to track what happens after this point 96 + devServer.clearLogs(); 97 + 98 + // Modify main.rs - this is a tracked dependency, should trigger recompile 99 + const modifiedMain = originalMainContent.replace( 100 + "BuildOptions::default()", 101 + "BuildOptions::default() // Modified comment", 102 + ); 103 + writeFileSync(mainPath, modifiedMain, "utf-8"); 104 + 105 + // Wait for rebuild to complete 106 + const logs = await waitForBuildComplete(devServer, 20000); 107 + const logsText = logs.join("\n"); 108 + 109 + // Check logs to verify it actually recompiled (ran cargo) 110 + expect(logsText).toContain("rebuilding"); 111 + // Make sure it didn't just rerun the binary 112 + expect(logsText.toLowerCase()).not.toContain("rerunning binary"); 113 + }); 114 + 115 + test("should rerun without recompile when non-dependency files change", async ({ 116 + page, 117 + devServer, 118 + }) => { 119 + await page.goto(devServer.url); 120 + 121 + // Verify initial content 122 + await expect(page.locator("#title")).toHaveText("Original Title"); 123 + 124 + // Clear logs to track what happens after this point 125 + devServer.clearLogs(); 126 + 127 + // Modify data.txt - this file is NOT in the .d dependencies 128 + // So it should trigger a rerun without recompilation 129 + writeFileSync(dataPath, "Modified data", "utf-8"); 130 + 131 + // Wait for build/rerun to complete 132 + const logs = await waitForBuildComplete(devServer, 20000); 133 + const logsText = logs.join("\n"); 134 + 135 + // Should see "rerunning binary" message (case insensitive) 136 + const hasRerunMessage = logsText.toLowerCase().includes("rerunning binary"); 137 + expect(hasRerunMessage).toBe(true); 138 + 139 + // Should NOT see cargo-related rebuild messages (compiling, building crate) 140 + // Note: "Rebuilding N affected routes" is fine - that's the incremental build system 141 + expect(logsText.toLowerCase()).not.toContain("compiling"); 142 + expect(logsText.toLowerCase()).not.toContain("cargo build"); 143 + }); 144 + 145 + test("should show updated content after file changes", async ({ page, devServer }) => { 146 + await page.goto(devServer.url); 147 + 148 + // Verify initial content 149 + await expect(page.locator("#title")).toHaveText("Original Title"); 150 + 151 + // Prepare to wait for actual reload by waiting for the same URL to reload 152 + const currentUrl = page.url(); 153 + 154 + // Modify the file 155 + const modifiedContent = originalIndexContent.replace( 156 + 'h1 id="title" { "Original Title" }', 157 + 'h1 id="title" { "Another Update" }', 158 + ); 159 + writeFileSync(indexPath, modifiedContent, "utf-8"); 160 + 161 + // Wait for the page to actually reload on the same URL 162 + await page.waitForURL(currentUrl, { timeout: 15000 }); 163 + // Verify the updated content 164 + await expect(page.locator("#title")).toHaveText("Another Update", { timeout: 15000 }); 165 + }); 166 + });
+1024
e2e/tests/incremental-build.spec.ts
···
··· 1 + import { expect } from "@playwright/test"; 2 + import { createTestWithFixture } from "./test-utils"; 3 + import { readFileSync, writeFileSync, renameSync, existsSync } from "node:fs"; 4 + import { resolve, dirname } from "node:path"; 5 + import { fileURLToPath } from "node:url"; 6 + 7 + const __filename = fileURLToPath(import.meta.url); 8 + const __dirname = dirname(__filename); 9 + 10 + // Create test instance with incremental-build fixture 11 + const test = createTestWithFixture("incremental-build"); 12 + 13 + // Run tests serially since they share state; allow retries for timing-sensitive tests 14 + test.describe.configure({ mode: "serial", retries: 2 }); 15 + 16 + /** 17 + * Wait for dev server to complete a build by polling logs. 18 + * Returns logs once build is finished. 19 + */ 20 + async function waitForBuildComplete(devServer: any, timeoutMs = 30000): Promise<string[]> { 21 + const startTime = Date.now(); 22 + const pollInterval = 50; 23 + 24 + // Phase 1: Wait for build to start 25 + while (Date.now() - startTime < timeoutMs) { 26 + const logs = devServer.getLogs(200); 27 + const logsText = logs.join("\n").toLowerCase(); 28 + 29 + if ( 30 + logsText.includes("rerunning") || 31 + logsText.includes("rebuilding") || 32 + logsText.includes("files changed") 33 + ) { 34 + break; 35 + } 36 + 37 + await new Promise((r) => setTimeout(r, pollInterval)); 38 + } 39 + 40 + // Phase 2: Wait for build to finish 41 + while (Date.now() - startTime < timeoutMs) { 42 + const logs = devServer.getLogs(200); 43 + const logsText = logs.join("\n").toLowerCase(); 44 + 45 + if ( 46 + logsText.includes("finished") || 47 + logsText.includes("rerun finished") || 48 + logsText.includes("build finished") 49 + ) { 50 + return logs; 51 + } 52 + 53 + await new Promise((r) => setTimeout(r, pollInterval)); 54 + } 55 + 56 + console.log("TIMEOUT - logs seen:", devServer.getLogs(50)); 57 + throw new Error(`Build did not complete within ${timeoutMs}ms`); 58 + } 59 + 60 + /** 61 + * Wait for the dev server to become idle (no builds in progress). 62 + * This polls build IDs until they stop changing. 63 + */ 64 + async function waitForIdle(htmlPaths: Record<string, string>, stableMs = 200): Promise<void> { 65 + let lastIds = recordBuildIds(htmlPaths); 66 + let stableTime = 0; 67 + 68 + while (stableTime < stableMs) { 69 + await new Promise((r) => setTimeout(r, 50)); 70 + const currentIds = recordBuildIds(htmlPaths); 71 + 72 + const allSame = Object.keys(lastIds).every( 73 + (key) => lastIds[key] === currentIds[key] 74 + ); 75 + 76 + if (allSame) { 77 + stableTime += 50; 78 + } else { 79 + stableTime = 0; 80 + lastIds = currentIds; 81 + } 82 + } 83 + } 84 + 85 + /** 86 + * Wait for a specific HTML file's build ID to change from a known value. 87 + * This is more reliable than arbitrary sleeps. 88 + */ 89 + async function waitForBuildIdChange( 90 + htmlPath: string, 91 + previousId: string | null, 92 + timeoutMs = 30000, 93 + ): Promise<string> { 94 + const startTime = Date.now(); 95 + const pollInterval = 50; 96 + 97 + while (Date.now() - startTime < timeoutMs) { 98 + const currentId = getBuildId(htmlPath); 99 + if (currentId !== null && currentId !== previousId) { 100 + // Small delay to let any concurrent writes settle 101 + await new Promise((r) => setTimeout(r, 100)); 102 + return currentId; 103 + } 104 + await new Promise((r) => setTimeout(r, pollInterval)); 105 + } 106 + 107 + throw new Error(`Build ID did not change within ${timeoutMs}ms`); 108 + } 109 + 110 + /** 111 + * Extract the build ID from an HTML file. 112 + */ 113 + function getBuildId(htmlPath: string): string | null { 114 + try { 115 + const content = readFileSync(htmlPath, "utf-8"); 116 + const match = content.match(/data-build-id="(\d+)"/); 117 + return match ? match[1] : null; 118 + } catch { 119 + return null; 120 + } 121 + } 122 + 123 + /** 124 + * Check if logs indicate incremental build was used 125 + */ 126 + function isIncrementalBuild(logs: string[]): boolean { 127 + return logs.join("\n").toLowerCase().includes("incremental build"); 128 + } 129 + 130 + /** 131 + * Get the number of affected routes from logs 132 + */ 133 + function getAffectedRouteCount(logs: string[]): number { 134 + const logsText = logs.join("\n"); 135 + const match = logsText.match(/Rebuilding (\d+) affected routes/i); 136 + return match ? parseInt(match[1], 10) : -1; 137 + } 138 + 139 + /** 140 + * Record build IDs for all pages 141 + */ 142 + function recordBuildIds(htmlPaths: Record<string, string>): Record<string, string | null> { 143 + const ids: Record<string, string | null> = {}; 144 + for (const [name, path] of Object.entries(htmlPaths)) { 145 + ids[name] = getBuildId(path); 146 + } 147 + return ids; 148 + } 149 + 150 + /** 151 + * Trigger a change and wait for build to complete. 152 + * Returns logs from the build. 153 + */ 154 + async function triggerAndWaitForBuild( 155 + devServer: any, 156 + modifyFn: () => void, 157 + timeoutMs = 30000, 158 + ): Promise<string[]> { 159 + devServer.clearLogs(); 160 + modifyFn(); 161 + return await waitForBuildComplete(devServer, timeoutMs); 162 + } 163 + 164 + /** 165 + * Set up incremental build state by triggering two builds. 166 + * First build establishes state, second ensures state is populated. 167 + * Returns build IDs recorded after the second build completes and server is idle. 168 + * 169 + * Note: We don't assert incremental here - the actual test will verify that. 170 + * This is because on first test run the server might still be initializing. 171 + */ 172 + async function setupIncrementalState( 173 + devServer: any, 174 + modifyFn: (suffix: string) => void, 175 + htmlPaths: Record<string, string>, 176 + expectedChangedRoute: string, // Which route we expect to change 177 + ): Promise<Record<string, string | null>> { 178 + // First change: triggers build (establishes state) 179 + const beforeInit = getBuildId(htmlPaths[expectedChangedRoute]); 180 + await triggerAndWaitForBuild(devServer, () => modifyFn("init")); 181 + await waitForBuildIdChange(htmlPaths[expectedChangedRoute], beforeInit); 182 + 183 + // Second change: state should now exist for incremental builds 184 + const beforeSetup = getBuildId(htmlPaths[expectedChangedRoute]); 185 + await triggerAndWaitForBuild(devServer, () => modifyFn("setup")); 186 + await waitForBuildIdChange(htmlPaths[expectedChangedRoute], beforeSetup); 187 + 188 + // Wait for server to become completely idle before recording baseline 189 + await waitForIdle(htmlPaths); 190 + 191 + return recordBuildIds(htmlPaths); 192 + } 193 + 194 + test.describe("Incremental Build", () => { 195 + test.setTimeout(180000); 196 + 197 + const fixturePath = resolve(__dirname, "..", "fixtures", "incremental-build"); 198 + 199 + // Asset paths 200 + const assets = { 201 + blogCss: resolve(fixturePath, "src", "assets", "blog.css"), 202 + utilsJs: resolve(fixturePath, "src", "assets", "utils.js"), 203 + mainJs: resolve(fixturePath, "src", "assets", "main.js"), 204 + aboutJs: resolve(fixturePath, "src", "assets", "about.js"), 205 + stylesCss: resolve(fixturePath, "src", "assets", "styles.css"), 206 + logoPng: resolve(fixturePath, "src", "assets", "logo.png"), 207 + teamPng: resolve(fixturePath, "src", "assets", "team.png"), 208 + bgPng: resolve(fixturePath, "src", "assets", "bg.png"), 209 + }; 210 + 211 + // Content file paths (for granular content tracking tests) 212 + const contentFiles = { 213 + firstPost: resolve(fixturePath, "content", "articles", "first-post.md"), 214 + secondPost: resolve(fixturePath, "content", "articles", "second-post.md"), 215 + thirdPost: resolve(fixturePath, "content", "articles", "third-post.md"), 216 + }; 217 + 218 + // Output HTML paths 219 + const htmlPaths = { 220 + index: resolve(fixturePath, "dist", "index.html"), 221 + about: resolve(fixturePath, "dist", "about", "index.html"), 222 + blog: resolve(fixturePath, "dist", "blog", "index.html"), 223 + articles: resolve(fixturePath, "dist", "articles", "index.html"), 224 + articleFirst: resolve(fixturePath, "dist", "articles", "first-post", "index.html"), 225 + articleSecond: resolve(fixturePath, "dist", "articles", "second-post", "index.html"), 226 + articleThird: resolve(fixturePath, "dist", "articles", "third-post", "index.html"), 227 + }; 228 + 229 + // Original content storage 230 + const originals: Record<string, string | Buffer> = {}; 231 + 232 + test.beforeAll(async () => { 233 + // Store original content for all assets we might modify 234 + originals.blogCss = readFileSync(assets.blogCss, "utf-8"); 235 + originals.utilsJs = readFileSync(assets.utilsJs, "utf-8"); 236 + originals.mainJs = readFileSync(assets.mainJs, "utf-8"); 237 + originals.aboutJs = readFileSync(assets.aboutJs, "utf-8"); 238 + originals.stylesCss = readFileSync(assets.stylesCss, "utf-8"); 239 + originals.logoPng = readFileSync(assets.logoPng); // binary 240 + originals.teamPng = readFileSync(assets.teamPng); // binary 241 + originals.bgPng = readFileSync(assets.bgPng); // binary 242 + // Content files 243 + originals.firstPost = readFileSync(contentFiles.firstPost, "utf-8"); 244 + originals.secondPost = readFileSync(contentFiles.secondPost, "utf-8"); 245 + originals.thirdPost = readFileSync(contentFiles.thirdPost, "utf-8"); 246 + }); 247 + 248 + test.afterAll(async () => { 249 + // Restore all original content 250 + writeFileSync(assets.blogCss, originals.blogCss); 251 + writeFileSync(assets.utilsJs, originals.utilsJs); 252 + writeFileSync(assets.mainJs, originals.mainJs); 253 + writeFileSync(assets.aboutJs, originals.aboutJs); 254 + writeFileSync(assets.stylesCss, originals.stylesCss); 255 + writeFileSync(assets.logoPng, originals.logoPng); 256 + writeFileSync(assets.teamPng, originals.teamPng); 257 + writeFileSync(assets.bgPng, originals.bgPng); 258 + // Restore content files 259 + writeFileSync(contentFiles.firstPost, originals.firstPost); 260 + writeFileSync(contentFiles.secondPost, originals.secondPost); 261 + writeFileSync(contentFiles.thirdPost, originals.thirdPost); 262 + }); 263 + 264 + // ============================================================ 265 + // TEST 1: Direct CSS dependency (blog.css โ†’ /blog only) 266 + // ============================================================ 267 + test("CSS file change rebuilds only routes using it", async ({ devServer }) => { 268 + let testCounter = 0; 269 + 270 + function modifyFile(suffix: string) { 271 + testCounter++; 272 + writeFileSync(assets.blogCss, originals.blogCss + `\n/* test-${testCounter}-${suffix} */`); 273 + } 274 + 275 + const before = await setupIncrementalState(devServer, modifyFile, htmlPaths, "blog"); 276 + expect(before.index).not.toBeNull(); 277 + expect(before.about).not.toBeNull(); 278 + expect(before.blog).not.toBeNull(); 279 + 280 + // Trigger the final change and wait for build 281 + const logs = await triggerAndWaitForBuild(devServer, () => modifyFile("final")); 282 + await waitForBuildIdChange(htmlPaths.blog, before.blog); 283 + 284 + // Verify incremental build with 1 route 285 + expect(isIncrementalBuild(logs)).toBe(true); 286 + expect(getAffectedRouteCount(logs)).toBe(1); 287 + 288 + // Verify only blog was rebuilt 289 + const after = recordBuildIds(htmlPaths); 290 + expect(after.index).toBe(before.index); 291 + expect(after.about).toBe(before.about); 292 + expect(after.blog).not.toBe(before.blog); 293 + }); 294 + 295 + // ============================================================ 296 + // TEST 2: Transitive JS dependency (utils.js โ†’ main.js โ†’ /) 297 + // ============================================================ 298 + test("transitive JS dependency change rebuilds affected routes", async ({ devServer }) => { 299 + let testCounter = 0; 300 + 301 + function modifyFile(suffix: string) { 302 + testCounter++; 303 + writeFileSync(assets.utilsJs, originals.utilsJs + `\n// test-${testCounter}-${suffix}`); 304 + } 305 + 306 + const before = await setupIncrementalState(devServer, modifyFile, htmlPaths, "index"); 307 + expect(before.index).not.toBeNull(); 308 + 309 + const logs = await triggerAndWaitForBuild(devServer, () => modifyFile("final")); 310 + await waitForBuildIdChange(htmlPaths.index, before.index); 311 + 312 + // Verify incremental build with 1 route 313 + expect(isIncrementalBuild(logs)).toBe(true); 314 + expect(getAffectedRouteCount(logs)).toBe(1); 315 + 316 + // Only index should be rebuilt (uses main.js which imports utils.js) 317 + const after = recordBuildIds(htmlPaths); 318 + expect(after.about).toBe(before.about); 319 + expect(after.blog).toBe(before.blog); 320 + expect(after.index).not.toBe(before.index); 321 + }); 322 + 323 + // ============================================================ 324 + // TEST 3: Direct JS entry point change (about.js โ†’ /about) 325 + // ============================================================ 326 + test("direct JS entry point change rebuilds only routes using it", async ({ devServer }) => { 327 + let testCounter = 0; 328 + 329 + function modifyFile(suffix: string) { 330 + testCounter++; 331 + writeFileSync(assets.aboutJs, originals.aboutJs + `\n// test-${testCounter}-${suffix}`); 332 + } 333 + 334 + const before = await setupIncrementalState(devServer, modifyFile, htmlPaths, "about"); 335 + expect(before.about).not.toBeNull(); 336 + 337 + const logs = await triggerAndWaitForBuild(devServer, () => modifyFile("final")); 338 + await waitForBuildIdChange(htmlPaths.about, before.about); 339 + 340 + // Verify incremental build with 1 route 341 + expect(isIncrementalBuild(logs)).toBe(true); 342 + expect(getAffectedRouteCount(logs)).toBe(1); 343 + 344 + // Only about should be rebuilt 345 + const after = recordBuildIds(htmlPaths); 346 + expect(after.index).toBe(before.index); 347 + expect(after.blog).toBe(before.blog); 348 + expect(after.about).not.toBe(before.about); 349 + }); 350 + 351 + // ============================================================ 352 + // TEST 4: Shared asset change (styles.css โ†’ / AND /about) 353 + // ============================================================ 354 + test("shared asset change rebuilds all routes using it", async ({ devServer }) => { 355 + let testCounter = 0; 356 + 357 + function modifyFile(suffix: string) { 358 + testCounter++; 359 + writeFileSync(assets.stylesCss, originals.stylesCss + `\n/* test-${testCounter}-${suffix} */`); 360 + } 361 + 362 + const before = await setupIncrementalState(devServer, modifyFile, htmlPaths, "index"); 363 + expect(before.index).not.toBeNull(); 364 + expect(before.about).not.toBeNull(); 365 + 366 + const logs = await triggerAndWaitForBuild(devServer, () => modifyFile("final")); 367 + await waitForBuildIdChange(htmlPaths.index, before.index); 368 + 369 + // Verify incremental build with 2 routes (/ and /about both use styles.css) 370 + expect(isIncrementalBuild(logs)).toBe(true); 371 + expect(getAffectedRouteCount(logs)).toBe(2); 372 + 373 + // Index and about should be rebuilt, blog should not 374 + const after = recordBuildIds(htmlPaths); 375 + expect(after.blog).toBe(before.blog); 376 + expect(after.index).not.toBe(before.index); 377 + expect(after.about).not.toBe(before.about); 378 + }); 379 + 380 + // ============================================================ 381 + // TEST 5: Image change (logo.png โ†’ /) 382 + // ============================================================ 383 + test("image change rebuilds only routes using it", async ({ devServer }) => { 384 + let testCounter = 0; 385 + 386 + function modifyFile(suffix: string) { 387 + testCounter++; 388 + const modified = Buffer.concat([ 389 + originals.logoPng as Buffer, 390 + Buffer.from(`<!-- test-${testCounter}-${suffix} -->`), 391 + ]); 392 + writeFileSync(assets.logoPng, modified); 393 + } 394 + 395 + const before = await setupIncrementalState(devServer, modifyFile, htmlPaths, "index"); 396 + expect(before.index).not.toBeNull(); 397 + 398 + const logs = await triggerAndWaitForBuild(devServer, () => modifyFile("final")); 399 + await waitForBuildIdChange(htmlPaths.index, before.index); 400 + 401 + // Verify incremental build with 1 route 402 + expect(isIncrementalBuild(logs)).toBe(true); 403 + expect(getAffectedRouteCount(logs)).toBe(1); 404 + 405 + // Only index should be rebuilt (uses logo.png) 406 + const after = recordBuildIds(htmlPaths); 407 + expect(after.about).toBe(before.about); 408 + expect(after.blog).toBe(before.blog); 409 + expect(after.index).not.toBe(before.index); 410 + }); 411 + 412 + // ============================================================ 413 + // TEST 6: Multiple files changed simultaneously 414 + // ============================================================ 415 + test("multiple file changes rebuild union of affected routes", async ({ devServer }) => { 416 + let testCounter = 0; 417 + 418 + function modifyFile(suffix: string) { 419 + testCounter++; 420 + // Change both blog.css (affects /blog) and about.js (affects /about) 421 + writeFileSync(assets.blogCss, originals.blogCss + `\n/* test-${testCounter}-${suffix} */`); 422 + writeFileSync(assets.aboutJs, originals.aboutJs + `\n// test-${testCounter}-${suffix}`); 423 + } 424 + 425 + const before = await setupIncrementalState(devServer, modifyFile, htmlPaths, "blog"); 426 + expect(before.about).not.toBeNull(); 427 + expect(before.blog).not.toBeNull(); 428 + 429 + const logs = await triggerAndWaitForBuild(devServer, () => modifyFile("final")); 430 + await waitForBuildIdChange(htmlPaths.blog, before.blog); 431 + 432 + // Verify incremental build with 2 routes (/about and /blog) 433 + expect(isIncrementalBuild(logs)).toBe(true); 434 + expect(getAffectedRouteCount(logs)).toBe(2); 435 + 436 + // About and blog should be rebuilt, index should not 437 + const after = recordBuildIds(htmlPaths); 438 + expect(after.index).toBe(before.index); 439 + expect(after.about).not.toBe(before.about); 440 + expect(after.blog).not.toBe(before.blog); 441 + }); 442 + 443 + // ============================================================ 444 + // TEST 7: CSS url() asset dependency (bg.png via blog.css โ†’ /blog) 445 + // ============================================================ 446 + test("CSS url() asset change triggers rebundling and rebuilds affected routes", async ({ 447 + devServer, 448 + }) => { 449 + let testCounter = 0; 450 + 451 + function modifyFile(suffix: string) { 452 + testCounter++; 453 + const modified = Buffer.concat([ 454 + originals.bgPng as Buffer, 455 + Buffer.from(`<!-- test-${testCounter}-${suffix} -->`), 456 + ]); 457 + writeFileSync(assets.bgPng, modified); 458 + } 459 + 460 + const before = await setupIncrementalState(devServer, modifyFile, htmlPaths, "blog"); 461 + expect(before.blog).not.toBeNull(); 462 + 463 + const logs = await triggerAndWaitForBuild(devServer, () => modifyFile("final")); 464 + await waitForBuildIdChange(htmlPaths.blog, before.blog); 465 + 466 + // Verify incremental build triggered 467 + expect(isIncrementalBuild(logs)).toBe(true); 468 + 469 + // Blog should be rebuilt (uses blog.css which references bg.png via url()) 470 + const after = recordBuildIds(htmlPaths); 471 + expect(after.blog).not.toBe(before.blog); 472 + }); 473 + 474 + // ============================================================ 475 + // TEST 8: Source file change rebuilds only routes defined in that file 476 + // ============================================================ 477 + test("source file change rebuilds only routes defined in that file", async ({ devServer }) => { 478 + // This test verifies that when a .rs source file changes, only routes 479 + // defined in that file are rebuilt (via source_to_routes tracking). 480 + // 481 + // Flow: 482 + // 1. Dev server starts โ†’ initial build โ†’ creates build_state.json with source file mappings 483 + // 2. Modify about.rs โ†’ cargo recompiles โ†’ binary reruns with MAUDIT_CHANGED_FILES 484 + // 3. New binary loads build_state.json and finds /about is affected by about.rs 485 + // 4. Only /about route is rebuilt 486 + // 487 + // Note: Unlike asset changes, .rs changes require cargo recompilation. 488 + // The binary's logs (showing "Incremental build") aren't captured by the 489 + // dev server's log collection, so we verify behavior through build IDs. 490 + 491 + const aboutRs = resolve(fixturePath, "src", "pages", "about.rs"); 492 + const originalAboutRs = readFileSync(aboutRs, "utf-8"); 493 + 494 + try { 495 + let testCounter = 0; 496 + 497 + function modifyFile(suffix: string) { 498 + testCounter++; 499 + writeFileSync(aboutRs, originalAboutRs + `\n// test-${testCounter}-${suffix}`); 500 + } 501 + 502 + const rsTimeout = 60000; 503 + 504 + // First change: triggers recompile + build (establishes build state with source_to_routes) 505 + const beforeInit = getBuildId(htmlPaths.about); 506 + await triggerAndWaitForBuild(devServer, () => modifyFile("init"), rsTimeout); 507 + await waitForBuildIdChange(htmlPaths.about, beforeInit, rsTimeout); 508 + 509 + // Record build IDs - state now exists with source_to_routes mappings 510 + const before = recordBuildIds(htmlPaths); 511 + expect(before.index).not.toBeNull(); 512 + expect(before.about).not.toBeNull(); 513 + expect(before.blog).not.toBeNull(); 514 + 515 + // Second change: should do incremental build (only about.rs route) 516 + await triggerAndWaitForBuild(devServer, () => modifyFile("final"), rsTimeout); 517 + await waitForBuildIdChange(htmlPaths.about, before.about, rsTimeout); 518 + 519 + // Verify only /about was rebuilt (it's defined in about.rs) 520 + const after = recordBuildIds(htmlPaths); 521 + expect(after.index).toBe(before.index); 522 + expect(after.blog).toBe(before.blog); 523 + expect(after.about).not.toBe(before.about); 524 + 525 + } finally { 526 + // Restore original content and wait for build to complete 527 + const beforeRestore = getBuildId(htmlPaths.about); 528 + writeFileSync(aboutRs, originalAboutRs); 529 + try { 530 + await waitForBuildIdChange(htmlPaths.about, beforeRestore, 60000); 531 + } catch { 532 + // Restoration build may not always complete, that's ok 533 + } 534 + } 535 + }); 536 + 537 + // ============================================================ 538 + // TEST 9: include_str! file change triggers full rebuild (untracked file) 539 + // ============================================================ 540 + test("include_str file change triggers full rebuild", async ({ devServer }) => { 541 + // This test verifies that changing a file referenced by include_str!() 542 + // triggers cargo recompilation and a FULL rebuild (all routes). 543 + // 544 + // Setup: about.rs uses include_str!("../assets/about-content.txt") 545 + // The .d file from cargo includes this dependency, so the dependency tracker 546 + // knows that changing about-content.txt requires recompilation. 547 + // 548 + // Flow: 549 + // 1. Dev server starts โ†’ initial build 550 + // 2. Modify about-content.txt โ†’ cargo recompiles (because .d file tracks it) 551 + // 3. Binary runs with MAUDIT_CHANGED_FILES pointing to about-content.txt 552 + // 4. Since about-content.txt is NOT in source_to_routes or asset_to_routes, 553 + // it's an "untracked file" and triggers a full rebuild of all routes 554 + // 555 + // This is the correct safe behavior - we don't know which route uses the 556 + // include_str! file, so we rebuild everything to ensure correctness. 557 + 558 + const contentFile = resolve(fixturePath, "src", "assets", "about-content.txt"); 559 + const originalContent = readFileSync(contentFile, "utf-8"); 560 + const rsTimeout = 60000; 561 + 562 + try { 563 + let testCounter = 0; 564 + 565 + function modifyFile(suffix: string) { 566 + testCounter++; 567 + writeFileSync(contentFile, originalContent + `\n<!-- test-${testCounter}-${suffix} -->`); 568 + } 569 + 570 + // First change: triggers recompile + full build (establishes build state) 571 + const beforeInit = getBuildId(htmlPaths.about); 572 + await triggerAndWaitForBuild(devServer, () => modifyFile("init"), rsTimeout); 573 + await waitForBuildIdChange(htmlPaths.about, beforeInit, rsTimeout); 574 + 575 + // Record build IDs before the final change 576 + const before = recordBuildIds(htmlPaths); 577 + expect(before.index).not.toBeNull(); 578 + expect(before.about).not.toBeNull(); 579 + expect(before.blog).not.toBeNull(); 580 + 581 + // Trigger the content file change with unique content to verify 582 + devServer.clearLogs(); 583 + writeFileSync(contentFile, originalContent + "\nUpdated content!"); 584 + await waitForBuildComplete(devServer, rsTimeout); 585 + await waitForBuildIdChange(htmlPaths.about, before.about, rsTimeout); 586 + 587 + // All routes should be rebuilt (full rebuild due to untracked file) 588 + const after = recordBuildIds(htmlPaths); 589 + expect(after.index).not.toBe(before.index); 590 + expect(after.about).not.toBe(before.about); 591 + expect(after.blog).not.toBe(before.blog); 592 + 593 + // Verify the content was actually updated in the output 594 + const aboutHtml = readFileSync(htmlPaths.about, "utf-8"); 595 + expect(aboutHtml).toContain("Updated content!"); 596 + 597 + } finally { 598 + // Restore original content and wait for build to complete 599 + const beforeRestore = getBuildId(htmlPaths.about); 600 + writeFileSync(contentFile, originalContent); 601 + try { 602 + await waitForBuildIdChange(htmlPaths.about, beforeRestore, 60000); 603 + } catch { 604 + // Restoration build may not always complete, that's ok 605 + } 606 + } 607 + }); 608 + 609 + // ============================================================ 610 + // TEST 10: Folder rename detection 611 + // ============================================================ 612 + test("folder rename is detected and affects routes using assets in that folder", async ({ devServer }) => { 613 + // This test verifies that renaming a folder containing tracked assets 614 + // is detected by the file watcher and affects the correct routes. 615 + // 616 + // Setup: The blog page uses src/assets/icons/blog-icon.css 617 + // Test: Rename icons -> icons-renamed, verify the blog route is identified as affected 618 + // 619 + // Note: The actual build will fail because the asset path becomes invalid, 620 + // but this test verifies the DETECTION and ROUTE MATCHING works correctly. 621 + 622 + const iconsFolder = resolve(fixturePath, "src", "assets", "icons"); 623 + const renamedFolder = resolve(fixturePath, "src", "assets", "icons-renamed"); 624 + const iconFile = resolve(iconsFolder, "blog-icon.css"); 625 + 626 + // Ensure we start with the correct state 627 + if (existsSync(renamedFolder)) { 628 + renameSync(renamedFolder, iconsFolder); 629 + // Wait briefly for any triggered build to start 630 + await new Promise((resolve) => setTimeout(resolve, 500)); 631 + } 632 + 633 + expect(existsSync(iconsFolder)).toBe(true); 634 + expect(existsSync(iconFile)).toBe(true); 635 + 636 + const originalContent = readFileSync(iconFile, "utf-8"); 637 + 638 + try { 639 + let testCounter = 0; 640 + 641 + function modifyFile(suffix: string) { 642 + testCounter++; 643 + writeFileSync(iconFile, originalContent + `\n/* test-${testCounter}-${suffix} */`); 644 + } 645 + 646 + // Use setupIncrementalState to establish tracking 647 + const before = await setupIncrementalState(devServer, modifyFile, htmlPaths, "blog"); 648 + expect(before.blog).not.toBeNull(); 649 + 650 + // Clear logs for the actual test 651 + devServer.clearLogs(); 652 + 653 + // Rename icons -> icons-renamed 654 + renameSync(iconsFolder, renamedFolder); 655 + 656 + // Wait for the build to be attempted (it will fail because path is now invalid) 657 + const startTime = Date.now(); 658 + const timeoutMs = 15000; 659 + let logs: string[] = []; 660 + 661 + while (Date.now() - startTime < timeoutMs) { 662 + logs = devServer.getLogs(100); 663 + const logsText = logs.join("\n"); 664 + 665 + // Wait for either success or failure indication 666 + if (logsText.includes("finished") || logsText.includes("failed") || logsText.includes("error")) { 667 + break; 668 + } 669 + 670 + await new Promise((resolve) => setTimeout(resolve, 100)); 671 + } 672 + 673 + logs = devServer.getLogs(100); 674 + const logsText = logs.join("\n"); 675 + 676 + // Key assertions: verify the detection and route matching worked 677 + // 1. The folder paths should be in changed files 678 + expect(logsText).toContain("icons"); 679 + 680 + // 2. The blog route should be identified as affected 681 + expect(logsText).toContain("Rebuilding 1 affected routes"); 682 + expect(logsText).toContain("/blog"); 683 + 684 + // 3. Other routes should NOT be affected (index and about don't use icons/) 685 + expect(logsText).not.toContain("/about"); 686 + 687 + } finally { 688 + // Restore: rename icons-renamed back to icons 689 + if (existsSync(renamedFolder) && !existsSync(iconsFolder)) { 690 + renameSync(renamedFolder, iconsFolder); 691 + } 692 + // Restore original content and wait for build 693 + if (existsSync(iconFile)) { 694 + const beforeRestore = getBuildId(htmlPaths.blog); 695 + writeFileSync(iconFile, originalContent); 696 + try { 697 + await waitForBuildIdChange(htmlPaths.blog, beforeRestore, 30000); 698 + } catch { 699 + // Restoration build may not always complete, that's ok 700 + } 701 + } 702 + } 703 + }); 704 + 705 + // ============================================================ 706 + // TEST 11: Shared Rust module change triggers full rebuild 707 + // ============================================================ 708 + test("shared Rust module change triggers full rebuild", async ({ devServer }) => { 709 + // This test verifies that changing a shared Rust module (not a route file) 710 + // triggers a full rebuild of all routes. 711 + // 712 + // Setup: helpers.rs contains shared functions used by about.rs 713 + // The helpers.rs file is not tracked in source_to_routes (only route files are) 714 + // so it's treated as an "untracked file" which triggers a full rebuild. 715 + // 716 + // This is the correct safe behavior - we can't determine which routes 717 + // depend on the shared module, so we rebuild everything. 718 + 719 + const helpersRs = resolve(fixturePath, "src", "pages", "helpers.rs"); 720 + const originalContent = readFileSync(helpersRs, "utf-8"); 721 + const rsTimeout = 60000; 722 + 723 + try { 724 + let testCounter = 0; 725 + 726 + function modifyFile(suffix: string) { 727 + testCounter++; 728 + writeFileSync(helpersRs, originalContent + `\n// test-${testCounter}-${suffix}`); 729 + } 730 + 731 + // First change: triggers recompile + full build (establishes build state) 732 + const beforeInit = getBuildId(htmlPaths.index); 733 + await triggerAndWaitForBuild(devServer, () => modifyFile("init"), rsTimeout); 734 + await waitForBuildIdChange(htmlPaths.index, beforeInit, rsTimeout); 735 + 736 + // Record build IDs before the final change 737 + const before = recordBuildIds(htmlPaths); 738 + expect(before.index).not.toBeNull(); 739 + expect(before.about).not.toBeNull(); 740 + expect(before.blog).not.toBeNull(); 741 + 742 + // Trigger the shared module change 743 + await triggerAndWaitForBuild(devServer, () => modifyFile("final"), rsTimeout); 744 + await waitForBuildIdChange(htmlPaths.index, before.index, rsTimeout); 745 + 746 + // All routes should be rebuilt (full rebuild due to untracked shared module) 747 + const after = recordBuildIds(htmlPaths); 748 + expect(after.index).not.toBe(before.index); 749 + expect(after.about).not.toBe(before.about); 750 + expect(after.blog).not.toBe(before.blog); 751 + 752 + } finally { 753 + // Restore original content and wait for build to complete 754 + const beforeRestore = getBuildId(htmlPaths.index); 755 + writeFileSync(helpersRs, originalContent); 756 + try { 757 + await waitForBuildIdChange(htmlPaths.index, beforeRestore, 60000); 758 + } catch { 759 + // Restoration build may not always complete, that's ok 760 + } 761 + } 762 + }); 763 + 764 + // ============================================================ 765 + // TEST 12: Content file change rebuilds only routes accessing that specific file 766 + // ============================================================ 767 + test("content file change rebuilds only routes accessing that file (granular tracking)", async ({ devServer }) => { 768 + // This test verifies granular content file tracking. 769 + // 770 + // Setup: 771 + // - /articles/first-post uses get_entry("first-post") โ†’ tracks only first-post.md 772 + // - /articles/second-post uses get_entry("second-post") โ†’ tracks only second-post.md 773 + // - /articles (list) uses entries() โ†’ tracks ALL content files 774 + // 775 + // When we change first-post.md: 776 + // - /articles/first-post should be rebuilt (directly uses this file) 777 + // - /articles should be rebuilt (uses entries() which tracks all files) 778 + // - /articles/second-post should NOT be rebuilt (uses different file) 779 + // - /articles/third-post should NOT be rebuilt (uses different file) 780 + // - Other routes (/, /about, /blog) should NOT be rebuilt 781 + 782 + let testCounter = 0; 783 + 784 + function modifyFile(suffix: string) { 785 + testCounter++; 786 + const newContent = (originals.firstPost as string).replace( 787 + "first post", 788 + `first post - test-${testCounter}-${suffix}` 789 + ); 790 + writeFileSync(contentFiles.firstPost, newContent); 791 + } 792 + 793 + // Setup: establish incremental state 794 + const before = await setupIncrementalState(devServer, modifyFile, htmlPaths, "articleFirst"); 795 + expect(before.articleFirst).not.toBeNull(); 796 + expect(before.articleSecond).not.toBeNull(); 797 + expect(before.articles).not.toBeNull(); 798 + 799 + // Trigger the final change 800 + const logs = await triggerAndWaitForBuild(devServer, () => modifyFile("final")); 801 + await waitForBuildIdChange(htmlPaths.articleFirst, before.articleFirst); 802 + 803 + // Verify incremental build occurred 804 + expect(isIncrementalBuild(logs)).toBe(true); 805 + 806 + // Check which routes were rebuilt 807 + const after = recordBuildIds(htmlPaths); 808 + 809 + // Routes that should NOT be rebuilt (don't access first-post.md) 810 + expect(after.index).toBe(before.index); 811 + expect(after.about).toBe(before.about); 812 + expect(after.blog).toBe(before.blog); 813 + expect(after.articleSecond).toBe(before.articleSecond); 814 + expect(after.articleThird).toBe(before.articleThird); 815 + 816 + // Routes that SHOULD be rebuilt (access first-post.md) 817 + expect(after.articleFirst).not.toBe(before.articleFirst); 818 + expect(after.articles).not.toBe(before.articles); // Uses entries() which tracks all files 819 + }); 820 + 821 + // ============================================================ 822 + // TEST 13: Different content file changes rebuild different routes 823 + // ============================================================ 824 + test("different content files trigger rebuilds of different routes", async ({ devServer }) => { 825 + // This test verifies that changing different content files rebuilds 826 + // different sets of routes, proving granular tracking works. 827 + // 828 + // Change second-post.md: 829 + // - /articles/second-post should be rebuilt 830 + // - /articles (list) should be rebuilt (entries() tracks all) 831 + // - /articles/first-post and /articles/third-post should NOT be rebuilt 832 + 833 + let testCounter = 0; 834 + 835 + function modifyFile(suffix: string) { 836 + testCounter++; 837 + const newContent = (originals.secondPost as string).replace( 838 + "second post", 839 + `second post - test-${testCounter}-${suffix}` 840 + ); 841 + writeFileSync(contentFiles.secondPost, newContent); 842 + } 843 + 844 + // Setup: establish incremental state 845 + const before = await setupIncrementalState(devServer, modifyFile, htmlPaths, "articleSecond"); 846 + expect(before.articleFirst).not.toBeNull(); 847 + expect(before.articleSecond).not.toBeNull(); 848 + expect(before.articleThird).not.toBeNull(); 849 + expect(before.articles).not.toBeNull(); 850 + 851 + // Trigger the final change 852 + const logs = await triggerAndWaitForBuild(devServer, () => modifyFile("final")); 853 + await waitForBuildIdChange(htmlPaths.articleSecond, before.articleSecond); 854 + 855 + // Verify incremental build occurred 856 + expect(isIncrementalBuild(logs)).toBe(true); 857 + 858 + // Check which routes were rebuilt 859 + const after = recordBuildIds(htmlPaths); 860 + 861 + // Routes that should NOT be rebuilt 862 + expect(after.index).toBe(before.index); 863 + expect(after.about).toBe(before.about); 864 + expect(after.blog).toBe(before.blog); 865 + expect(after.articleFirst).toBe(before.articleFirst); 866 + expect(after.articleThird).toBe(before.articleThird); 867 + 868 + // Routes that SHOULD be rebuilt 869 + expect(after.articleSecond).not.toBe(before.articleSecond); 870 + expect(after.articles).not.toBe(before.articles); 871 + }); 872 + 873 + // ============================================================ 874 + // TEST 14: Multiple content files changed rebuilds union of affected routes 875 + // ============================================================ 876 + test("multiple content file changes rebuild union of affected routes", async ({ devServer }) => { 877 + // This test verifies that changing multiple content files correctly 878 + // rebuilds the union of all routes that access any of the changed files. 879 + // 880 + // Change both first-post.md and third-post.md simultaneously: 881 + // - /articles/first-post should be rebuilt 882 + // - /articles/third-post should be rebuilt 883 + // - /articles (list) should be rebuilt 884 + // - /articles/second-post should NOT be rebuilt 885 + 886 + let testCounter = 0; 887 + 888 + function modifyFile(suffix: string) { 889 + testCounter++; 890 + // Change both first and third posts 891 + const newFirst = (originals.firstPost as string).replace( 892 + "first post", 893 + `first post - multi-${testCounter}-${suffix}` 894 + ); 895 + const newThird = (originals.thirdPost as string).replace( 896 + "third post", 897 + `third post - multi-${testCounter}-${suffix}` 898 + ); 899 + writeFileSync(contentFiles.firstPost, newFirst); 900 + writeFileSync(contentFiles.thirdPost, newThird); 901 + } 902 + 903 + // Setup: establish incremental state 904 + const before = await setupIncrementalState(devServer, modifyFile, htmlPaths, "articleFirst"); 905 + expect(before.articleFirst).not.toBeNull(); 906 + expect(before.articleSecond).not.toBeNull(); 907 + expect(before.articleThird).not.toBeNull(); 908 + expect(before.articles).not.toBeNull(); 909 + 910 + // Trigger the final change 911 + const logs = await triggerAndWaitForBuild(devServer, () => modifyFile("final")); 912 + await waitForBuildIdChange(htmlPaths.articleFirst, before.articleFirst); 913 + 914 + // Verify incremental build occurred 915 + expect(isIncrementalBuild(logs)).toBe(true); 916 + 917 + // Check which routes were rebuilt 918 + const after = recordBuildIds(htmlPaths); 919 + 920 + // Routes that should NOT be rebuilt 921 + expect(after.index).toBe(before.index); 922 + expect(after.about).toBe(before.about); 923 + expect(after.blog).toBe(before.blog); 924 + expect(after.articleSecond).toBe(before.articleSecond); 925 + 926 + // Routes that SHOULD be rebuilt 927 + expect(after.articleFirst).not.toBe(before.articleFirst); 928 + expect(after.articleThird).not.toBe(before.articleThird); 929 + expect(after.articles).not.toBe(before.articles); 930 + }); 931 + 932 + // ============================================================ 933 + // TEST 15: Full rebuild from untracked file properly initializes content sources 934 + // ============================================================ 935 + test("full rebuild from untracked file properly initializes content sources", async ({ devServer }) => { 936 + // This test verifies that when an untracked Rust file (like helpers.rs) changes, 937 + // triggering a full rebuild (routes_to_rebuild = None), content sources are 938 + // still properly initialized. 939 + // 940 + // This was a bug where the code checked `is_incremental` instead of 941 + // `routes_to_rebuild.is_some()`, causing content sources to not be initialized 942 + // during full rebuilds triggered by untracked file changes. 943 + // 944 + // Setup: 945 + // - helpers.rs is a shared module not tracked in source_to_routes 946 + // - Changing it triggers routes_to_rebuild = None (full rebuild) 947 + // - Routes like /articles/* use content from the "articles" content source 948 + // - If content sources aren't initialized, the build would crash 949 + // 950 + // This test: 951 + // 1. First modifies a content file to ensure specific content exists 952 + // 2. Then modifies helpers.rs to trigger a full rebuild 953 + // 3. Verifies the content-using routes are properly built with correct content 954 + 955 + const helpersRs = resolve(fixturePath, "src", "pages", "helpers.rs"); 956 + const originalHelpersRs = readFileSync(helpersRs, "utf-8"); 957 + const rsTimeout = 60000; 958 + 959 + try { 960 + // Step 1: Modify content file to set up specific content we can verify 961 + const testMarker = `CONTENT-INIT-TEST-${Date.now()}`; 962 + const newContent = (originals.firstPost as string).replace( 963 + "first post", 964 + `first post - ${testMarker}` 965 + ); 966 + writeFileSync(contentFiles.firstPost, newContent); 967 + 968 + // Wait for the content change to be processed 969 + const beforeContent = getBuildId(htmlPaths.articleFirst); 970 + await waitForBuildComplete(devServer, rsTimeout); 971 + await waitForBuildIdChange(htmlPaths.articleFirst, beforeContent, rsTimeout); 972 + 973 + // Verify the content was updated 974 + let articleHtml = readFileSync(htmlPaths.articleFirst, "utf-8"); 975 + expect(articleHtml).toContain(testMarker); 976 + 977 + // Record build IDs before the helpers.rs change 978 + const before = recordBuildIds(htmlPaths); 979 + expect(before.articleFirst).not.toBeNull(); 980 + expect(before.articles).not.toBeNull(); 981 + 982 + // Step 2: Modify helpers.rs to trigger full rebuild 983 + // This is an untracked file, so it triggers routes_to_rebuild = None 984 + devServer.clearLogs(); 985 + writeFileSync(helpersRs, originalHelpersRs + `\n// content-init-test-${Date.now()}`); 986 + 987 + await waitForBuildComplete(devServer, rsTimeout); 988 + await waitForBuildIdChange(htmlPaths.articleFirst, before.articleFirst, rsTimeout); 989 + 990 + // Step 3: Verify the build succeeded and content is still correct 991 + // If content sources weren't initialized, this would fail or crash 992 + const after = recordBuildIds(htmlPaths); 993 + 994 + // All routes should be rebuilt (full rebuild) 995 + expect(after.index).not.toBe(before.index); 996 + expect(after.about).not.toBe(before.about); 997 + expect(after.blog).not.toBe(before.blog); 998 + expect(after.articleFirst).not.toBe(before.articleFirst); 999 + expect(after.articles).not.toBe(before.articles); 1000 + 1001 + // Most importantly: verify the content-using routes have correct content 1002 + // This proves content sources were properly initialized during the full rebuild 1003 + articleHtml = readFileSync(htmlPaths.articleFirst, "utf-8"); 1004 + expect(articleHtml).toContain(testMarker); 1005 + 1006 + // Also verify the articles list page works (uses entries()) 1007 + const articlesHtml = readFileSync(htmlPaths.articles, "utf-8"); 1008 + expect(articlesHtml).toContain("First Post"); 1009 + 1010 + } finally { 1011 + // Restore original content 1012 + writeFileSync(helpersRs, originalHelpersRs); 1013 + writeFileSync(contentFiles.firstPost, originals.firstPost as string); 1014 + 1015 + // Wait for restoration build 1016 + const beforeRestore = getBuildId(htmlPaths.articleFirst); 1017 + try { 1018 + await waitForBuildIdChange(htmlPaths.articleFirst, beforeRestore, 60000); 1019 + } catch { 1020 + // Restoration build may not always complete, that's ok 1021 + } 1022 + } 1023 + }); 1024 + });
+3 -1
e2e/tests/prefetch.spec.ts
··· 1 - import { test, expect } from "./test-utils"; 2 import { prefetchScript } from "./utils"; 3 4 test.describe("Prefetch", () => { 5 test("should create prefetch via speculation rules on Chromium or link element elsewhere", async ({
··· 1 + import { createTestWithFixture, expect } from "./test-utils"; 2 import { prefetchScript } from "./utils"; 3 + 4 + const test = createTestWithFixture("prefetch-prerender"); 5 6 test.describe("Prefetch", () => { 7 test("should create prefetch via speculation rules on Chromium or link element elsewhere", async ({
+3 -1
e2e/tests/prerender.spec.ts
··· 1 - import { test, expect } from "./test-utils"; 2 import { prefetchScript } from "./utils"; 3 4 test.describe("Prefetch - Speculation Rules (Prerender)", () => { 5 test("should create speculation rules on Chromium or link prefetch elsewhere when prerender is enabled", async ({
··· 1 + import { createTestWithFixture, expect } from "./test-utils"; 2 import { prefetchScript } from "./utils"; 3 + 4 + const test = createTestWithFixture("prefetch-prerender"); 5 6 test.describe("Prefetch - Speculation Rules (Prerender)", () => { 7 test("should create speculation rules on Chromium or link prefetch elsewhere when prerender is enabled", async ({
+117 -26
e2e/tests/test-utils.ts
··· 1 - import { spawn, execFile, type ChildProcess } from "node:child_process"; 2 - import { join, resolve, dirname } from "node:path"; 3 import { existsSync } from "node:fs"; 4 import { fileURLToPath } from "node:url"; 5 import { test as base } from "@playwright/test"; ··· 23 port: number; 24 /** Stop the dev server */ 25 stop: () => Promise<void>; 26 } 27 28 /** ··· 52 const childProcess = spawn(command, args, { 53 cwd: fixturePath, 54 stdio: ["ignore", "pipe", "pipe"], 55 }); 56 57 // Capture output to detect when server is ready 58 let serverReady = false; 59 60 const outputPromise = new Promise<number>((resolve, reject) => { 61 const timeout = setTimeout(() => { 62 - reject(new Error("Dev server did not start within 30 seconds")); 63 - }, 30000); 64 65 childProcess.stdout?.on("data", (data: Buffer) => { 66 const output = data.toString(); 67 68 // Look for "waiting for requests" to know server is ready 69 if (output.includes("waiting for requests")) { ··· 75 }); 76 77 childProcess.stderr?.on("data", (data: Buffer) => { 78 - // Only log errors, not all stderr output 79 const output = data.toString(); 80 if (output.toLowerCase().includes("error")) { 81 console.error(`[maudit dev] ${output}`); 82 } ··· 113 }, 5000); 114 }); 115 }, 116 }; 117 } 118 ··· 136 } 137 138 // Worker-scoped server pool - one server per worker, shared across all tests in that worker 139 - const workerServers = new Map<number, DevServer>(); 140 141 - // Extend Playwright's test with a devServer fixture 142 - export const test = base.extend<{ devServer: DevServer }>({ 143 - devServer: async ({}, use, testInfo) => { 144 - // Use worker index to get or create a server for this worker 145 - const workerIndex = testInfo.workerIndex; 146 147 - let server = workerServers.get(workerIndex); 148 149 - if (!server) { 150 - // Assign unique port based on worker index 151 - const port = 1864 + workerIndex; 152 153 - server = await startDevServer({ 154 - fixture: "prefetch-prerender", 155 - port, 156 - }); 157 158 - workerServers.set(workerIndex, server); 159 - } 160 161 - await use(server); 162 163 - // Don't stop the server here - it stays alive for all tests in this worker 164 - // Playwright will clean up when the worker exits 165 - }, 166 - }); 167 168 export { expect } from "@playwright/test";
··· 1 + import { spawn } from "node:child_process"; 2 + import { resolve, dirname } from "node:path"; 3 import { existsSync } from "node:fs"; 4 import { fileURLToPath } from "node:url"; 5 import { test as base } from "@playwright/test"; ··· 23 port: number; 24 /** Stop the dev server */ 25 stop: () => Promise<void>; 26 + /** Get recent log output (last N lines) */ 27 + getLogs: (lines?: number) => string[]; 28 + /** Clear captured logs */ 29 + clearLogs: () => void; 30 } 31 32 /** ··· 56 const childProcess = spawn(command, args, { 57 cwd: fixturePath, 58 stdio: ["ignore", "pipe", "pipe"], 59 + env: { 60 + ...process.env, 61 + // Show binary output for tests so we can verify incremental build logs 62 + MAUDIT_SHOW_BINARY_OUTPUT: "1", 63 + }, 64 }); 65 66 // Capture output to detect when server is ready 67 let serverReady = false; 68 + const capturedLogs: string[] = []; 69 70 const outputPromise = new Promise<number>((resolve, reject) => { 71 const timeout = setTimeout(() => { 72 + console.error("[test-utils] Dev server startup timeout. Recent logs:"); 73 + console.error(capturedLogs.slice(-20).join("\n")); 74 + reject(new Error("Dev server did not start within 120 seconds")); 75 + }, 120000); // Increased to 120 seconds for CI 76 77 childProcess.stdout?.on("data", (data: Buffer) => { 78 const output = data.toString(); 79 + // Capture all stdout logs 80 + output 81 + .split("\n") 82 + .filter((line) => line.trim()) 83 + .forEach((line) => { 84 + capturedLogs.push(line); 85 + }); 86 87 // Look for "waiting for requests" to know server is ready 88 if (output.includes("waiting for requests")) { ··· 94 }); 95 96 childProcess.stderr?.on("data", (data: Buffer) => { 97 const output = data.toString(); 98 + // Capture all stderr logs 99 + output 100 + .split("\n") 101 + .filter((line) => line.trim()) 102 + .forEach((line) => { 103 + capturedLogs.push(line); 104 + }); 105 + 106 + // Only log errors to console, not all stderr output 107 if (output.toLowerCase().includes("error")) { 108 console.error(`[maudit dev] ${output}`); 109 } ··· 140 }, 5000); 141 }); 142 }, 143 + getLogs: (lines?: number) => { 144 + if (lines) { 145 + return capturedLogs.slice(-lines); 146 + } 147 + return [...capturedLogs]; 148 + }, 149 + clearLogs: () => { 150 + capturedLogs.length = 0; 151 + }, 152 }; 153 } 154 ··· 172 } 173 174 // Worker-scoped server pool - one server per worker, shared across all tests in that worker 175 + // Key format: "workerIndex-fixtureName" 176 + const workerServers = new Map<string, DevServer>(); 177 + 178 + // Track used ports to avoid collisions 179 + const usedPorts = new Set<number>(); 180 + 181 + /** 182 + * Generate a deterministic port offset based on fixture name. 183 + * This ensures each fixture gets a unique port range, avoiding collisions 184 + * when multiple fixtures run on the same worker. 185 + */ 186 + function getFixturePortOffset(fixtureName: string): number { 187 + // Simple hash function to get a number from the fixture name 188 + let hash = 0; 189 + for (let i = 0; i < fixtureName.length; i++) { 190 + const char = fixtureName.charCodeAt(i); 191 + hash = (hash << 5) - hash + char; 192 + hash = hash & hash; // Convert to 32bit integer 193 + } 194 + // Use modulo to keep the offset reasonable (0-99) 195 + return Math.abs(hash) % 100; 196 + } 197 + 198 + /** 199 + * Find an available port starting from the preferred port. 200 + */ 201 + function findAvailablePort(preferredPort: number): number { 202 + let port = preferredPort; 203 + while (usedPorts.has(port)) { 204 + port++; 205 + } 206 + usedPorts.add(port); 207 + return port; 208 + } 209 210 + /** 211 + * Create a test instance with a devServer fixture for a specific fixture. 212 + * This allows each test file to use a different fixture while sharing the same pattern. 213 + * 214 + * @param fixtureName - Name of the fixture directory under e2e/fixtures/ 215 + * @param basePort - Starting port number (default: 1864). Each fixture gets a unique port based on its name. 216 + * 217 + * @example 218 + * ```ts 219 + * import { createTestWithFixture } from "./test-utils"; 220 + * const test = createTestWithFixture("my-fixture"); 221 + * 222 + * test("my test", async ({ devServer }) => { 223 + * // devServer is automatically started for "my-fixture" 224 + * }); 225 + * ``` 226 + */ 227 + export function createTestWithFixture(fixtureName: string, basePort = 1864) { 228 + return base.extend<{ devServer: DevServer }>({ 229 + // oxlint-disable-next-line no-empty-pattern 230 + devServer: async ({}, use, testInfo) => { 231 + // Use worker index to get or create a server for this worker 232 + const workerIndex = testInfo.workerIndex; 233 + const serverKey = `${workerIndex}-${fixtureName}`; 234 235 + let server = workerServers.get(serverKey); 236 237 + if (!server) { 238 + // Calculate port based on fixture name hash + worker index to avoid collisions 239 + const fixtureOffset = getFixturePortOffset(fixtureName); 240 + const preferredPort = basePort + workerIndex * 100 + fixtureOffset; 241 + const port = findAvailablePort(preferredPort); 242 243 + server = await startDevServer({ 244 + fixture: fixtureName, 245 + port, 246 + }); 247 248 + workerServers.set(serverKey, server); 249 + } 250 251 + await use(server); 252 253 + // Don't stop the server here - it stays alive for all tests in this worker 254 + // Playwright will clean up when the worker exits 255 + }, 256 + }); 257 + } 258 259 export { expect } from "@playwright/test";
+1 -1
e2e/tests/utils.ts
··· 4 // Find the actual prefetch bundle file (hash changes on each build) 5 const distDir = join(process.cwd(), "../crates/maudit/js/dist"); 6 const prefetchFile = readdirSync(distDir).find( 7 - (f) => f.startsWith("prefetch-") && f.endsWith(".js"), 8 ); 9 if (!prefetchFile) throw new Error("Could not find prefetch bundle"); 10
··· 4 // Find the actual prefetch bundle file (hash changes on each build) 5 const distDir = join(process.cwd(), "../crates/maudit/js/dist"); 6 const prefetchFile = readdirSync(distDir).find( 7 + (f) => f.startsWith("prefetch") && f.endsWith(".js"), 8 ); 9 if (!prefetchFile) throw new Error("Could not find prefetch bundle"); 10
+1 -1
examples/blog/Cargo.toml
··· 10 [dependencies] 11 maudit = { workspace = true } 12 maud = "0.27.0" 13 - serde = { version = "1.0.216" }
··· 10 [dependencies] 11 maudit = { workspace = true } 12 maud = "0.27.0" 13 + serde = { version = "1.0.228" }
+2 -2
examples/blog/src/routes/index.rs
··· 4 use crate::{ 5 content::ArticleContent, 6 layout::layout, 7 - routes::{Article, article::ArticleParams}, 8 }; 9 10 #[route("/")] ··· 16 17 let markup = html! { 18 ul { 19 - @for entry in &articles.entries { 20 li { 21 a href=(&Article.url(ArticleParams { article: entry.id.clone() })) { 22 h2 { (entry.data(ctx).title) }
··· 4 use crate::{ 5 content::ArticleContent, 6 layout::layout, 7 + routes::{article::ArticleParams, Article}, 8 }; 9 10 #[route("/")] ··· 16 17 let markup = html! { 18 ul { 19 + @for entry in articles.entries() { 20 li { 21 a href=(&Article.url(ArticleParams { article: entry.id.clone() })) { 22 h2 { (entry.data(ctx).title) }
+1 -1
examples/library/Cargo.toml
··· 10 [dependencies] 11 maudit = { workspace = true } 12 maud = "0.27.0" 13 - serde = { version = "1.0.216" }
··· 10 [dependencies] 11 maudit = { workspace = true } 12 maud = "0.27.0" 13 + serde = { version = "1.0.228" }
+2 -2
examples/library/src/routes/index.rs
··· 4 use crate::{ 5 content::ArticleContent, 6 layout::layout, 7 - routes::{Article, article::ArticleParams}, 8 }; 9 10 #[route("/")] ··· 18 let markup = html! { 19 (logo.render("Maudit logo, a crudely drawn crown")) 20 ul { 21 - @for entry in &articles.entries { 22 li { 23 a href=(&Article.url(ArticleParams { article: entry.id.clone() })) { 24 h2 { (entry.data(ctx).title) }
··· 4 use crate::{ 5 content::ArticleContent, 6 layout::layout, 7 + routes::{article::ArticleParams, Article}, 8 }; 9 10 #[route("/")] ··· 18 let markup = html! { 19 (logo.render("Maudit logo, a crudely drawn crown")) 20 ul { 21 + @for entry in articles.entries() { 22 li { 23 a href=(&Article.url(ArticleParams { article: entry.id.clone() })) { 24 h2 { (entry.data(ctx).title) }
+1 -1
examples/oubli-basics/src/routes/index.rs
··· 16 Ok(layout(html! { 17 (logo.render("Maudit logo, a crudely drawn crown")) 18 h1 { "Hello World" } 19 - @for archetype in &archetype_store.entries { 20 a href=(archetype.id) { (archetype.data(ctx).title) } 21 } 22 }))
··· 16 Ok(layout(html! { 17 (logo.render("Maudit logo, a crudely drawn crown")) 18 h1 { "Hello World" } 19 + @for archetype in archetype_store.entries() { 20 a href=(archetype.id) { (archetype.data(ctx).title) } 21 } 22 }))
+2 -1
package.json
··· 12 "lint:fix": "oxlint --fix --type-aware && cargo clippy --fix --allow-dirty --allow-staged", 13 "format": "pnpm run format:ts && pnpm run format:rs", 14 "format:ts": "oxfmt", 15 - "format:rs": "cargo fmt" 16 }, 17 "dependencies": { 18 "@tailwindcss/cli": "^4.1.18",
··· 12 "lint:fix": "oxlint --fix --type-aware && cargo clippy --fix --allow-dirty --allow-staged", 13 "format": "pnpm run format:ts && pnpm run format:rs", 14 "format:ts": "oxfmt", 15 + "format:rs": "cargo fmt", 16 + "test:e2e": "cd e2e && pnpm run test" 17 }, 18 "dependencies": { 19 "@tailwindcss/cli": "^4.1.18",
+1 -1
website/Cargo.toml
··· 8 maudit = { workspace = true } 9 maud = { workspace = true } 10 serde = { workspace = true } 11 - chrono = { version = "0.4.42", features = ["serde"] }
··· 8 maudit = { workspace = true } 9 maud = { workspace = true } 10 serde = { workspace = true } 11 + chrono = { version = "0.4.43", features = ["serde"] }
+1 -1
website/content/docs/images.md
··· 96 impl Route for ImagePage { 97 fn render(&self, ctx: &mut PageContext) -> impl Into<RenderResult> { 98 let image = ctx.assets.add_image("path/to/image.jpg")?; 99 - let placeholder = image.placeholder(); 100 101 Ok(format!("<img src=\"{}\" alt=\"Image with placeholder\" style=\"background-image: url('{}'); background-size: cover;\" />", image.url(), placeholder.data_uri())) 102 }
··· 96 impl Route for ImagePage { 97 fn render(&self, ctx: &mut PageContext) -> impl Into<RenderResult> { 98 let image = ctx.assets.add_image("path/to/image.jpg")?; 99 + let placeholder = image.placeholder()?; 100 101 Ok(format!("<img src=\"{}\" alt=\"Image with placeholder\" style=\"background-image: url('{}'); background-size: cover;\" />", image.url(), placeholder.data_uri())) 102 }
+1 -1
website/content/docs/prefetching.md
··· 49 50 Note that prerendering, unlike prefetching, may require rethinking how the JavaScript on your pages works, as it'll run JavaScript from pages that the user hasn't visited yet. For example, this might result in analytics reporting incorrect page views. 51 52 - ## Possible risks 53 54 Prefetching pages in static websites is typically always safe. In more traditional apps, an issue can arise if your pages cause side effects to happen on the server. For instance, if you were to prefetch `/logout`, your user might get disconnected on hover, or worse as soon as the log out link appear in the viewport. In modern times, it is typically not recommended to have links cause such side effects anyway, reducing the risk of this happening. 55
··· 49 50 Note that prerendering, unlike prefetching, may require rethinking how the JavaScript on your pages works, as it'll run JavaScript from pages that the user hasn't visited yet. For example, this might result in analytics reporting incorrect page views. 51 52 + ## Possible risks 53 54 Prefetching pages in static websites is typically always safe. In more traditional apps, an issue can arise if your pages cause side effects to happen on the server. For instance, if you were to prefetch `/logout`, your user might get disconnected on hover, or worse as soon as the log out link appear in the viewport. In modern times, it is typically not recommended to have links cause such side effects anyway, reducing the risk of this happening. 55
+2 -2
website/content/news/2026-in-the-cursed-lands.md
··· 55 impl Route for ImagePage { 56 fn render(&self, ctx: &mut PageContext) -> impl Into<RenderResult> { 57 let image = ctx.assets.add_image("path/to/image.jpg")?; 58 - let placeholder = image.placeholder(); 59 60 Ok(format!("<img src=\"{}\" alt=\"Image with placeholder\" style=\"background-image: url('{}'); background-size: cover;\" />", image.url(), placeholder.data_uri())) 61 } ··· 70 71 ### Shortcodes 72 73 - Embedding a YouTube video typically means copying a long, ugly iframe tag and configuring several attributes to ensure proper rendering. It'd be nice to have something friendlier, a code that would be short, you will. 74 75 ```md 76 Here's my cool video:
··· 55 impl Route for ImagePage { 56 fn render(&self, ctx: &mut PageContext) -> impl Into<RenderResult> { 57 let image = ctx.assets.add_image("path/to/image.jpg")?; 58 + let placeholder = image.placeholder()?; 59 60 Ok(format!("<img src=\"{}\" alt=\"Image with placeholder\" style=\"background-image: url('{}'); background-size: cover;\" />", image.url(), placeholder.data_uri())) 61 } ··· 70 71 ### Shortcodes 72 73 + Embedding a YouTube video typically means copying a long, ugly iframe tag and configuring several attributes to ensure proper rendering. It'd be nice to have something friendlier, a code that would be short, if you will. 74 75 ```md 76 Here's my cool video:
+2 -2
website/src/layout/docs_sidebars.rs
··· 1 - use maud::{Markup, html}; 2 use maudit::{ 3 content::MarkdownHeading, 4 route::{PageContext, RouteExt}, ··· 14 15 let mut sections = std::collections::HashMap::new(); 16 17 - for entry in content.entries.iter() { 18 if let Some(section) = &entry.data(ctx).section { 19 sections.entry(section).or_insert_with(Vec::new).push(entry); 20 }
··· 1 + use maud::{html, Markup}; 2 use maudit::{ 3 content::MarkdownHeading, 4 route::{PageContext, RouteExt}, ··· 14 15 let mut sections = std::collections::HashMap::new(); 16 17 + for entry in content.entries() { 18 if let Some(section) = &entry.data(ctx).section { 19 sections.entry(section).or_insert_with(Vec::new).push(entry); 20 }
+3 -3
website/src/routes/news.rs
··· 1 use chrono::Datelike; 2 - use maud::PreEscaped; 3 use maud::html; 4 use maudit::route::prelude::*; 5 use std::collections::BTreeMap; 6 7 use crate::content::NewsContent; 8 - use crate::layout::SeoMeta; 9 use crate::layout::layout; 10 11 #[route("/news/")] 12 pub struct NewsIndex; ··· 18 // Group articles by year 19 let mut articles_by_year: BTreeMap<String, Vec<_>> = BTreeMap::new(); 20 21 - for article in &content.entries { 22 let year = article.data(ctx).date.year().to_string(); 23 articles_by_year 24 .entry(year)
··· 1 use chrono::Datelike; 2 use maud::html; 3 + use maud::PreEscaped; 4 use maudit::route::prelude::*; 5 use std::collections::BTreeMap; 6 7 use crate::content::NewsContent; 8 use crate::layout::layout; 9 + use crate::layout::SeoMeta; 10 11 #[route("/news/")] 12 pub struct NewsIndex; ··· 18 // Group articles by year 19 let mut articles_by_year: BTreeMap<String, Vec<_>> = BTreeMap::new(); 20 21 + for article in content.entries() { 22 let year = article.data(ctx).date.year().to_string(); 23 articles_by_year 24 .entry(year)
+1 -1
xtask/Cargo.toml
··· 5 publish = false 6 7 [dependencies] 8 - rolldown = { package = "brk_rolldown", version = "0.2.3" } 9 tokio = { version = "1", features = ["rt"] }
··· 5 publish = false 6 7 [dependencies] 8 + rolldown = { package = "brk_rolldown", version = "0.8.0" } 9 tokio = { version = "1", features = ["rt"] }
+18 -2
xtask/src/main.rs
··· 110 // Configure Rolldown bundler input 111 let input_items = vec![ 112 InputItem { 113 - name: Some("prefetch".to_string()), 114 import: js_src_dir.join("prefetch.ts").to_string_lossy().to_string(), 115 }, 116 InputItem { 117 - name: Some("hover".to_string()), 118 import: js_src_dir 119 .join("prefetch") 120 .join("hover.ts") 121 .to_string_lossy() 122 .to_string(), 123 },
··· 110 // Configure Rolldown bundler input 111 let input_items = vec![ 112 InputItem { 113 + name: None, 114 import: js_src_dir.join("prefetch.ts").to_string_lossy().to_string(), 115 }, 116 InputItem { 117 + name: None, 118 import: js_src_dir 119 .join("prefetch") 120 .join("hover.ts") 121 + .to_string_lossy() 122 + .to_string(), 123 + }, 124 + InputItem { 125 + name: None, 126 + import: js_src_dir 127 + .join("prefetch") 128 + .join("tap.ts") 129 + .to_string_lossy() 130 + .to_string(), 131 + }, 132 + InputItem { 133 + name: None, 134 + import: js_src_dir 135 + .join("prefetch") 136 + .join("viewport.ts") 137 .to_string_lossy() 138 .to_string(), 139 },