diff --git a/Cargo.lock b/Cargo.lock index 4a153c0..5b5a9fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -24,6 +24,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "anyhow" version = "1.0.103" @@ -46,6 +52,28 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" +[[package]] +name = "ast_node" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edf54a7a1bf98e127c22e9e2b9d19619092c74283fdc14e4d67da69780f90db6" +dependencies = [ + "quote", + "swc_macros_common", + "syn", +] + +[[package]] +name = "auto_impl" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "autocfg" version = "1.5.1" @@ -58,12 +86,40 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + +[[package]] +name = "better_scoped_tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd228125315b132eed175bf47619ac79b945b26e56b848ba203ae4ea8603609" +dependencies = [ + "scoped-tls", +] + [[package]] name = "bitflags" version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "borrow-or-share" version = "0.2.4" @@ -82,9 +138,12 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.20.3" +version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +dependencies = [ + "allocator-api2", +] [[package]] name = "bytes" @@ -92,6 +151,25 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +[[package]] +name = "bytes-str" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "577d2bf5650f8554d5a372af5ac93535110a0fc75b3e702bb853369febf227c2" +dependencies = [ + "bytes", + "serde", +] + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -108,6 +186,35 @@ dependencies = [ "memchr", ] +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "cow-utils" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417bef24afe1460300965a25ff4a24b8b45ad011948302ec221e8a0a81eb2c79" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crossbeam" version = "0.8.4" @@ -164,6 +271,26 @@ version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dispatch2" version = "0.3.1" @@ -185,6 +312,18 @@ dependencies = [ "syn", ] +[[package]] +name = "dragonbox_ecma" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d742b56656e8b14d63e7ea9806597b1849ae25412584c8adf78c0f67bd985e66" + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -194,6 +333,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -219,6 +364,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "fluent-uri" version = "0.4.1" @@ -245,6 +396,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "from_variant" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5ff35a391aef949120a0340d690269b3d9f63460a6106e99bd07b961f345ea9" +dependencies = [ + "swc_macros_common", + "syn", +] + [[package]] name = "fsevent-sys" version = "4.1.0" @@ -278,6 +439,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -314,22 +485,39 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + [[package]] name = "hashbrown" version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", "foldhash", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "hashlink" version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" dependencies = [ - "hashbrown", + "hashbrown 0.15.5", ] [[package]] @@ -338,6 +526,20 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hstr" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23efb2e58d2f27a96edfa907c26016deb120b7ac8c66a19d2748f0e9c145f3cb" +dependencies = [ + "hashbrown 0.14.5", + "new_debug_unreachable", + "once_cell", + "rustc-hash", + "serde", + "triomphe", +] + [[package]] name = "html5gum" version = "0.8.4" @@ -459,6 +661,16 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "2.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + [[package]] name = "inotify" version = "0.11.3" @@ -479,6 +691,27 @@ dependencies = [ "libc", ] +[[package]] +name = "is-macro" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8267aa6001e25494f3015f9663bbd88a18240c74483afa5f0934a1b3e4c388e9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -571,6 +804,79 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "lexical" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7aefb36fd43fef7003334742cbf77b243fcd36418a1d1bdd480d613a67968f6" +dependencies = [ + "lexical-core", +] + +[[package]] +name = "lexical-core" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cde5de06e8d4c2faabc400238f9ae1c74d5412d03a7bd067645ccbc47070e46" +dependencies = [ + "lexical-parse-float", + "lexical-parse-integer", + "lexical-util", + "lexical-write-float", + "lexical-write-integer", +] + +[[package]] +name = "lexical-parse-float" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683b3a5ebd0130b8fb52ba0bdc718cc56815b6a097e28ae5a6997d0ad17dc05f" +dependencies = [ + "lexical-parse-integer", + "lexical-util", + "static_assertions", +] + +[[package]] +name = "lexical-parse-integer" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d0994485ed0c312f6d965766754ea177d07f9c00c9b82a5ee62ed5b47945ee9" +dependencies = [ + "lexical-util", + "static_assertions", +] + +[[package]] +name = "lexical-util" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5255b9ff16ff898710eb9eb63cb39248ea8a5bb036bea8085b1a767ff6c4e3fc" +dependencies = [ + "static_assertions", +] + +[[package]] +name = "lexical-write-float" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accabaa1c4581f05a3923d1b4cfd124c329352288b7b9da09e766b0668116862" +dependencies = [ + "lexical-util", + "lexical-write-integer", + "static_assertions", +] + +[[package]] +name = "lexical-write-integer" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b6f3d1f4422866b68192d62f77bc5c700bee84f3069f2469d7bc8c77852446" +dependencies = [ + "lexical-util", + "static_assertions", +] + [[package]] name = "libc" version = "0.2.186" @@ -653,6 +959,18 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nonmax" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "610a5acd306ec67f907abe5567859a3c693fb9886eb1f012ab8f2a47bef3db51" + [[package]] name = "notify" version = "8.2.0" @@ -689,6 +1007,25 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -761,12 +1098,348 @@ dependencies = [ "num-traits", ] +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + +[[package]] +name = "owo-colors" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c45bb4a6ae1280ec0803b1ef9d3455eb50f01efbbe1447ab020f1d54fba9d8" + +[[package]] +name = "oxc-miette" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4356a61f2ed4c9b3610245215fbf48970eb277126919f87db9d0efa93a74245c" +dependencies = [ + "cfg-if", + "owo-colors", + "oxc-miette-derive", + "textwrap", + "thiserror", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "oxc-miette-derive" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b237422b014f8f8fff75bb9379e697d13f8d57551a22c88bebb39f073c1bf696" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "oxc_allocator" +version = "0.81.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c67f2b817263a72b6cc5b46e32467bc4077496f88315ce08c1796647fed84d4" +dependencies = [ + "allocator-api2", + "bumpalo", + "hashbrown 0.15.5", + "oxc_data_structures", + "rustc-hash", +] + +[[package]] +name = "oxc_ast" +version = "0.81.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "989b81258ce151e23adee6f1393f7c0fb7e10e3a6c6c001f71bbaff4081c72a4" +dependencies = [ + "bitflags", + "oxc_allocator", + "oxc_ast_macros", + "oxc_data_structures", + "oxc_estree", + "oxc_regular_expression", + "oxc_span", + "oxc_syntax", +] + +[[package]] +name = "oxc_ast_macros" +version = "0.81.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff7e8e42eea621e6fa7072a480fa04ae4dfe73f9a958822c066cc7f1eba57e82" +dependencies = [ + "phf", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "oxc_ast_visit" +version = "0.81.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0a968790ef8968de08d813a2fe1a15739930fb1c171fddeebf3545d802ea216" +dependencies = [ + "oxc_allocator", + "oxc_ast", + "oxc_span", + "oxc_syntax", +] + +[[package]] +name = "oxc_cfg" +version = "0.81.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5361ef66f38635d9671596d83abf44bcdbc3e39dbff303f3517828b86ad60e8f" +dependencies = [ + "bitflags", + "itertools", + "nonmax", + "oxc_index", + "oxc_syntax", + "petgraph", + "rustc-hash", +] + +[[package]] +name = "oxc_codegen" +version = "0.81.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fde8fc6f7337632fce3e9e51c150f57fb0ef234524c6947f63e8009e1ea613cf" +dependencies = [ + "bitflags", + "cow-utils", + "dragonbox_ecma", + "nonmax", + "oxc_allocator", + "oxc_ast", + "oxc_data_structures", + "oxc_index", + "oxc_semantic", + "oxc_sourcemap", + "oxc_span", + "oxc_syntax", + "rustc-hash", +] + +[[package]] +name = "oxc_data_structures" +version = "0.81.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16c58a40be09dbb289150046a39da2fb130eb5b8ff1f013f2963e880e1af261c" +dependencies = [ + "rustversion", +] + +[[package]] +name = "oxc_diagnostics" +version = "0.81.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "702bde62d95587518aec86247457830f189242f814fba8389beb6e8c1585c444" +dependencies = [ + "cow-utils", + "oxc-miette", + "percent-encoding", +] + +[[package]] +name = "oxc_ecmascript" +version = "0.81.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf4dab5abe97e790fe1b631a7eacb9a1cbecbe2dad5b984ef11fcb9115b376ab" +dependencies = [ + "cow-utils", + "num-bigint", + "num-traits", + "oxc_allocator", + "oxc_ast", + "oxc_span", + "oxc_syntax", +] + +[[package]] +name = "oxc_estree" +version = "0.81.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a453daacd90f168554015dd49467038ab6894045805f861950c1d0a8e6a5e" + +[[package]] +name = "oxc_index" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "967ae797e1f284bd1385f2d8e8ab94293ad27f623c76839ecf66827521365f5b" + +[[package]] +name = "oxc_parser" +version = "0.81.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70253728183a5a92d1ba964d9952201e0269ebd07c4917c4553283d564225bab" +dependencies = [ + "bitflags", + "cow-utils", + "memchr", + "num-bigint", + "num-traits", + "oxc_allocator", + "oxc_ast", + "oxc_data_structures", + "oxc_diagnostics", + "oxc_ecmascript", + "oxc_regular_expression", + "oxc_span", + "oxc_syntax", + "rustc-hash", + "seq-macro", +] + +[[package]] +name = "oxc_regular_expression" +version = "0.81.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de5bb2ed6646bb7815126e0f36787c349e13561fed2c05f95b27a1f82dd066c4" +dependencies = [ + "bitflags", + "oxc_allocator", + "oxc_ast_macros", + "oxc_diagnostics", + "oxc_span", + "phf", + "rustc-hash", + "unicode-id-start", +] + +[[package]] +name = "oxc_semantic" +version = "0.81.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06858ccc5976a5303ac903bef13d03b7f8860e837b7399e4899c05f5c729d30b" +dependencies = [ + "itertools", + "oxc_allocator", + "oxc_ast", + "oxc_ast_visit", + "oxc_cfg", + "oxc_data_structures", + "oxc_diagnostics", + "oxc_ecmascript", + "oxc_index", + "oxc_span", + "oxc_syntax", + "phf", + "rustc-hash", + "self_cell", +] + +[[package]] +name = "oxc_sourcemap" +version = "4.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d6057a9bbd4a84bb4146539123642ee66e0f5ac700b0fcddd193cabcb2e0460" +dependencies = [ + "base64-simd", + "rustc-hash", + "serde", + "serde_json", +] + +[[package]] +name = "oxc_span" +version = "0.81.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b315080fee7613168e0e08d3280efccdc12fea1efe5c0285e3a2d6265660f02" +dependencies = [ + "compact_str", + "oxc-miette", + "oxc_allocator", + "oxc_ast_macros", + "oxc_estree", +] + +[[package]] +name = "oxc_syntax" +version = "0.81.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d9d80d47d2eee5c1e6dbcdf1841b42afc4f1cd1f63701fc346162616dac52f" +dependencies = [ + "bitflags", + "cow-utils", + "dragonbox_ecma", + "nonmax", + "oxc_allocator", + "oxc_ast_macros", + "oxc_data_structures", + "oxc_estree", + "oxc_index", + "oxc_span", + "phf", + "rustc-hash", + "unicode-id-start", +] + [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", + "serde", +] + +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_generator" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cbb1126afed61dd6368748dae63b1ee7dc480191c6262a3b4ff1e29d86a6c5b" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d713258393a82f091ead52047ca779d37e5766226d009de21696c4e667044368" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher 1.0.3", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -962,6 +1635,12 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -990,6 +1669,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -1022,12 +1707,30 @@ dependencies = [ "hashlink", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + [[package]] name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +[[package]] +name = "seq-macro" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" + [[package]] name = "serde" version = "1.0.228" @@ -1077,6 +1780,17 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -1102,6 +1816,18 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "slab" version = "0.4.12" @@ -1114,12 +1840,211 @@ version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +[[package]] +name = "smawk" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100" + [[package]] name = "stable_deref_trait" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "string_enum" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae36a4951ca7bd1cfd991c241584a9824a70f6aff1e7d4f693fb3f2465e4030e" +dependencies = [ + "quote", + "swc_macros_common", + "syn", +] + +[[package]] +name = "swc_atoms" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c70a493080ceb12dabddb96905a3ddac679fbe9c71ddc81a2a769126e8cde7c4" +dependencies = [ + "hstr", + "once_cell", + "serde", +] + +[[package]] +name = "swc_common" +version = "26.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142d2a06cafce623859ca799f5d1935bb8a886f18b66a2585ddeadff210121d0" +dependencies = [ + "anyhow", + "ast_node", + "better_scoped_tls", + "bytes-str", + "either", + "from_variant", + "num-bigint", + "once_cell", + "rustc-hash", + "serde", + "siphasher 0.3.11", + "swc_atoms", + "swc_eq_ignore_macros", + "swc_visit", + "tracing", + "unicode-width", + "url", +] + +[[package]] +name = "swc_css" +version = "31.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31987180c85810a404ea619d02fb5e413cec3214ec2228f1fbca471b6019c903" +dependencies = [ + "swc_css_ast", + "swc_css_codegen", + "swc_css_minifier", + "swc_css_parser", + "swc_css_utils", + "swc_css_visit", +] + +[[package]] +name = "swc_css_ast" +version = "26.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e9af48ba0830de48912e6c3e8e8a18a4cf0f79a72ad7c1289b8fc5b0e48731" +dependencies = [ + "is-macro", + "string_enum", + "swc_atoms", + "swc_common", +] + +[[package]] +name = "swc_css_codegen" +version = "26.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c6a4170c77fac7abd665df0d091ab638bcdc0ee281f0eb498c8e71ca64dcde6" +dependencies = [ + "auto_impl", + "bitflags", + "rustc-hash", + "serde", + "swc_atoms", + "swc_common", + "swc_css_ast", + "swc_css_codegen_macros", + "swc_css_utils", +] + +[[package]] +name = "swc_css_codegen_macros" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7e32e407d0a010fedb53cf9dfdccf091521a2c9081efc077da647f7c8963741" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "swc_css_minifier" +version = "26.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85d9a3f76392784a9ece0308e1496cb10ea19287e0208e8d38241993f60e112f" +dependencies = [ + "rustc-hash", + "serde", + "swc_atoms", + "swc_common", + "swc_css_ast", + "swc_css_utils", + "swc_css_visit", +] + +[[package]] +name = "swc_css_parser" +version = "26.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e17341cb961ba38bed9840d4ed207b0477d1818457683b8f69aab2578bdfe19" +dependencies = [ + "lexical", + "serde", + "swc_atoms", + "swc_common", + "swc_css_ast", +] + +[[package]] +name = "swc_css_utils" +version = "26.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d10a9413198de0cac0957b162eea7ff5e282f5e1ff91839533f1c741ecdaba63" +dependencies = [ + "once_cell", + "rustc-hash", + "serde", + "serde_json", + "swc_atoms", + "swc_css_ast", + "swc_css_visit", +] + +[[package]] +name = "swc_css_visit" +version = "26.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fb5bf486cf0b2f9fac9d2c1cea6ca33a8c5060ef66876cbdbe2a0e886ed3588" +dependencies = [ + "serde", + "swc_atoms", + "swc_common", + "swc_css_ast", + "swc_visit", +] + +[[package]] +name = "swc_eq_ignore_macros" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c16ce73424a6316e95e09065ba6a207eba7765496fed113702278b7711d4b632" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "swc_macros_common" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aae1efbaa74943dc5ad2a2fb16cbd78b77d7e4d63188f3c5b4df2b4dcd2faaae" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "swc_visit" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7df72a1be54e379864cebf04f26abb4d7ee637977f62f8ec8387233f7a27e88" +dependencies = [ + "either", +] + [[package]] name = "syn" version = "2.0.118" @@ -1161,6 +2086,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "smawk", + "unicode-linebreak", + "unicode-width", +] + [[package]] name = "thiserror" version = "2.0.18" @@ -1268,6 +2204,16 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "triomphe" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b40688ea6389c8171614b25491f71d4a27946e0c7ce2da1c6de27e25abf1a0ae" +dependencies = [ + "serde", + "stable_deref_trait", +] + [[package]] name = "tungstenite" version = "0.29.0" @@ -1280,12 +2226,36 @@ dependencies = [ "thiserror", ] +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-id-start" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81b79ad29b5e19de4260020f8919b443b2ef0277d242ce532ec7b7a2cc8b6007" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + [[package]] name = "unicode-width" version = "0.2.2" @@ -1322,6 +2292,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "walkdir" version = "2.5.0" @@ -1566,16 +2542,25 @@ dependencies = [ "ariadne", "crossbeam", "fluent-uri", + "globset", "html5gum", "minijinja", "minijinja-contrib", "mio", + "oxc_allocator", + "oxc_codegen", + "oxc_parser", + "oxc_sourcemap", + "oxc_span", "percent-encoding", "pyo3", "regex", "saphyr", "serde", "serde_json", + "sha2", + "swc_common", + "swc_css", "tempfile", "thiserror", "tracing", diff --git a/Cargo.toml b/Cargo.toml index b7bfd99..a63e4f5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,6 +50,7 @@ base64 = "0.22" crossbeam = "0.8" file-id = "0.2" fluent-uri = "0.4" +globset = "0.4.18" html5gum = { version = "0.8.4", default-features = false } httparse = "1.10" httpdate = "1.0" @@ -59,6 +60,11 @@ mio = "1.2.0" minijinja = "2.19.0" minijinja-contrib = "2.19.0" notify = "8.2" +oxc_allocator = "0.81.0" +oxc_codegen = "0.81.0" +oxc_parser = "0.81.0" +oxc_sourcemap = "=4.0.5" +oxc_span = "0.81.0" percent-encoding = "2.3" regex = "1.12.3" saphyr = "0.0.6" @@ -66,6 +72,9 @@ sha1_smol = "1.0" slab = "0.4.12" serde = "1.0" serde_json = "1.0" +sha2 = "0.10.9" +swc_common = "26.0.0" +swc_css = { version = "31.0.0", features = ["minifier"] } thiserror = "2.0.18" tungstenite = { version = "0.29.0", default-features = false } tracing = { version = "0.1" } diff --git a/crates/zensical/Cargo.toml b/crates/zensical/Cargo.toml index d7115a2..83fe708 100644 --- a/crates/zensical/Cargo.toml +++ b/crates/zensical/Cargo.toml @@ -50,18 +50,27 @@ anyhow.workspace = true ariadne.workspace = true crossbeam.workspace = true fluent-uri.workspace = true +globset.workspace = true html5gum.workspace = true minijinja = { workspace = true, features = [ "json", "loader", "builtins", "urlencode" ] } minijinja-contrib = { workspace = true, features = ["html_entities"] } mio = { workspace = true, features = ["net", "os-poll"] } +oxc_allocator.workspace = true +oxc_codegen.workspace = true +oxc_parser.workspace = true +oxc_sourcemap.workspace = true +oxc_span.workspace = true percent-encoding.workspace = true pyo3.workspace = true regex.workspace = true saphyr.workspace = true serde = { workspace = true, features = ["derive", "rc"] } serde_json.workspace = true +sha2.workspace = true +swc_common.workspace = true +swc_css.workspace = true thiserror.workspace = true tracing = { workspace = true, optional = true, features = [ "max_level_trace" diff --git a/crates/zensical/src/compat/mkdocs/plugin.rs b/crates/zensical/src/compat/mkdocs/plugin.rs index 1cd698f..95da046 100644 --- a/crates/zensical/src/compat/mkdocs/plugin.rs +++ b/crates/zensical/src/compat/mkdocs/plugin.rs @@ -14,6 +14,7 @@ use crate::structure::markdown::Markdown; pub mod autorefs; pub mod meta; +pub mod minify; pub mod mkdocstrings; pub mod redirects; pub mod search; diff --git a/crates/zensical/src/compat/mkdocs/plugin/minify.rs b/crates/zensical/src/compat/mkdocs/plugin/minify.rs new file mode 100644 index 0000000..fae4622 --- /dev/null +++ b/crates/zensical/src/compat/mkdocs/plugin/minify.rs @@ -0,0 +1,73 @@ +// Copyright (c) 2025-2026 Zensical and contributors + +// SPDX-License-Identifier: MIT +// All contributions are certified under the DCO + +//! MkDocs-compatible minify plugin. + +use crate::config::plugins::MinifyPluginConfig; +use crate::config::Config; +use std::path::Path; + +pub(crate) mod asset; +mod html; +mod script; +mod style; + +/// Resolved minification settings shared by page render tasks. +#[derive(Clone, Debug)] +pub(crate) struct Settings { + config: MinifyPluginConfig, +} + +impl Settings { + /// Resolves minification settings from project configuration. + pub(crate) fn new(config: &Config) -> Self { + Self { + config: config.project.plugins.minify.config.clone(), + } + } + + /// Processes one final HTML document after all compatibility mutations. + /// + /// External asset options stay in the resolved plugin configuration for + /// the asset output stage and do not affect this document-local pass. + pub(crate) fn html(&self, content: impl Into) -> String { + let content = content.into(); + if !self.config.enabled { + return content; + } + if self.config.minify_html { + html::minify( + &content, + &self.config.htmlmin_opts, + self.config.minify_inline_js, + self.config.minify_inline_css, + ) + } else if self.config.minify_inline_js || self.config.minify_inline_css + { + html::minify_inline( + content, + self.config.minify_inline_js, + self.config.minify_inline_css, + ) + } else { + content + } + } + + /// Processes a rendered static template when it produces HTML. + pub(crate) fn template( + &self, name: &str, content: impl Into, + ) -> String { + let content = content.into(); + if Path::new(name) + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("html")) + { + self.html(content) + } else { + content + } + } +} diff --git a/crates/zensical/src/compat/mkdocs/plugin/minify/asset.rs b/crates/zensical/src/compat/mkdocs/plugin/minify/asset.rs new file mode 100644 index 0000000..6dd99ef --- /dev/null +++ b/crates/zensical/src/compat/mkdocs/plugin/minify/asset.rs @@ -0,0 +1,626 @@ +// Copyright (c) 2025-2026 Zensical and contributors + +// SPDX-License-Identifier: MIT +// All contributions are certified under the DCO + +//! External asset processing for the MkDocs-compatible minify plugin. + +use anyhow::{anyhow, Context as _}; +use globset::{GlobBuilder, GlobSet, GlobSetBuilder}; +use sha2::{Digest, Sha384}; +use std::collections::{BTreeMap, BTreeSet}; +use std::hash::{DefaultHasher, Hash, Hasher}; +use std::path::{Component, Path, PathBuf}; +use std::sync::Arc; +use std::{fs, io}; +use zrx::id::Id; +use zrx::scheduler::action::{Action, Concurrency, Context}; +use zrx::stream::function::Collection; +use zrx::stream::operator::Operator; +use zrx::stream::{Change, Key, Signal, Stream, Value}; + +use crate::config::plugins::MinifyPluginConfig; +use crate::config::{Config, Project}; + +use super::{script, style}; + +// ----------------------------------------------------------------------------- +// Structs +// ----------------------------------------------------------------------------- + +/// One source that may become a site asset. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct Resource { + /// Logical output path relative to the site directory. + pub(crate) path: String, + /// Physical source path. + pub(crate) source: PathBuf, + /// Override priority, with lower values taking precedence. + pub(crate) priority: usize, +} + +impl Value for Resource {} + +/// Final asset bytes or a source file that can be copied without reading it. +#[derive(Clone, Debug)] +enum Contents { + Copy(PathBuf), + Bytes(Arc<[u8]>), +} + +/// One fully resolved output asset. +#[derive(Clone, Debug)] +struct Emission { + source_path: String, + output_path: String, + contents: Contents, + claimed: bool, +} + +impl Value for Emission {} + +/// Path rewrite produced by one claimed asset. +#[derive(Clone, Debug, PartialEq, Eq)] +struct Mapping { + source_path: String, + output_path: String, +} + +impl Value for Mapping {} + +/// Asset-derived project view consumed by template rendering. +#[derive(Clone, Debug)] +pub(crate) struct Manifest { + /// Project with configured asset paths rewritten to their emitted names. + pub(crate) project: Arc, + /// Stable hash of the current path mapping. + pub(crate) hash: u64, +} + +impl Value for Manifest {} + +/// Compiled settings for external asset selection and transformation. +#[derive(Clone, Debug)] +struct Settings { + enabled: bool, + javascript: Selectors, + stylesheet: Selectors, + minify: BTreeSet, + cache_safe: bool, +} + +/// Exact and glob selectors for one kind of asset. +#[derive(Clone, Debug)] +struct Selectors { + exact: BTreeSet, + globs: GlobSet, + error: Option, +} + +/// Writes insertions and removes retracted output paths. +#[derive(Clone)] +struct Writer { + root_dir: PathBuf, + site_dir: String, +} + +// ----------------------------------------------------------------------------- +// Implementations +// ----------------------------------------------------------------------------- + +impl Settings { + fn new(config: &MinifyPluginConfig) -> Self { + Self { + enabled: config.enabled, + javascript: Selectors::new(&config.js_files), + stylesheet: Selectors::new(&config.css_files), + minify: [ + config.minify_js.then_some(AssetKind::JavaScript), + config.minify_css.then_some(AssetKind::Stylesheet), + ] + .into_iter() + .flatten() + .collect(), + cache_safe: config.cache_safe, + } + } + + fn claim(&self, path: &str) -> anyhow::Result> { + if !self.active() { + return Ok(None); + } + let javascript = self.active_kind(AssetKind::JavaScript) + && self.javascript.matches(path)?; + let stylesheet = self.active_kind(AssetKind::Stylesheet) + && self.stylesheet.matches(path)?; + match (javascript, stylesheet) { + (true, true) => Err(anyhow!( + "asset is selected as both JavaScript and CSS: {path}" + )), + (true, false) => { + ensure_extension(path, "js")?; + Ok(Some(AssetKind::JavaScript)) + } + (false, true) => { + ensure_extension(path, "css")?; + Ok(Some(AssetKind::Stylesheet)) + } + (false, false) => Ok(None), + } + } + + fn transform(&self, resource: &Resource) -> anyhow::Result { + let Some(kind) = self.claim(&resource.path)? else { + return Ok(Emission { + source_path: resource.path.clone(), + output_path: resource.path.clone(), + contents: Contents::Copy(resource.source.clone()), + claimed: false, + }); + }; + + let source = + fs::read_to_string(&resource.source).with_context(|| { + format!("failed to read asset {}", resource.source.display()) + })?; + let minify = self.minify.contains(&kind); + let transformed = if minify { + match kind { + AssetKind::JavaScript => script::minify(&source, false), + AssetKind::Stylesheet => style::minify(&source), + } + .filter(|output| output.len() < source.len()) + .unwrap_or(source) + } else { + source + }; + let bytes = Arc::<[u8]>::from(transformed.into_bytes()); + let output_path = output_path( + &resource.path, + minify, + self.cache_safe.then(|| digest(&bytes)), + )?; + Ok(Emission { + source_path: resource.path.clone(), + output_path, + contents: Contents::Bytes(bytes), + claimed: true, + }) + } + + fn validate<'a>( + &self, mappings: impl Iterator, + ) -> anyhow::Result<()> { + if !self.active() { + return Ok(()); + } + for (kind, selectors) in [ + (AssetKind::JavaScript, &self.javascript), + (AssetKind::Stylesheet, &self.stylesheet), + ] { + if !self.active_kind(kind) { + continue; + } + if let Some(error) = &selectors.error { + return Err(anyhow!("invalid minify asset selector: {error}")); + } + } + let found = mappings + .map(|mapping| mapping.source_path.as_str()) + .collect::>(); + let missing = [ + (AssetKind::JavaScript, &self.javascript), + (AssetKind::Stylesheet, &self.stylesheet), + ] + .into_iter() + .filter(|(kind, _)| self.active_kind(*kind)) + .flat_map(|(_, selectors)| &selectors.exact) + .filter(|path| !found.contains(path.as_str())) + .collect::>(); + if missing.is_empty() { + Ok(()) + } else { + Err(anyhow!( + "selected asset does not exist: {}", + missing + .iter() + .map(|path| path.as_str()) + .collect::>() + .join(", ") + )) + } + } + + fn active(&self) -> bool { + self.active_kind(AssetKind::JavaScript) + || self.active_kind(AssetKind::Stylesheet) + } + + fn active_kind(&self, kind: AssetKind) -> bool { + if !self.enabled || (!self.minify.contains(&kind) && !self.cache_safe) { + return false; + } + match kind { + AssetKind::JavaScript => !self.javascript.is_empty(), + AssetKind::Stylesheet => !self.stylesheet.is_empty(), + } + } +} + +impl Selectors { + fn new(patterns: &[String]) -> Self { + let mut exact = BTreeSet::new(); + let mut builder = GlobSetBuilder::new(); + let mut error = None; + for pattern in patterns { + let pattern = normalize(pattern); + if let Err(reason) = validate_relative(&pattern) { + error.get_or_insert_with(|| reason.to_string()); + continue; + } + if contains_glob(&pattern) { + match GlobBuilder::new(&pattern) + .literal_separator(true) + .backslash_escape(false) + .build() + { + Ok(pattern) => { + builder.add(pattern); + } + Err(reason) => { + error.get_or_insert_with(|| reason.to_string()); + } + } + } else { + exact.insert(pattern); + } + } + let globs = builder.build().unwrap_or_else(|reason| { + error.get_or_insert_with(|| reason.to_string()); + GlobSetBuilder::new().build().expect("empty glob set") + }); + Self { exact, globs, error } + } + + fn matches(&self, path: &str) -> anyhow::Result { + if let Some(error) = &self.error { + return Err(anyhow!("invalid minify asset selector: {error}")); + } + let path = normalize(path); + Ok(self.exact.contains(&path) || self.globs.is_match(path)) + } + + fn is_empty(&self) -> bool { + self.error.is_none() && self.exact.is_empty() && self.globs.is_empty() + } +} + +impl Manifest { + pub(crate) fn base(project: Arc) -> Self { + Self { project, hash: 0 } + } + + fn new<'a>( + project: Arc, mappings: impl Iterator, + ) -> Self { + let paths = mappings + .map(|mapping| { + (mapping.source_path.clone(), mapping.output_path.clone()) + }) + .collect::>(); + if paths.is_empty() { + return Self { project, hash: 0 }; + } + + let mut projected = (*project).clone(); + projected.extra_css = projected + .extra_css + .iter() + .map(|path| rewrite(path, &paths)) + .collect(); + projected.extra_javascript = projected + .extra_javascript + .iter() + .cloned() + .map(|mut script| { + script.path = rewrite(&script.path, &paths); + script + }) + .collect(); + let changed = projected.extra_css != project.extra_css + || projected + .extra_javascript + .iter() + .map(|script| &script.path) + .ne(project.extra_javascript.iter().map(|script| &script.path)); + if !changed { + return Self { project, hash: 0 }; + } + let mut hasher = DefaultHasher::new(); + projected.extra_css.hash(&mut hasher); + projected.extra_javascript.hash(&mut hasher); + Self { + project: Arc::new(projected), + hash: hasher.finish(), + } + } +} + +impl Writer { + fn output_path(&self, key: &Key) -> anyhow::Result { + let id = key.try_as_id()?; + if id.context() != self.site_dir { + return Err(anyhow!("asset output escaped the site directory")); + } + let location = id.location(); + validate_relative(&location)?; + Ok(self.root_dir.join(id.to_path())) + } + + fn insert(&self, key: &Key, emission: &Emission) -> anyhow::Result<()> { + let path = self.output_path(key)?; + fs::create_dir_all(path.parent().expect("site asset has parent"))?; + match &emission.contents { + Contents::Copy(source) => copy_file(source, path)?, + Contents::Bytes(bytes) => fs::write(path, bytes)?, + } + Ok(()) + } + + fn remove(&self, key: &Key) -> anyhow::Result<()> { + let path = self.output_path(key)?; + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.into()), + } + } +} + +impl Action> for Writer { + type Inputs = (Emission,); + type Output = (); + + fn concurrency(&self) -> Concurrency { + Concurrency::adaptive() + } + + fn execute(&mut self, context: Context<'_, Key, Self>) { + let Context { inputs: input, output, .. } = context; + input.for_each(output, |change, emit| { + match change { + Change::Insert(key, emission) => { + self.insert(&key, emission.as_ref())?; + emit.insert(key, ()); + } + Change::Remove(key) => { + self.remove(&key)?; + emit.remove(key); + } + } + Ok(()) + }); + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +enum AssetKind { + JavaScript, + Stylesheet, +} + +// ----------------------------------------------------------------------------- +// Functions +// ----------------------------------------------------------------------------- + +/// Transforms selected resources, writes every effective asset, and publishes +/// the project view whose configured asset paths name the emitted files. +pub(crate) fn attach( + config: &Config, resources: &Stream, +) -> Signal { + let settings = Settings::new(&config.project.plugins.minify.config); + let settings_for_transform = settings.clone(); + let emissions = resources.map(move |resource: &Resource| { + settings_for_transform.transform(resource) + }); + + let site_dir = config.project.site_dir.clone(); + let site_dir_for_key = site_dir.clone(); + let outputs = emissions.unique_by_key(move |emission: &Emission| { + output_key(&site_dir_for_key, &emission.output_path) + }); + let _ = outputs.subscribe(Writer { + root_dir: config.get_root_dir(), + site_dir, + }); + + let project = config.project.clone(); + let settings_for_manifest = settings; + outputs + .filter(|emission: &Emission| emission.claimed) + .map(|emission: &Emission| Mapping { + source_path: emission.source_path.clone(), + output_path: emission.output_path.clone(), + }) + .reduce(move |mappings: &dyn Collection, Mapping>| { + settings_for_manifest + .validate(mappings.iter().map(|(_, mapping)| mapping))?; + Ok::<_, anyhow::Error>(Some(Manifest::new( + project.clone(), + mappings.iter().map(|(_, mapping)| mapping), + ))) + }) +} + +/// Returns whether configuration claims any external asset resources. +pub(crate) fn is_enabled(config: &Config) -> bool { + Settings::new(&config.project.plugins.minify.config).active() +} + +/// Creates the group key used to resolve theme and project overrides before +/// any asset is transformed or written. +pub(crate) fn resource_key(path: &str) -> anyhow::Result> { + validate_relative(path)?; + let id = Id::builder() + .provider("asset") + .context(".") + .location(path) + .build()?; + Ok(Key::from(id)) +} + +fn output_key(site_dir: &str, path: &str) -> anyhow::Result> { + validate_relative(path)?; + let id = Id::builder() + .provider("file") + .context(site_dir) + .location(path) + .build()?; + Ok(Key::from(id)) +} + +fn output_path( + path: &str, minify: bool, digest: Option, +) -> anyhow::Result { + let path = Path::new(path); + let extension = path + .extension() + .and_then(|value| value.to_str()) + .ok_or_else(|| { + anyhow!("selected asset has no extension: {}", path.display()) + })?; + let stem = path + .file_stem() + .and_then(|value| value.to_str()) + .ok_or_else(|| { + anyhow!("selected asset has no file name: {}", path.display()) + })?; + let mut name = stem.to_owned(); + if let Some(digest) = digest { + name.push('.'); + name.push_str(&digest[..6]); + } + if minify { + name.push_str(".min"); + } + name.push('.'); + name.push_str(extension); + Ok(path + .with_file_name(name) + .to_string_lossy() + .replace('\\', "/")) +} + +fn digest(bytes: &[u8]) -> String { + format!("{:x}", Sha384::digest(bytes)) +} + +fn rewrite(path: &str, mappings: &BTreeMap) -> String { + let normalized = normalize(path); + mappings + .get(&normalized) + .cloned() + .unwrap_or_else(|| path.to_owned()) +} + +fn normalize(path: &str) -> String { + path.replace('\\', "/") + .trim_start_matches("./") + .trim_start_matches('/') + .to_owned() +} + +fn contains_glob(pattern: &str) -> bool { + pattern + .bytes() + .any(|byte| matches!(byte, b'*' | b'?' | b'[' | b']')) +} + +fn validate_relative(path: &str) -> anyhow::Result<()> { + if path.is_empty() + || Path::new(path).is_absolute() + || Path::new(path) + .components() + .any(|component| matches!(component, Component::ParentDir)) + { + return Err(anyhow!("asset path must be relative to the site: {path}")); + } + Ok(()) +} + +fn ensure_extension(path: &str, expected: &str) -> anyhow::Result<()> { + if Path::new(path) + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case(expected)) + { + Ok(()) + } else { + Err(anyhow!( + "selected asset must have a .{expected} extension: {path}" + )) + } +} + +fn copy_file(from: impl AsRef, to: impl AsRef) -> io::Result<()> { + let mut from = fs::File::open(from)?; + let mut to = fs::File::create(to)?; + io::copy(&mut from, &mut to).map(|_| ()) +} + +// ----------------------------------------------------------------------------- +// Tests +// ----------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn selectors_support_exact_paths_and_recursive_globs() { + let selectors = + Selectors::new(&["scripts/app.js".into(), "vendor/**/*.js".into()]); + assert!(selectors.matches("./scripts/app.js").unwrap()); + assert!(selectors.matches("vendor/lib/tool.js").unwrap()); + assert!(!selectors.matches("scripts/other.js").unwrap()); + } + + #[test] + fn output_names_follow_minify_and_cache_safe_modes() { + assert_eq!( + output_path("assets/app.js", true, None).unwrap(), + "assets/app.min.js" + ); + assert_eq!( + output_path("assets/app.js", false, Some("abcdef12".into())) + .unwrap(), + "assets/app.abcdef.js" + ); + assert_eq!( + output_path("assets/app.js", true, Some("abcdef12".into())) + .unwrap(), + "assets/app.abcdef.min.js" + ); + } + + #[test] + fn digest_uses_sha384_and_emitted_bytes() { + assert_eq!(&digest(b"const value=1;")[..6], "11c998"); + } + + #[test] + fn rewrite_preserves_unmatched_and_structured_paths() { + let paths = BTreeMap::from([( + "assets/app.js".into(), + "assets/app.abcdef.min.js".into(), + )]); + assert_eq!( + rewrite("./assets/app.js", &paths), + "assets/app.abcdef.min.js" + ); + assert_eq!( + rewrite("https://example.com/app.js", &paths), + "https://example.com/app.js" + ); + } +} diff --git a/crates/zensical/src/compat/mkdocs/plugin/minify/html.rs b/crates/zensical/src/compat/mkdocs/plugin/minify/html.rs new file mode 100644 index 0000000..641ee15 --- /dev/null +++ b/crates/zensical/src/compat/mkdocs/plugin/minify/html.rs @@ -0,0 +1,1045 @@ +// Copyright (c) 2025-2026 Zensical and contributors + +// SPDX-License-Identifier: MIT +// All contributions are certified under the DCO + +//! HTML minification over the shared HTML tokenizer. + +use html5gum::emitters::callback::{CallbackEmitter, CallbackEvent}; +use html5gum::{Span, Tokenizer}; +use std::convert::Infallible; +use std::ops::Range; + +use crate::config::plugins::HtmlMinOptions; + +use super::{script, style}; + +/// Minifies a complete rendered HTML document. +pub(super) fn minify( + input: &str, options: &HtmlMinOptions, inline_script: bool, + inline_style: bool, +) -> String { + let mut serializer = + Serializer::new(input, options, inline_script, inline_style); + { + let mut emitter = CallbackEmitter::new( + |event: CallbackEvent<'_>, span: Span| { + serializer.event(event, span); + None:: + }, + ); + emitter.naively_switch_states(true); + Tokenizer::new_with_emitter(input, emitter) + .finish() + .expect("string input is infallible"); + } + serializer.finish() +} + +/// Minifies only inline language bodies, retaining all surrounding HTML. +pub(super) fn minify_inline( + input: String, inline_script: bool, inline_style: bool, +) -> String { + let edits = { + let mut editor = InlineEditor::new(&input, inline_script, inline_style); + { + let mut emitter = CallbackEmitter::new( + |event: CallbackEvent<'_>, span: Span| { + editor.event(event, span); + None:: + }, + ); + emitter.naively_switch_states(true); + Tokenizer::new_with_emitter(input.as_str(), emitter) + .finish() + .expect("string input is infallible"); + } + editor.finish() + }; + if edits.is_empty() { + return input; + } + + let removed = edits.iter().map(|(range, _)| range.len()).sum::(); + let inserted = edits.iter().map(|(_, value)| value.len()).sum::(); + let mut output = String::with_capacity(input.len() - removed + inserted); + let mut cursor = 0; + for (range, value) in edits { + output.push_str(&input[cursor..range.start]); + output.push_str(&value); + cursor = range.end; + } + output.push_str(&input[cursor..]); + output +} + +/// Span editor used when only inline language minification is enabled. +struct InlineEditor<'a> { + input: &'a str, + inline_script: bool, + inline_style: bool, + tag: Option, + active: Option<(InlineKind, usize)>, + edits: Vec<(Range, String)>, +} + +impl<'a> InlineEditor<'a> { + fn new(input: &'a str, inline_script: bool, inline_style: bool) -> Self { + Self { + input, + inline_script, + inline_style, + tag: None, + active: None, + edits: Vec::new(), + } + } + + fn event(&mut self, event: CallbackEvent<'_>, span: Span) { + match event { + CallbackEvent::OpenStartTag { name } => { + self.tag = Some(StartTag { + name: String::from_utf8_lossy(name).into_owned(), + output_name: source_name(self.input, span, 1, name.len()), + attributes: Vec::new(), + attribute: None, + }); + } + CallbackEvent::AttributeName { name } => { + self.attribute_name(name, span); + } + CallbackEvent::AttributeValue { value } => { + self.attribute_value(value, span); + } + CallbackEvent::CloseStartTag { self_closing } => { + self.close_start_tag(self_closing, span.end); + } + CallbackEvent::EndTag { name } => self.end_tag(name, span.start), + _ => {} + } + } + + fn attribute_name(&mut self, name: &[u8], span: Span) { + let Some(tag) = self.tag.as_mut() else { + return; + }; + if let Some(attribute) = tag.attribute.take() { + tag.attributes.push(attribute); + } + tag.attribute = Some(Attribute { + name: String::from_utf8_lossy(name).into_owned(), + output_name: source_name(self.input, span, 0, name.len()), + value: None, + }); + } + + fn attribute_value(&mut self, value: &[u8], span: Span) { + if let Some(attribute) = + self.tag.as_mut().and_then(|tag| tag.attribute.as_mut()) + { + attribute.value = Some(Value { + decoded: String::from_utf8_lossy(value).into_owned(), + raw: self.input[span.start..span.end].into(), + }); + } + } + + fn close_start_tag(&mut self, self_closing: bool, end: usize) { + let Some(mut tag) = self.tag.take() else { + return; + }; + if let Some(attribute) = tag.attribute.take() { + tag.attributes.push(attribute); + } + if self_closing { + return; + } + self.active = match tag.name.as_str() { + "script" if self.inline_script && is_javascript(&tag) => { + Some((InlineKind::Script { module: is_module(&tag) }, end)) + } + "style" if self.inline_style && is_css(&tag) => { + Some((InlineKind::Style, end)) + } + _ => None, + }; + } + + fn end_tag(&mut self, name: &[u8], end: usize) { + let expected = match self.active.as_ref().map(|item| item.0) { + Some(InlineKind::Script { .. }) => b"script".as_slice(), + Some(InlineKind::Style) => b"style".as_slice(), + None => return, + }; + if name != expected { + return; + } + + let (kind, start) = self.active.take().expect("active"); + let source = &self.input[start..end]; + let output = match kind { + InlineKind::Script { module } => script::minify(source, module), + InlineKind::Style => style::minify(source), + }; + if let Some(output) = output + && output.len() < source.len() + { + self.edits.push((start..end, output)); + } + } + + fn finish(self) -> Vec<(Range, String)> { + self.edits + } +} + +/// One parsed attribute waiting for serialization. +#[derive(Debug)] +struct Attribute { + name: String, + output_name: String, + value: Option, +} + +/// One parsed attribute value in decoded and source forms. +#[derive(Debug)] +struct Value { + decoded: String, + raw: String, +} + +/// One start tag being assembled from tokenizer events. +#[derive(Debug)] +struct StartTag { + name: String, + output_name: String, + attributes: Vec, + attribute: Option, +} + +/// One open element relevant to minification state. +#[derive(Debug)] +struct Element { + name: String, + preserve: bool, + language: Option, +} + +/// Inline language content buffered until its closing tag. +#[derive(Debug)] +struct Inline { + kind: InlineKind, + source: String, +} + +/// Inline language selected from a script or style element. +#[derive(Clone, Copy, Debug)] +enum InlineKind { + Script { module: bool }, + Style, +} + +/// Stateful serializer consuming html5gum callback events. +struct Serializer<'a> { + input: &'a str, + options: &'a HtmlMinOptions, + inline_script: bool, + inline_style: bool, + output: String, + start_tag: Option, + elements: Vec, + inline: Option, + after_doctype: bool, +} + +impl<'a> Serializer<'a> { + fn new( + input: &'a str, options: &'a HtmlMinOptions, inline_script: bool, + inline_style: bool, + ) -> Self { + Self { + input, + options, + inline_script, + inline_style, + output: String::with_capacity(input.len()), + start_tag: None, + elements: Vec::new(), + inline: None, + after_doctype: false, + } + } + + fn event(&mut self, event: CallbackEvent<'_>, span: Span) { + match event { + CallbackEvent::OpenStartTag { name } => { + self.open_start_tag(name, span); + } + CallbackEvent::AttributeName { name } => { + self.attribute_name(name, span); + } + CallbackEvent::AttributeValue { value } => { + self.attribute_value(value, span); + } + CallbackEvent::CloseStartTag { self_closing } => { + self.close_start_tag(self_closing); + } + CallbackEvent::EndTag { name } => self.end_tag(name, span), + CallbackEvent::String { value } => self.text(value, span), + CallbackEvent::Comment { value } => self.comment(value), + CallbackEvent::Doctype { + name, + public_identifier, + system_identifier, + .. + } => self.doctype(name, public_identifier, system_identifier), + CallbackEvent::Error(_) => {} + } + } + + fn open_start_tag(&mut self, name: &[u8], span: Span) { + let name = String::from_utf8_lossy(name).into_owned(); + self.close_optional_element(&name); + self.start_tag = Some(StartTag { + output_name: source_name(self.input, span, 1, name.len()), + name, + attributes: Vec::new(), + attribute: None, + }); + self.after_doctype = false; + } + + fn attribute_name(&mut self, name: &[u8], span: Span) { + let Some(tag) = self.start_tag.as_mut() else { + return; + }; + if let Some(attribute) = tag.attribute.take() { + tag.attributes.push(attribute); + } + tag.attribute = Some(Attribute { + name: String::from_utf8_lossy(name).into_owned(), + output_name: source_name(self.input, span, 0, name.len()), + value: None, + }); + } + + fn attribute_value(&mut self, value: &[u8], span: Span) { + let Some(attribute) = self + .start_tag + .as_mut() + .and_then(|tag| tag.attribute.as_mut()) + else { + return; + }; + attribute.value = Some(Value { + decoded: String::from_utf8_lossy(value).into_owned(), + raw: self.input[span.start..span.end].into(), + }); + } + + fn close_start_tag(&mut self, self_closing: bool) { + let Some(mut tag) = self.start_tag.take() else { + return; + }; + if let Some(attribute) = tag.attribute.take() { + tag.attributes.push(attribute); + } + + let parent_language = self + .elements + .last() + .and_then(|element| element.language.clone()); + let language = tag + .attributes + .iter() + .find(|attribute| attribute.name == "lang") + .and_then(|attribute| attribute.value.as_ref()) + .map(|value| value.decoded.clone()) + .or_else(|| parent_language.clone()); + let preserve = self.serialize_start_tag( + &tag, + self_closing, + parent_language.as_deref(), + ); + + if is_void(&tag.name) || self_closing { + return; + } + + let preserve = preserve + || self.is_preserved() + || tag.name == "script" + || tag.name == "style" + || self + .options + .pre_tags + .iter() + .any(|name| name.eq_ignore_ascii_case(&tag.name)); + self.elements.push(Element { + name: tag.name.clone(), + preserve, + language, + }); + + let kind = match tag.name.as_str() { + "script" if self.inline_script && is_javascript(&tag) => { + Some(InlineKind::Script { module: is_module(&tag) }) + } + "style" if self.inline_style && is_css(&tag) => { + Some(InlineKind::Style) + } + _ => None, + }; + if let Some(kind) = kind { + self.inline = Some(Inline { kind, source: String::new() }); + } + } + + fn serialize_start_tag( + &mut self, tag: &StartTag, self_closing: bool, + parent_language: Option<&str>, + ) -> bool { + self.output.push('<'); + self.output.push_str(&tag.output_name); + + let mut preserve = false; + for attribute in &tag.attributes { + let prefixed = attribute + .name + .strip_prefix(&format!("{}-", self.options.pre_attr)); + let name = prefixed.unwrap_or(&attribute.name); + let protected = prefixed.is_some(); + let output_name = if protected { + attribute + .output_name + .get(self.options.pre_attr.len() + 1..) + .unwrap_or(&attribute.output_name) + } else { + &attribute.output_name + }; + + if name == self.options.pre_attr { + preserve = true; + if !self.options.keep_pre && !protected { + continue; + } + } + if name == "lang" + && attribute.value.as_ref().is_some_and(|value| { + parent_language == Some(value.decoded.as_str()) + }) + { + continue; + } + + self.output.push(' '); + self.output.push_str(output_name); + let Some(value) = &attribute.value else { + continue; + }; + if (self.options.reduce_empty_attributes + && value.decoded.is_empty()) + || (self.options.reduce_boolean_attributes + && is_boolean_attribute(&tag.name, name)) + { + continue; + } + + self.output.push('='); + let value = if protected || !self.options.convert_charrefs { + value.raw.as_str() + } else { + value.decoded.as_str() + }; + serialize_attribute_value( + &mut self.output, + value, + self.options.remove_optional_attribute_quotes, + protected || !self.options.convert_charrefs, + ); + } + + if self_closing && !is_void(&tag.name) { + self.output.push_str("/>"); + } else { + self.output.push('>'); + } + preserve + } + + fn end_tag(&mut self, name: &[u8], span: Span) { + let name = String::from_utf8_lossy(name); + if matches!(name.as_ref(), "script" | "style") { + self.finish_inline(); + } + if name == "title" { + while self.output.ends_with(char::is_whitespace) { + self.output.pop(); + } + } + if !is_void(&name) { + self.output.push_str("'); + } + if let Some(index) = self + .elements + .iter() + .rposition(|element| element.name == name) + { + self.elements.truncate(index); + } + } + + fn text(&mut self, value: &[u8], span: Span) { + if let Some(inline) = self.inline.as_mut() { + inline.source.push_str(&self.input[span.start..span.end]); + return; + } + if self.is_preserved() { + self.output.push_str(&self.input[span.start..span.end]); + return; + } + + let value = String::from_utf8_lossy(value); + let only_whitespace = value.chars().all(is_html_whitespace); + if only_whitespace + && (self.options.remove_all_empty_space + || self.in_head() + || self.after_doctype + || (self.options.remove_empty_space + && value.contains(['\n', '\r']))) + { + return; + } + + let mut value = collapse_whitespace(&value); + if self.in_title() && self.output.ends_with("") { + value = value.trim_start().into(); + } + if self.output.ends_with(' ') && value.starts_with(' ') { + value.remove(0); + } + escape_text(&mut self.output, &value); + } + + fn comment(&mut self, value: &[u8]) { + let value = String::from_utf8_lossy(value); + if self.options.remove_comments { + if let Some(value) = value.strip_prefix('!') { + self.output.push_str("<!--"); + self.output.push_str(value); + self.output.push_str("-->"); + } else if value.trim_start().starts_with("[if ") { + self.output.push_str("<!--"); + self.output.push_str(&value); + self.output.push_str("-->"); + } + } else { + self.output.push_str("<!--"); + self.output.push_str(&value); + self.output.push_str("-->"); + } + } + + fn doctype( + &mut self, name: &[u8], public: Option<&[u8]>, system: Option<&[u8]>, + ) { + self.output.push_str("<!doctype "); + self.output.push_str(&String::from_utf8_lossy(name)); + if let Some(public) = public { + self.output.push_str(" public \""); + self.output.push_str(&String::from_utf8_lossy(public)); + self.output.push('"'); + } + if let Some(system) = system { + if public.is_none() { + self.output.push_str(" system"); + } + self.output.push_str(" \""); + self.output.push_str(&String::from_utf8_lossy(system)); + self.output.push('"'); + } + self.output.push('>'); + self.after_doctype = true; + } + + fn finish_inline(&mut self) { + let Some(inline) = self.inline.take() else { + return; + }; + let output = match inline.kind { + InlineKind::Script { module } => { + script::minify(&inline.source, module) + } + InlineKind::Style => style::minify(&inline.source), + }; + if let Some(output) = + output.filter(|output| output.len() < inline.source.len()) + { + self.output.push_str(&output); + } else { + self.output.push_str(&inline.source); + } + } + + fn is_preserved(&self) -> bool { + self.elements.last().is_some_and(|element| element.preserve) + } + + fn in_head(&self) -> bool { + self.elements.iter().any(|element| element.name == "head") + } + + fn in_title(&self) -> bool { + self.elements + .last() + .is_some_and(|element| element.name == "title") + } + + fn close_optional_element(&mut self, next: &str) { + let Some(current) = self.elements.last() else { + return; + }; + if closes_on_start(¤t.name, next) { + self.elements.pop(); + } + } + + fn finish(mut self) -> String { + self.finish_inline(); + self.output + } +} + +fn collapse_whitespace(value: &str) -> String { + let mut output = String::with_capacity(value.len()); + let mut whitespace = false; + for character in value.chars() { + if is_html_whitespace(character) { + whitespace = true; + } else { + if whitespace { + output.push(' '); + whitespace = false; + } + output.push(character); + } + } + if whitespace { + output.push(' '); + } + output +} + +fn source_name( + input: &str, span: Span<usize>, prefix: usize, length: usize, +) -> String { + let start = span.start.saturating_add(prefix); + let end = (start + length).min(input.len()); + input.get(start..end).unwrap_or_default().into() +} + +fn is_html_whitespace(character: char) -> bool { + matches!(character, '\t' | '\n' | '\u{c}' | '\r' | ' ') +} + +fn escape_text(output: &mut String, value: &str) { + for character in value.chars() { + match character { + '&' => output.push_str("&"), + '<' => output.push_str("<"), + _ => output.push(character), + } + } +} + +fn serialize_attribute_value( + output: &mut String, value: &str, remove_quotes: bool, + preserve_references: bool, +) { + if remove_quotes && !value.is_empty() && value.chars().all(is_unquoted) { + if preserve_references { + output.push_str(value); + } else { + escape_ampersands(output, value); + } + return; + } + + let single = value.matches('\'').count(); + let double = value.matches('"').count(); + let quote = if double > single { '\'' } else { '"' }; + output.push(quote); + for character in value.chars() { + match character { + '&' if !preserve_references => output.push_str("&"), + '\'' if quote == '\'' => output.push_str("'"), + '"' if quote == '"' => output.push_str("""), + _ => output.push(character), + } + } + output.push(quote); +} + +fn escape_ampersands(output: &mut String, value: &str) { + for character in value.chars() { + if character == '&' { + output.push_str("&"); + } else { + output.push(character); + } + } +} + +fn is_unquoted(character: char) -> bool { + !is_html_whitespace(character) + && !matches!(character, '"' | '\'' | '`' | '=' | '<' | '>') +} + +fn is_void(name: &str) -> bool { + matches!( + name, + "area" + | "base" + | "br" + | "col" + | "command" + | "embed" + | "hr" + | "img" + | "input" + | "keygen" + | "link" + | "meta" + | "param" + | "source" + | "track" + | "wbr" + ) +} + +fn is_boolean_attribute(tag: &str, name: &str) -> bool { + if name == "hidden" { + return true; + } + match tag { + "audio" | "video" => { + matches!(name, "autoplay" | "controls" | "loop" | "muted") + } + "button" => matches!(name, "autofocus" | "disabled" | "formnovalidate"), + "dialog" => name == "open", + "fieldset" | "optgroup" => name == "disabled", + "form" => name == "novalidate", + "iframe" => name == "seamless", + "img" => name == "ismap", + "input" => matches!( + name, + "autofocus" + | "checked" + | "disabled" + | "formnovalidate" + | "multiple" + | "readonly" + | "required" + ), + "keygen" => matches!(name, "autofocus" | "disabled"), + "object" => name == "typemustmatch", + "ol" => name == "reversed", + "option" => matches!(name, "disabled" | "selected"), + "script" => matches!(name, "async" | "defer"), + "select" => { + matches!(name, "autofocus" | "disabled" | "multiple" | "required") + } + "style" => name == "scoped", + "textarea" => { + matches!(name, "autofocus" | "disabled" | "readonly" | "required") + } + "track" => name == "default", + _ => false, + } +} + +fn closes_on_start(current: &str, next: &str) -> bool { + match current { + "li" => next == "li", + "dd" | "dt" => matches!(next, "dd" | "dt"), + "rp" | "rt" => matches!(next, "rp" | "rt"), + "p" => matches!( + next, + "address" + | "article" + | "aside" + | "blockquote" + | "dir" + | "div" + | "dl" + | "fieldset" + | "footer" + | "form" + | "h1" + | "h2" + | "h3" + | "h4" + | "h5" + | "h6" + | "header" + | "hgroup" + | "hr" + | "menu" + | "nav" + | "ol" + | "p" + | "pre" + | "section" + | "table" + | "ul" + ), + "option" => matches!(next, "option" | "optgroup"), + "optgroup" => next == "optgroup", + "colgroup" => true, + "thead" | "tbody" => matches!(next, "tbody" | "tfoot"), + "tfoot" => next == "tbody", + "tr" => next == "tr", + "td" | "th" => matches!(next, "td" | "th"), + _ => false, + } +} + +fn is_javascript(tag: &StartTag) -> bool { + let kind = attribute(tag, "type") + .map(str::trim) + .map(str::to_ascii_lowercase); + let language = attribute(tag, "language") + .map(str::trim) + .map(str::to_ascii_lowercase); + match kind.as_deref() { + None | Some("" | "module") => {} + Some(kind) + if matches!( + kind.split(';').next().map(str::trim), + Some( + "text/javascript" + | "application/javascript" + | "text/ecmascript" + | "application/ecmascript" + ) + ) => {} + _ => return false, + } + language.as_deref().is_none_or(|language| { + matches!(language, "javascript" | "ecmascript" | "jscript") + }) +} + +fn is_css(tag: &StartTag) -> bool { + attribute(tag, "type").is_none_or(|kind| { + let kind = kind.trim().to_ascii_lowercase(); + kind.is_empty() + || kind + .split(';') + .next() + .is_some_and(|kind| kind.trim() == "text/css") + }) +} + +fn is_module(tag: &StartTag) -> bool { + attribute(tag, "type") + .is_some_and(|kind| kind.trim().eq_ignore_ascii_case("module")) +} + +fn attribute<'a>(tag: &'a StartTag, name: &str) -> Option<&'a str> { + tag.attributes + .iter() + .find(|attribute| attribute.name == name) + .and_then(|attribute| attribute.value.as_ref()) + .map(|value| value.decoded.as_str()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn options() -> HtmlMinOptions { + HtmlMinOptions { + remove_comments: false, + remove_empty_space: false, + remove_all_empty_space: false, + reduce_empty_attributes: true, + reduce_boolean_attributes: false, + remove_optional_attribute_quotes: true, + convert_charrefs: true, + keep_pre: false, + pre_tags: vec!["pre".into(), "textarea".into()], + pre_attr: "pre".into(), + } + } + + #[test] + fn matches_default_htmlmin_behavior() { + let input = r#"<!doctype html> + <html><head><!-- keep --><title> A title + +

A B

+
  exact
+ value 
exact
"#; + let output = minify(input, &options(), false, false); + assert_eq!( + output, + "A title \ +

A B

\ +
  exact\n value 
exact
" + ); + } + + #[test] + fn supports_comment_and_whitespace_modes() { + let mut options = options(); + options.remove_comments = true; + options.remove_empty_space = true; + let output = minify( + "\n
A
\t
B
", + &options, + false, + false, + ); + assert_eq!(output, "
A
B
"); + + options.remove_all_empty_space = true; + let output = + minify(" A B ", &options, false, false); + assert_eq!(output, "AB"); + } + + #[test] + fn supports_attribute_options() { + let mut options = options(); + options.reduce_boolean_attributes = true; + options.remove_optional_attribute_quotes = false; + options.convert_charrefs = false; + let output = minify( + r#""#, + &options, + false, + false, + ); + assert_eq!(output, r#""#); + } + + #[test] + fn supports_custom_preservation_markers() { + let mut options = options(); + options.pre_attr = "custom".into(); + options.pre_tags.push("code".into()); + let output = minify( + "
exact
code ", + &options, + false, + false, + ); + assert_eq!( + output, + "
exact
code " + ); + } + + #[test] + fn supports_preservation_configuration() { + let mut options = options(); + options.keep_pre = true; + assert_eq!( + minify( + r#" exact "#, + &options, + false, + false, + ), + " exact " + ); + + options.pre_tags.clear(); + assert_eq!( + minify("
  compact  
", &options, false, false), + "
 compact 
" + ); + } + + #[test] + fn controls_character_reference_conversion() { + let input = + r#""#; + let converted = minify(input, &options(), false, false); + assert!(converted.contains(".π>")); + assert!(!converted.contains(".")); + + let mut options = options(); + options.convert_charrefs = false; + assert_eq!(minify(input, &options, false, false), input); + } + + #[test] + fn preserves_scripts_styles_and_conditional_comments_by_default() { + let mut options = options(); + options.remove_comments = true; + let input = ""; + assert_eq!(minify(input, &options, false, false), input); + } + + #[test] + fn preserves_foreign_element_and_attribute_case() { + let input = r#""#; + assert_eq!( + minify(input, &options(), false, false), + r#""# + ); + } + + #[test] + fn minifies_supported_inline_languages() { + let input = r#" + +"#; + let output = minify(input, &options(), true, true); + assert!(output.contains("")); + assert!(output.contains( + "" + )); + assert!(output.contains("screen and (min-width:45em)")); + } + + #[test] + fn minifies_module_scripts_as_modules() { + let input = ""; + assert_eq!( + minify(input, &options(), true, false), + "" + ); + } + + #[test] + fn inline_only_retains_surrounding_html() { + let input = + "
A
"; + let output = minify_inline(input.into(), true, false); + assert!(output.starts_with("
A
")); + assert!(output.ends_with("")); + } + + #[test] + fn inline_minification_never_expands_a_body() { + let input = ""; + let owned = input.to_string(); + let pointer = owned.as_ptr(); + let output = minify_inline(owned, true, true); + assert_eq!(output, input); + assert_eq!(output.as_ptr(), pointer); + assert_eq!(minify(input, &options(), true, true), input); + } + + #[test] + fn invalid_inline_languages_are_preserved() { + let input = ""; + assert_eq!(minify_inline(input.into(), true, true), input); + } +} diff --git a/crates/zensical/src/compat/mkdocs/plugin/minify/script.rs b/crates/zensical/src/compat/mkdocs/plugin/minify/script.rs new file mode 100644 index 0000000..a3416d9 --- /dev/null +++ b/crates/zensical/src/compat/mkdocs/plugin/minify/script.rs @@ -0,0 +1,70 @@ +// Copyright (c) 2025-2026 Zensical and contributors + +// SPDX-License-Identifier: MIT +// All contributions are certified under the DCO + +//! JavaScript minification. + +use oxc_allocator::Allocator; +use oxc_codegen::{Codegen, CodegenOptions, CommentOptions}; +use oxc_parser::Parser; +use oxc_span::SourceType; + +/// Minifies JavaScript while retaining the original source on parse errors. +pub(super) fn minify(source: &str, module: bool) -> Option { + let allocator = Allocator::default(); + let source_type = if module { + SourceType::mjs() + } else { + SourceType::unambiguous() + }; + let parsed = Parser::new(&allocator, source, source_type).parse(); + if parsed.panicked || !parsed.errors.is_empty() { + return None; + } + + let output = Codegen::new() + .with_options(CodegenOptions { + minify: true, + comments: CommentOptions { + normal: false, + jsdoc: false, + annotation: true, + ..CommentOptions::default() + }, + ..CodegenOptions::default() + }) + .build(&parsed.program); + Some(output.code) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn minifies_modern_javascript() { + let source = r" + /*! license */ + const value = { nested: { answer: 42 } }; + console.log(value?.nested?.answer ?? `missing ${value}`); + "; + let output = minify(source, false).expect("valid JavaScript"); + assert!(output.contains("/*! license */")); + assert!(output.contains("value?.nested?.answer??")); + assert!(!output.contains(" ")); + } + + #[test] + fn rejects_invalid_javascript_without_rewriting_it() { + assert!(minify("const = ;", false).is_none()); + } + + #[test] + fn supports_module_only_syntax() { + assert_eq!( + minify("await Promise.resolve(1);", true).as_deref(), + Some("await Promise.resolve(1);") + ); + } +} diff --git a/crates/zensical/src/compat/mkdocs/plugin/minify/style.rs b/crates/zensical/src/compat/mkdocs/plugin/minify/style.rs new file mode 100644 index 0000000..c55894e --- /dev/null +++ b/crates/zensical/src/compat/mkdocs/plugin/minify/style.rs @@ -0,0 +1,126 @@ +// Copyright (c) 2025-2026 Zensical and contributors + +// SPDX-License-Identifier: MIT +// All contributions are certified under the DCO + +//! CSS minification. + +use swc_common::input::StringInput; +use swc_common::{BytePos, Globals, GLOBALS}; +use swc_css::ast::Stylesheet; +use swc_css::codegen::writer::basic::{BasicCssWriter, BasicCssWriterConfig}; +use swc_css::codegen::{CodeGenerator, CodegenConfig, Emit}; +use swc_css::minifier::options::MinifyOptions; +use swc_css::parser::parser::ParserConfig; + +/// Minifies CSS while retaining the original source on parse errors. +pub(super) fn minify(source: &str) -> Option { + GLOBALS.set(&Globals::default(), || { + let legal = legal_comments(source); + let end = u32::try_from(source.len()).ok()?; + let input = StringInput::new(source, BytePos(0), BytePos(end)); + let mut errors = Vec::new(); + let mut stylesheet: Stylesheet = swc_css::parser::parse_string_input( + input, + None, + ParserConfig::default(), + &mut errors, + ) + .ok()?; + if !errors.is_empty() { + return None; + } + + swc_css::minifier::minify(&mut stylesheet, MinifyOptions::default()); + + let mut output = String::new(); + let writer = BasicCssWriter::new( + &mut output, + None, + BasicCssWriterConfig::default(), + ); + CodeGenerator::new(writer, CodegenConfig { minify: true }) + .emit(&stylesheet) + .ok()?; + if !legal.is_empty() { + let mut preserved = String::with_capacity( + legal.iter().map(|comment| comment.len()).sum::() + + output.len(), + ); + for comment in legal { + if !output.contains(comment) { + preserved.push_str(comment); + } + } + preserved.push_str(&output); + output = preserved; + } + Some(output) + }) +} + +/// Returns legal comments that minifiers conventionally preserve. +fn legal_comments(source: &str) -> Vec<&str> { + let bytes = source.as_bytes(); + let mut comments = Vec::new(); + let mut offset = 0; + let mut quote = None; + while offset < bytes.len() { + match (quote, bytes[offset]) { + (Some(_), b'\\') => offset += 2, + (Some(current), byte) if byte == current => { + quote = None; + offset += 1; + } + (None, byte @ (b'\'' | b'"')) => { + quote = Some(byte); + offset += 1; + } + (None, b'/') if bytes.get(offset + 1) == Some(&b'*') => { + let start = offset; + offset += 2; + while offset + 1 < bytes.len() + && bytes[offset..offset + 2] != *b"*/" + { + offset += 1; + } + offset = (offset + 2).min(bytes.len()); + if bytes.get(start + 2) == Some(&b'!') { + comments.push(&source[start..offset]); + } + } + (Some(_) | None, _) => offset += 1, + } + } + comments +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn minifies_modern_css() { + let source = r" + /*! license */ + @media screen and (min-width: 45em) { + .card:has(> img) { color: rgb(255, 0, 0); } + } + "; + let output = minify(source).expect("valid CSS"); + assert!(output.contains("/*! license */")); + assert!(output.contains("screen and (min-width:45em)")); + assert!(!output.contains("screen and(")); + } + + #[test] + fn rejects_invalid_css_without_rewriting_it() { + assert!(minify(".a { color: ;").is_none()); + } + + #[test] + fn ignores_legal_comment_markers_inside_strings() { + assert!(legal_comments(r#".a { content: \"/*! not legal */\" }"#) + .is_empty()); + } +} diff --git a/crates/zensical/src/config/plugins.rs b/crates/zensical/src/config/plugins.rs index 9d5e92b..255a3c2 100644 --- a/crates/zensical/src/config/plugins.rs +++ b/crates/zensical/src/config/plugins.rs @@ -51,6 +51,8 @@ pub struct Plugins { pub meta: MetaPlugin, /// Redirects plugin. pub redirects: RedirectsPlugin, + /// Minify plugin. + pub minify: MinifyPlugin, /// Offline plugin. pub offline: OfflinePlugin, } @@ -97,6 +99,70 @@ pub struct RedirectsPluginConfig { // ---------------------------------------------------------------------------- +/// Minify plugin. +#[derive(Clone, Debug, Hash, FromPyObject, Serialize)] +#[pyo3(from_item_all)] +pub struct MinifyPlugin { + /// Plugin configuration. + pub config: MinifyPluginConfig, +} + +/// Minify plugin configuration. +#[allow(clippy::struct_excessive_bools)] +#[derive(Clone, Debug, Hash, FromPyObject, Serialize)] +#[pyo3(from_item_all)] +pub struct MinifyPluginConfig { + /// Whether the plugin is enabled. + pub enabled: bool, + /// Whether rendered HTML is minified. + pub minify_html: bool, + /// Whether selected JavaScript assets are minified. + pub minify_js: bool, + /// Whether selected CSS assets are minified. + pub minify_css: bool, + /// Whether inline JavaScript is minified. + pub minify_inline_js: bool, + /// Whether inline CSS is minified. + pub minify_inline_css: bool, + /// JavaScript asset paths or patterns. + pub js_files: Vec, + /// CSS asset paths or patterns. + pub css_files: Vec, + /// HTML minification options. + pub htmlmin_opts: HtmlMinOptions, + /// Whether asset names include a hash of their emitted contents. + pub cache_safe: bool, +} + +/// HTML options accepted by mkdocs-minify-plugin. +#[allow(clippy::struct_excessive_bools)] +#[derive(Clone, Debug, Hash, FromPyObject, Serialize)] +#[pyo3(from_item_all)] +pub struct HtmlMinOptions { + /// Whether ordinary HTML comments are removed. + pub remove_comments: bool, + /// Whether newline-containing whitespace-only text is removed. + pub remove_empty_space: bool, + /// Whether all whitespace-only text is removed. + pub remove_all_empty_space: bool, + /// Whether empty attribute values are collapsed. + pub reduce_empty_attributes: bool, + /// Whether HTML boolean attribute values are collapsed. + pub reduce_boolean_attributes: bool, + /// Whether optional attribute quotes are removed. + pub remove_optional_attribute_quotes: bool, + /// Whether character references in attributes are decoded when safe. + pub convert_charrefs: bool, + /// Whether the preservation marker attribute remains in output. + pub keep_pre: bool, + /// Elements whose contents are preserved verbatim. + pub pre_tags: Vec, + /// Attribute marking an element or attribute value for preservation. + pub pre_attr: String, +} + +// ---------------------------------------------------------------------------- + /// Search plugin. #[derive(Clone, Debug, Hash, FromPyObject, Serialize)] #[pyo3(from_item_all)] diff --git a/crates/zensical/src/config/project.rs b/crates/zensical/src/config/project.rs index 635623d..b021a58 100644 --- a/crates/zensical/src/config/project.rs +++ b/crates/zensical/src/config/project.rs @@ -43,7 +43,7 @@ use super::validation::Validation; // ---------------------------------------------------------------------------- /// Project settings. -#[derive(Debug, Hash, FromPyObject, Serialize)] +#[derive(Clone, Debug, Hash, FromPyObject, Serialize)] #[pyo3(from_item_all)] pub struct Project { /// Project root directory. diff --git a/crates/zensical/src/config/theme.rs b/crates/zensical/src/config/theme.rs index 82d2702..93eb14a 100644 --- a/crates/zensical/src/config/theme.rs +++ b/crates/zensical/src/config/theme.rs @@ -35,7 +35,7 @@ use std::path::PathBuf; // ---------------------------------------------------------------------------- /// Theme settings. -#[derive(Debug, Hash, FromPyObject, Serialize)] +#[derive(Clone, Debug, Hash, FromPyObject, Serialize)] #[pyo3(from_item_all)] pub struct Theme { /// Theme name. @@ -67,7 +67,7 @@ pub struct Theme { // ---------------------------------------------------------------------------- /// Font settings. -#[derive(Debug, Hash, FromPyObject, Serialize)] +#[derive(Clone, Debug, Hash, FromPyObject, Serialize)] #[serde(untagged)] #[pyo3(from_item_all)] pub enum Font { @@ -78,7 +78,7 @@ pub enum Font { } /// Custom fonts. -#[derive(Debug, Hash, FromPyObject, Serialize)] +#[derive(Clone, Debug, Hash, FromPyObject, Serialize)] #[pyo3(from_item_all)] pub struct CustomFont { /// Text font. @@ -90,7 +90,7 @@ pub struct CustomFont { // ---------------------------------------------------------------------------- /// Icon settings. -#[derive(Debug, Hash, FromPyObject, Serialize)] +#[derive(Clone, Debug, Hash, FromPyObject, Serialize)] #[pyo3(from_item_all)] pub struct Icon { /// Edit button icon. @@ -128,7 +128,7 @@ pub struct Icon { // ---------------------------------------------------------------------------- /// Color palette settings. -#[derive(Debug, Hash, FromPyObject, Serialize)] +#[derive(Clone, Debug, Hash, FromPyObject, Serialize)] #[pyo3(from_item_all)] pub struct Palette { /// Palette media query. @@ -144,7 +144,7 @@ pub struct Palette { } /// Color palette toggle. -#[derive(Debug, Hash, FromPyObject, Serialize)] +#[derive(Clone, Debug, Hash, FromPyObject, Serialize)] #[pyo3(from_item_all)] pub struct PaletteToggle { /// Palette toggle icon. diff --git a/crates/zensical/src/structure/page.rs b/crates/zensical/src/structure/page.rs index f02e46d..3159d6e 100644 --- a/crates/zensical/src/structure/page.rs +++ b/crates/zensical/src/structure/page.rs @@ -35,7 +35,7 @@ use zensical_serve::http::Uri; use zrx::id::Id; use zrx::scheduler::Value; -use crate::config::Config; +use crate::config::{Config, Project}; use crate::template::{Output, Template, GENERATOR}; use super::dynamic::Dynamic; @@ -195,6 +195,7 @@ impl Page { )] pub fn render_template( &mut self, template: &Template, config: &Config, nav: Navigation, + project: &Arc, ) -> Result { let name = match self.meta.get("template") { Some(Dynamic::String(value)) => value.clone(), @@ -214,9 +215,9 @@ impl Page { generator => GENERATOR, nav => TemplateValue::from_object(nav), base_url => config.get_base_url(&self.url), - extra_css => config.project.extra_css.clone(), - extra_javascript => config.project.extra_javascript.clone(), - config => config.project.clone(), + extra_css => project.extra_css.clone(), + extra_javascript => project.extra_javascript.clone(), + config => project.clone(), tags => self.tags(), page => self, }, diff --git a/crates/zensical/src/template.rs b/crates/zensical/src/template.rs index c2b5077..5bc988d 100644 --- a/crates/zensical/src/template.rs +++ b/crates/zensical/src/template.rs @@ -29,8 +29,9 @@ use minijinja::{context, AutoEscape, Environment, Error, Value}; use minijinja_contrib::filters::striptags; use serde::Serialize; use std::path::PathBuf; +use std::sync::Arc; -use super::config::Config; +use super::config::{Config, Project}; use super::structure::nav::{Navigation, NavigationView}; mod filter; @@ -93,6 +94,7 @@ impl Template<'_> { /// Renders the template. pub fn render( &self, name: &str, config: &Config, nav: &Navigation, + project: &Arc, ) -> Result { let template = self.env.get_template(name)?; let nav = NavigationView::new(nav.clone(), None); @@ -104,9 +106,9 @@ impl Template<'_> { nav => Value::from_object(nav), pages => pages, base_url => config.get_base_path(), - extra_css => config.project.extra_css.clone(), - extra_javascript => config.project.extra_javascript.clone(), - config => config.project.clone(), + extra_css => project.extra_css.clone(), + extra_javascript => project.extra_javascript.clone(), + config => project.clone(), // MiniJinja does not allow to pass empty objects, so we create a // dummy page here - these won't be used in static templates page => context! { diff --git a/crates/zensical/src/workflow.rs b/crates/zensical/src/workflow.rs index 272048e..f287170 100644 --- a/crates/zensical/src/workflow.rs +++ b/crates/zensical/src/workflow.rs @@ -27,12 +27,12 @@ use regex::Regex; use serde::{Deserialize, Serialize}; +use std::fs; use std::hash::{DefaultHasher, Hash, Hasher}; use std::ops::Deref; use std::path::Path; use std::str::FromStr; use std::sync::{Arc, LazyLock, OnceLock}; -use std::{fs, io}; use zrx::id::matcher::Matcher; use zrx::id::Id; use zrx::stream::function::Collection; @@ -42,7 +42,7 @@ use zrx::stream::{ }; use super::compat::mkdocs::plugin::{ - self, autorefs, meta, mkdocstrings, redirects, search, + self, autorefs, meta, minify, mkdocstrings, redirects, search, }; use super::config::Config; use super::structure::markdown::Markdown; @@ -145,6 +145,25 @@ impl Value for SitePage {} // ---------------------------------------------------------------------------- +/// Page-local work paired with revision-settled shared rendering facts. +#[derive(Clone, Debug)] +struct PageRender { + /// Page and its unresolved autoref slots. + input: SitePage, + /// Navigation for the current page relation. + nav: Navigation, + /// Autoref registry for the current page relation. + autorefs: autorefs::Registry, + /// Asset-projected template configuration. + project: Arc, + /// Stable asset mapping hash for the template cache key. + asset_hash: u64, +} + +impl Value for PageRender {} + +// ---------------------------------------------------------------------------- + /// Markdown source paired with route facts available before rendering. #[derive(Clone, Debug, PartialEq, Eq)] struct RoutedMarkdown { @@ -204,8 +223,7 @@ impl Main { let meta = meta::Settings::new(&self.config); // Set up workflow to process static assets and Markdown files. - process_theme_assets(&self.config, &files); - process_assets(&self.config, &files, &meta); + let assets = process_assets(&self.config, &files, &meta); let markdown = route_markdown(&self.config, &files); // Redirects depend on routes, not rendered Markdown. Settle their @@ -227,8 +245,8 @@ impl Main { let search = site.map(|site: &Site| site.search.clone()); search::attach(&self.config, &search); mkdocstrings::attach(&self.config, &nav); - let _ = render_templates(&self.config, &files, &nav); - let unresolved = render_pages(&self.config, &site); + let _ = render_templates(&self.config, &files, &nav, &assets); + let unresolved = render_pages(&self.config, &site, &assets); validate(&self.config, self.strict, &files, &page, &unresolved); } } @@ -328,88 +346,152 @@ fn page_hash(page: &Page, autorefs: &autorefs::References) -> u64 { /// Create a stream to process static assets. pub fn process_assets( config: &Config, files: &Stream, meta: &meta::Settings, -) { +) -> Signal { + if !minify::asset::is_enabled(config) { + copy_assets(config, files, meta); + let project = config.project.clone(); + return files.reduce(move |_: &dyn Collection, Input>| { + Ok::<_, anyhow::Error>(Some(minify::asset::Manifest::base( + project.clone(), + ))) + }); + } + let extra_templates = config.project.extra_templates.clone(); + let static_templates = config.project.theme.static_templates.clone(); let docs_dir = config.project.docs_dir.clone(); - let matcher = Arc::new( + let docs = Arc::new( Matcher::from_str(&format!("zrs::::{docs_dir}::")).expect("invariant"), ); + let themes = + Arc::new(Matcher::from_str("zrs::::templates/*::").expect("invariant")); + let meta = meta.clone(); + let resources = files.filter_map(move |id: &Id, input: &Input| { + let location = id.location().into_owned(); + let priority = + if docs.is_match(id).expect("invariant") { + if Path::new(&location).extension().is_some_and(|extension| { + extension.eq_ignore_ascii_case("md") + }) || meta::claims(&location, &meta) + || extra_templates.contains(&location) + { + return None; + } + 0 + } else if themes.is_match(id).expect("invariant") { + if Path::new(&location).extension().is_some_and(|extension| { + extension.eq_ignore_ascii_case("html") + }) || static_templates.contains(&location) + { + return None; + } + id.context() + .strip_prefix("templates/") + .and_then(|index| index.parse::().ok()) + .map_or(usize::MAX, |index| index + 1) + } else { + return None; + }; + Some(minify::asset::Resource { + path: location, + source: input.path.clone().into(), + priority, + }) + }); - // Create pipeline to copy static assets + // Resolve project/theme precedence before transformation. A removed + // project override therefore reveals the effective theme resource in the + // same revision instead of briefly deleting the logical output. + let effective = resources.reduce_by_key( + |resource: &minify::asset::Resource| { + minify::asset::resource_key(&resource.path) + }, + |resources: &dyn Collection, minify::asset::Resource>| { + Ok::<_, anyhow::Error>( + resources + .iter() + .map(|(_, resource)| resource) + .min_by(|left, right| { + left.priority + .cmp(&right.priority) + .then_with(|| left.source.cmp(&right.source)) + }) + .cloned(), + ) + }, + ); + minify::asset::attach(config, &effective) +} + +/// Copies assets directly when no compatibility module claims them. +fn copy_assets( + config: &Config, files: &Stream, meta: &meta::Settings, +) { + let docs_dir = config.project.docs_dir.clone(); + let docs = Arc::new( + Matcher::from_str(&format!("zrs::::{docs_dir}::")).expect("invariant"), + ); + let extra_templates = config.project.extra_templates.clone(); let site_dir = config.project.site_dir.clone(); let root_dir = config.get_root_dir(); let meta = meta.clone(); - let _ = files.map(move |id: &Id, from: &Input| { - if !matcher.is_match(id).expect("invariant") { + let _ = files.map(move |id: &Id, input: &Input| { + let location = id.location(); + if !docs.is_match(id).expect("invariant") + || Path::new(location.as_ref()) + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("md")) + || meta::claims(&location, &meta) + || extra_templates + .iter() + .any(|template| template == location.as_ref()) + { return Ok(()); } - - // Don't copy Markdown files - if id.location().ends_with(".md") { - return Ok(()); - } - - // Metadata files are inputs, not site assets. - if meta::claims(&id.location(), &meta) { - return Ok(()); - } - - // Don't copy template files that we render later - if extra_templates.contains(&id.location().into_owned()) { - return Ok(()); - } - - // Create identifier builder, as we need to change the context in order - // to copy the file over to the site directory - let builder = id.to_builder().context(&site_dir); - let id = builder.build().expect("invariant"); - - // Compute parent path, create intermediate directories and copy files - let to = root_dir.join(id.to_path()); - fs::create_dir_all(to.parent().expect("invariant"))?; - copy_file(&from.path, to)?; - Ok::<(), anyhow::Error>(()) + let output = id + .to_builder() + .context(&site_dir) + .build() + .expect("invariant"); + copy_asset(&input.path, root_dir.join(output.to_path())) }); -} -/// Create a stream to process static assets in theme. -pub fn process_theme_assets(config: &Config, files: &Stream) { - let matcher = + let themes = Arc::new(Matcher::from_str("zrs::::templates/*::").expect("invariant")); - - // Create pipeline to copy static assets + let static_templates = config.project.theme.static_templates.clone(); let site_dir = config.project.site_dir.clone(); let root_dir = config.get_root_dir(); - let _ = files.map(move |id: &Id, from: &Input| { - if !matcher.is_match(id).expect("invariant") { + let _ = files.map(move |id: &Id, input: &Input| { + let location = id.location(); + if !themes.is_match(id).expect("invariant") + || Path::new(location.as_ref()) + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("html")) + || static_templates + .iter() + .any(|template| template == location.as_ref()) + { return Ok(()); } - - // Don't copy templates - they will be rendered later - if id.location().ends_with(".html") { - return Ok(()); - } - - // Create identifier builder, as we need to change the context in order - // to copy the file over to the site directory - let builder = id.to_builder().context(&site_dir); - let id = builder.build().expect("invariant"); - - // Compute parent path, create intermediate directories and copy files - let to = root_dir.join(id.to_path()); - fs::create_dir_all(to.parent().expect("invariant"))?; - copy_file(&from.path, to)?; - Ok::<_, anyhow::Error>(()) + let output = id + .to_builder() + .context(&site_dir) + .build() + .expect("invariant"); + copy_asset(&input.path, root_dir.join(output.to_path())) }); } -/// Copy a file to a new location, without copying its permissions. -fn copy_file( +/// Copies one asset without preserving source permissions. +fn copy_asset( from: impl AsRef, to: impl AsRef, -) -> Result<(), io::Error> { +) -> anyhow::Result<()> { + let to = to.as_ref(); + fs::create_dir_all(to.parent().expect("site asset has parent"))?; let mut from = fs::File::open(from)?; let mut to = fs::File::create(to)?; - io::copy(&mut from, &mut to).map(|_| ()) + std::io::copy(&mut from, &mut to)?; + Ok(()) } /// Select Markdown sources and derive their routes before rendering. @@ -577,6 +659,7 @@ fn generate_nav(site: &Signal) -> Signal { /// Render static and extra templates. pub fn render_templates( config: &Config, files: &Stream, nav: &Signal, + assets: &Signal, ) -> Stream { let docs_dir = config.project.docs_dir.clone(); @@ -607,73 +690,93 @@ pub fn render_templates( // Create pipeline to render templates let renderer = Template::new(theme_dirs); + let minify = minify::Settings::new(config); let config = config.clone(); - templates - .product(nav) - .map(move |template: &Input, nav: &Navigation| { + templates.product(nav).product(assets).map( + move |input: &(Input, Navigation), assets: &minify::asset::Manifest| { + let (template, nav) = input; let name = Path::new(&template.path).file_name().expect("invariant"); let site_dir = config.get_site_dir(); // Render template and write to disk - let data = - renderer.render(&name.to_string_lossy(), &config, nav)?; - let path = site_dir.join(name); + let name = name.to_string_lossy(); + let data = renderer.render(&name, &config, nav, &assets.project)?; + let data = minify.template(&name, data); + let path = site_dir.join(name.as_ref()); fs::create_dir_all(path.parent().expect("invariant"))?; fs::write(path, &data)?; Ok::<_, anyhow::Error>(()) - }) + }, + ) } /// Render pages. fn render_pages( config: &Config, site: &Signal, + assets: &Signal, ) -> Stream { - let pages = site.flat_map(|site: &Site| { - site.pages - .iter() - .map(|(key, page)| { - ( - key.clone(), - (page.clone(), site.nav.clone(), site.autorefs.clone()), - ) - }) - .collect::>() - }); + let pages = site.product(assets).flat_map( + |site: &Site, assets: &minify::asset::Manifest| { + site.pages + .iter() + .map(|(key, page)| { + ( + key.clone(), + PageRender { + input: page.clone(), + nav: site.nav.clone(), + autorefs: site.autorefs.clone(), + project: assets.project.clone(), + asset_hash: assets.hash, + }, + ) + }) + .collect::>() + }, + ); let template = OnceLock::new(); let theme_dirs = config.theme_dirs.clone(); + let minify = minify::Settings::new(config); let config = config.clone(); - pages.map( - move |input: &SitePage, - nav: &Navigation, - autorefs: &autorefs::Registry| { - let mut page = input.page.clone(); - let references = &input.autorefs; - let id = page.url.clone(); + pages.map(move |input: &PageRender| { + let mut page = input.input.page.clone(); + let references = &input.input.autorefs; + let id = page.url.clone(); - // Cache template rendering independently of autorefs, which are - // substituted below on every pass. Deriving a cache key for the - // substitution would require the same resolution scan that the - // substitution itself performs, so caching it can't pay off. - let args = (config.hash, nav.hash, page_hash(&page, references)); - let rendered = - cached(&config, ("template", id), args, |(_, _, _)| { - let template = template - .get_or_init(|| Template::new(theme_dirs.clone())); - Ok(page.render_template(template, &config, nav.clone())?) - })?; + // Cache template rendering independently of autorefs, which are + // substituted below on every pass. Deriving a cache key for the + // substitution would require the same resolution scan that the + // substitution itself performs, so caching it can't pay off. + let args = ( + config.hash, + input.nav.hash, + input.asset_hash, + page_hash(&page, references), + ); + let rendered = + cached(&config, ("template", id), args, |(_, _, _, _)| { + let template = + template.get_or_init(|| Template::new(theme_dirs.clone())); + Ok(page.render_template( + template, + &config, + input.nav.clone(), + &input.project, + )?) + })?; - // Replace autorefs and retain unresolved identifiers - let (data, unresolved) = - autorefs.replace_in(rendered, references, &page.url); + // Replace autorefs and retain unresolved identifiers + let (data, unresolved) = + input.autorefs.replace_in(rendered, references, &page.url); + let data = minify.html(data); - let path = Path::new(&page.path); - fs::create_dir_all(path.parent().expect("invariant"))?; - fs::write(path, &data)?; - Ok::<_, anyhow::Error>(unresolved) - }, - ) + let path = Path::new(&page.path); + fs::create_dir_all(path.parent().expect("invariant"))?; + fs::write(path, &data)?; + Ok::<_, anyhow::Error>(unresolved) + }) } /// Creates a workflow for the given config. diff --git a/python/tests/integration/test_minify.py b/python/tests/integration/test_minify.py new file mode 100644 index 0000000..4d38e02 --- /dev/null +++ b/python/tests/integration/test_minify.py @@ -0,0 +1,319 @@ +# Copyright (c) 2025-2026 Zensical and contributors + +# SPDX-License-Identifier: MIT +# All contributions are certified under the DCO + +"""Integration tests for MkDocs-compatible HTML minification.""" + +from __future__ import annotations + +import hashlib +import re +import subprocess +import sys +import time +from typing import TYPE_CHECKING + +import pytest + +import zensical + +if TYPE_CHECKING: + from collections.abc import Callable + from pathlib import Path + + +def _project(root: Path, *, minify_html: bool = True) -> Path: + """Create a project exercising HTML and inline language minification.""" + docs = root / "docs" + docs.mkdir() + (docs / "index.md").write_text( + """\ +# Minify + + + +
preserved text
+ + + + + + +""", + encoding="utf-8", + ) + config = root / "mkdocs.yml" + config.write_text( + f"""\ +site_name: Minify +plugins: + - minify: + minify_html: {str(minify_html).lower()} + minify_inline_js: true + minify_inline_css: true + htmlmin_opts: + remove_comments: true +""", + encoding="utf-8", + ) + return config + + +def test_minifies_final_html_and_inline_languages(tmp_path: Path) -> None: + """HTML runs last while script/style use parser-backed minifiers.""" + zensical.build( + str(_project(tmp_path)), {"clean": False, "strict": False} + ) + output = (tmp_path / "site" / "index.html").read_text() + + assert "remove this comment" not in output + assert "
preserved text
" in output + assert "const value={nested:{answer:42}};" in output + assert "value?.nested?.answer??0" in output + assert "screen and (min-width:45em)" in output + assert "screen and(" not in output + assert '{ "preserved": true }' in output + + +def test_inline_minification_does_not_require_html_minification( + tmp_path: Path, +) -> None: + """Inline-only mode retains the surrounding rendered HTML byte shape.""" + zensical.build( + str(_project(tmp_path, minify_html=False)), + {"clean": False, "strict": False}, + ) + output = (tmp_path / "site" / "index.html").read_text() + + assert "" in output + assert '