From 11d7db3418bdb06932eebdfce2888c59663ff998 Mon Sep 17 00:00:00 2001 From: Manus AI Date: Thu, 27 Aug 2026 15:53:31 +0000 Subject: [PATCH 1/9] feat: Add Tantivy FON search backend --- Cargo.lock | 1765 ++++++++++++++++++++++++ apps/fetch/back-end/Cargo.toml | 15 + apps/fetch/back-end/src/fon.rs | 516 +++++++ apps/fetch/back-end/src/main.rs | 33 +- apps/fetch/back-end/src/model.rs | 51 + apps/fetch/back-end/src/search.rs | 273 ++++ apps/fetch/back-end/src/web.rs | 196 +++ well-known/content/fuyeor-comments.fon | 13 + well-known/content/fuyeor-profile.fon | 13 + well-known/content/fuyeor-thoughts.fon | 13 + well-known/content/thought-en-1002.fon | 13 + well-known/content/thought-zh-1001.fon | 13 + well-known/search-patterns.fon | 26 + well-known/sitemaps/thoughts.fon | 13 + well-known/sitemaps/user-tabs.fon | 13 + well-known/sitemaps/users.fon | 8 + 16 files changed, 2973 insertions(+), 1 deletion(-) create mode 100644 Cargo.lock create mode 100644 apps/fetch/back-end/src/fon.rs create mode 100644 apps/fetch/back-end/src/model.rs create mode 100644 apps/fetch/back-end/src/search.rs create mode 100644 apps/fetch/back-end/src/web.rs create mode 100644 well-known/content/fuyeor-comments.fon create mode 100644 well-known/content/fuyeor-profile.fon create mode 100644 well-known/content/fuyeor-thoughts.fon create mode 100644 well-known/content/thought-en-1002.fon create mode 100644 well-known/content/thought-zh-1001.fon create mode 100644 well-known/search-patterns.fon create mode 100644 well-known/sitemaps/thoughts.fon create mode 100644 well-known/sitemaps/user-tabs.fon create mode 100644 well-known/sitemaps/users.fon diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..9545e09 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1765 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler32" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bitpacking" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96a7139abd3d9cebf8cd6f920a389cf3dc9576172e32f4563f188cae3c3eb019" +dependencies = [ + "crunchy", +] + +[[package]] +name = "bon" +version = "3.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e3fac94a66da67200398458a25412bcc3f9b6443b5119a6cad9cf3ccfcd8cc6" +dependencies = [ + "bon-macros", +] + +[[package]] +name = "bon-macros" +version = "3.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4654961ad0494e4774c5c60b4cb4cd0ae9b9d92d039d901638b1dba97ebebf5" +dependencies = [ + "darling", + "ident_case", + "prettyplease", + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cedarwood" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0524a528a6a0288df1863c3c20fe92c301875b4941e7b6c4b394ab08c5a4c55" +dependencies = [ + "smallvec", +] + +[[package]] +name = "census" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f4c707c6a209cbe82d10abd08e1ea8995e9ea937d2550646e02798948992be0" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "crc32fast" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.4", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "dary_heap" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe" + +[[package]] +name = "datasketches" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c286de4e81ea2590afc24d754e0f83810c566f50a1388fa75ebd57928c0d9745" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "downcast-rs" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastdivide" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afc2bd4d5a73106dd53d10d73d3401c2f32730ba2c0b93ddb888a8983680471" + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fetch-backend" +version = "0.1.0" +dependencies = [ + "axum", + "fon-parser", + "jieba-rs", + "serde", + "serde_json", + "tantivy", + "tempfile", + "tokio", + "tower", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "fon-parser" +version = "0.0.0" +source = "git+https://github.com/Fuyeor/fon-parser.git?branch=main#98dc40e9c444e1d610052bf6d93af6ebcce2ca4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs4" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8640e34b88f7652208ce9e88b1a37a2ae95227d84abec377ccd3c5cfeb141ed4" +dependencies = [ + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "htmlescape" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9025058dae765dee5070ec375f591e2ba14638c63feff74f13805a72e523163" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "include-flate" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f173716febb1ad596c16ea5637b5f1790ea32de8e627493ff82bc73b0876ce" +dependencies = [ + "include-flate-codegen", + "include-flate-compress", +] + +[[package]] +name = "include-flate-codegen" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a7875b62a72ad3f3203cdd8950d4cf9947db036030b974b8b37ceae90c8d8c0" +dependencies = [ + "include-flate-compress", + "proc-macro-error3", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "include-flate-compress" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fbb9c5ccb9a5b67b4afa2974c27e5507ea1bf6d22828cef418e4dfaeca51dd" +dependencies = [ + "libflate", + "zstd", +] + +[[package]] +name = "inventory" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +dependencies = [ + "rustversion", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jieba-macros" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34904340bc65749a9e9a02fcc7f3368e675427c18447b9bbe02df52c15c9a36a" +dependencies = [ + "phf_codegen", +] + +[[package]] +name = "jieba-rs" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb5bdea4dc241d589e179f39d2a778f31490f3370aa2f626223dbd930ebc5c9d" +dependencies = [ + "bytecount", + "cedarwood", + "include-flate", + "jieba-macros", + "phf", + "regex", + "rustc-hash", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "levenshtein_automata" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c2cdeb66e45e9f36bfad5bbdb4d2384e70936afbee843c6f6543f0c551ebb25" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libflate" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4da9b700e758e57152a1fd1c52cbdc5727c1aa6d8743dc1acda917398f1d76c" +dependencies = [ + "adler32", + "crc32fast", + "dary_heap", + "libflate_lz77", + "no_std_io2", +] + +[[package]] +name = "libflate_lz77" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff7a10e427698aef6eef269482776debfef63384d30f13aad39a1a95e0e098fd" +dependencies = [ + "hashbrown", + "no_std_io2", + "rle-decode-fast", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "lz4_flex" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef0d4ed8669f8f8826eb00dc878084aa8f253506c4fd5e8f58f5bce72ddb97e" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "measure_time" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51c55d61e72fc3ab704396c5fa16f4c184db37978ae4e94ca8959693a235fc0e" +dependencies = [ + "log", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "murmurhash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2195bf6aa996a481483b29d62a7663eed3fe39600c460e323f8ff41e90bdd89b" + +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "oneshot" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "269bca4c2591a28585d6bf10d9ed0332b7d76900a1b02bec41bdc3a2cdcda107" + +[[package]] +name = "ordered-float" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ownedbytes" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fbd56f7631767e61784dc43f8580f403f4475bd4aaa4da003e6295e1bab4a7e" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "prettyplease" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" +dependencies = [ + "proc-macro2", + "syn 3.0.4", +] + +[[package]] +name = "proc-macro-error-attr3" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0084e6206a967a2dad822180626b2f6b07a3b379325e8f1ec0438e33a469ba7" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error3" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cf066225f2373bc711684792b69bdeac0356019b007e721090c24d92d5d5a50" +dependencies = [ + "proc-macro-error-attr3", + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rle-decode-fast" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3582f63211428f83597b51b2ddb88e2a91a9d52d12831f9d08f5e624e8977422" + +[[package]] +name = "rust-stemmers" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e46a2036019fdb888131db7a4c847a1063a7493f971ed94ea82c67eada63ca54" +dependencies = [ + "serde", + "serde_derive", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +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 = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "sketches-ddsketch" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513c3f5f732bfd6fbb187619c2dfe9d2f25f1a2976f01d575f0fd329d565df56" +dependencies = [ + "serde", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "tantivy" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edde6a10743fff00a4e1a8c9ef020bf5f3cbad301b7d2d39f2b07f123c4eac07" +dependencies = [ + "aho-corasick", + "arc-swap", + "base64", + "bitpacking", + "bon", + "byteorder", + "census", + "crc32fast", + "crossbeam-channel", + "datasketches", + "downcast-rs", + "fastdivide", + "fnv", + "fs4", + "htmlescape", + "itertools", + "levenshtein_automata", + "log", + "lru", + "lz4_flex", + "measure_time", + "memmap2", + "once_cell", + "oneshot", + "rayon", + "regex", + "rust-stemmers", + "rustc-hash", + "serde", + "serde_json", + "sketches-ddsketch", + "smallvec", + "tantivy-bitpacker", + "tantivy-columnar", + "tantivy-common", + "tantivy-fst", + "tantivy-query-grammar", + "tantivy-stacker", + "tantivy-tokenizer-api", + "tempfile", + "thiserror", + "time", + "typetag", + "uuid", + "winapi", +] + +[[package]] +name = "tantivy-bitpacker" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fed3d674429bcd2de5d0a6d1aa5495fed8afd9c5ecce993019caf7615f53fa4" +dependencies = [ + "bitpacking", +] + +[[package]] +name = "tantivy-columnar" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c57166f5bcfd478f370ab8445afb4678dce44801fa5ce5c451aaf8595583c5dc" +dependencies = [ + "downcast-rs", + "fastdivide", + "itertools", + "serde", + "tantivy-bitpacker", + "tantivy-common", + "tantivy-sstable", + "tantivy-stacker", +] + +[[package]] +name = "tantivy-common" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbf10915aa75da3c3b0d58b58853d2e889efbaf32d4982a4c3715dde6bba23e5" +dependencies = [ + "async-trait", + "byteorder", + "ownedbytes", + "serde", + "time", +] + +[[package]] +name = "tantivy-fst" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d60769b80ad7953d8a7b2c70cdfe722bbcdcac6bccc8ac934c40c034d866fc18" +dependencies = [ + "byteorder", + "regex-syntax", + "utf8-ranges", +] + +[[package]] +name = "tantivy-query-grammar" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfadb8526b6da90704feb293b0701a6aae62ea14983143344be2dc5ce30f1d82" +dependencies = [ + "fnv", + "nom", + "ordered-float", + "serde", + "serde_json", +] + +[[package]] +name = "tantivy-sstable" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a2cfc3ac5164cbadc28965ffb145a8f47582a60ae5897859ad8d4316596c606" +dependencies = [ + "futures-util", + "itertools", + "tantivy-bitpacker", + "tantivy-common", + "tantivy-fst", + "zstd", +] + +[[package]] +name = "tantivy-stacker" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cbb051742da9d53ca9e8fff43a9b10e319338b24e2c0e15d0372df19ffeb951" +dependencies = [ + "murmurhash32", + "tantivy-common", +] + +[[package]] +name = "tantivy-tokenizer-api" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac258c2c6390673f2685813afeeafcb8c4e0ee7de8dd3fc46838dcc37263f98" +dependencies = [ + "serde", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typetag" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c90e86058a30d42a1a928dfb4b49bb33c98c3a2b4909492e6b0881cd94798ec2" +dependencies = [ + "erased-serde", + "inventory", + "once_cell", + "serde", + "typetag-impl", +] + +[[package]] +name = "typetag-impl" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f153acc4e99a5f2a5aefa09fb078be54e26271b2813f6041200b224c098d8328" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8-ranges" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fcfc827f90e53a02eaef5e535ee14266c1d569214c6aa70133a624d8a3164ba" + +[[package]] +name = "uuid" +version = "1.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +dependencies = [ + "getrandom", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/apps/fetch/back-end/Cargo.toml b/apps/fetch/back-end/Cargo.toml index eb96506..fa188fd 100644 --- a/apps/fetch/back-end/Cargo.toml +++ b/apps/fetch/back-end/Cargo.toml @@ -1,5 +1,20 @@ +# apps/fetch/back-end/Cargo.toml [package] name = "fetch-backend" +version = "0.1.0" edition = "2024" +rust-version = "1.86" +license = "MIT" [dependencies] +axum = "0.8.9" +fon-parser = { git = "https://github.com/Fuyeor/fon-parser.git", branch = "main", package = "fon-parser" } +jieba-rs = "0.10.3" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +tantivy = "0.26.1" +tokio = { version = "1.48", features = ["macros", "net", "rt-multi-thread"] } + +[dev-dependencies] +tower = { version = "0.5", features = ["util"] } +tempfile = "3.27.0" diff --git a/apps/fetch/back-end/src/fon.rs b/apps/fetch/back-end/src/fon.rs new file mode 100644 index 0000000..9f289ee --- /dev/null +++ b/apps/fetch/back-end/src/fon.rs @@ -0,0 +1,516 @@ +// apps/fetch/back-end/src/fon.rs + +use std::collections::BTreeMap; +use std::path::{Component, Path, PathBuf}; + +use fon_parser::{Ast, Member, StringPartKind, Value, parse}; + +use crate::model::IndexPage; + +type AppResult = Result>; + +/// Describes one pattern-to-sitemap mapping declared by the site. +#[derive(Debug, Clone)] +pub struct MappingSpec { + pub pattern: String, + pub datas: PathBuf, +} + +/// Contains the finite pages materialized from all submitted mappings. +#[derive(Debug, Clone)] +pub struct LoadedCatalog { + pub mappings: Vec, + pub pages: Vec, +} + +/// Load the three-file mock protocol from a local site root. +pub fn load_catalog(root: &Path) -> AppResult { + let mapping_path = resolve_site_path(root, Path::new("./well-known/search-patterns.fon"))?; + let mapping_source = std::fs::read_to_string(&mapping_path)?; + let mappings = parse_mappings(&mapping_source, &mapping_path)?; + let mut pages = Vec::new(); + + for mapping in &mappings { + let data_path = resolve_site_path(root, &mapping.datas)?; + let rows_source = std::fs::read_to_string(&data_path)?; + let rows = parse_root_value(&rows_source, &data_path)?; + let RuntimeValue::Array(rows) = rows else { + return Err(format!("{} must contain a root array", data_path.display()).into()); + }; + for row in rows { + pages.extend(materialize_row(root, mapping, &row)?); + } + } + + Ok(LoadedCatalog { mappings, pages }) +} + +/// Parse and semantically inspect the mapping index while preserving schema syntax. +fn parse_mappings(source: &str, path: &Path) -> AppResult> { + let result = parse(source); + if result.has_errors() { + return Err(format!( + "invalid mapping FON {}: {:?}", + path.display(), + result.diagnostics + ) + .into()); + } + let item_ids = result + .document + .ast + .root_array_items() + .ok_or_else(|| format!("{} must contain a root array", path.display()))?; + let mut mappings = Vec::with_capacity(item_ids.len()); + + for item_id in item_ids { + let value = result + .document + .ast + .value(*item_id) + .ok_or_else(|| "mapping references an invalid value".to_string())?; + let Value::Object(object) = value else { + return Err("each mapping must be an object".into()); + }; + let pattern = object_string_field(&result.document.ast, &object.members, "pattern")? + .ok_or_else(|| "mapping.pattern is required".to_string())?; + let datas = object_path_field(&result.document.ast, &object.members, "datas")? + .ok_or_else(|| "mapping.datas is required".to_string())?; + let params = object_field_value(&result.document.ast, &object.members, "params") + .ok_or_else(|| "mapping.params is required".to_string())?; + if !matches!(params, Value::Schema(_)) { + return Err("mapping.params must be a struct or enum schema".into()); + } + mappings.push(MappingSpec { + pattern, + datas: PathBuf::from(datas), + }); + } + + Ok(mappings) +} + +/// Materialize one sitemap row into one or more concrete pages. +fn materialize_row( + root: &Path, + mapping: &MappingSpec, + row: &RuntimeValue, +) -> AppResult> { + let RuntimeValue::Object(object) = row else { + return Err("each sitemap row must be an object".into()); + }; + let RuntimeValue::Object(params) = object + .get("params") + .ok_or_else(|| "sitemap row.params must be an object".to_string())? + else { + return Err("sitemap row.params must be an object".into()); + }; + let updated_at = object + .get("updated-at") + .map(RuntimeValue::as_string) + .unwrap_or_default(); + let content_reference = object + .get("content") + .map(RuntimeValue::as_string) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "sitemap row.content is required".to_string())?; + let content_path = resolve_site_path(root, Path::new(&content_reference))?; + let content = parse_content(&content_path)?; + let urls = expand_pattern(&mapping.pattern, params)?; + + Ok(urls + .into_iter() + .map(|url| IndexPage { + url, + title: choose_title(content.title.as_deref(), &content.body), + body: content.body.clone(), + updated_at: updated_at.clone(), + images: content.images.clone(), + graph: content.graph.clone(), + }) + .collect()) +} + +/// Parsed content envelope; image values are retained only as remote pointers. +#[derive(Debug, Clone)] +struct ContentPage { + title: Option, + body: String, + images: Vec, + graph: Option, +} + +/// Parse content FON without interpreting HTML; content remains plain text/Markdown. +fn parse_content(path: &Path) -> AppResult { + let source = std::fs::read_to_string(path)?; + let value = parse_root_value(&source, path)?; + let RuntimeValue::Object(object) = value else { + return Err(format!("{} must contain an object", path.display()).into()); + }; + let title = object + .get("title") + .map(RuntimeValue::as_string) + .filter(|value| !value.trim().is_empty()); + let body = object + .get("content") + .map(RuntimeValue::as_string) + .filter(|value| !value.is_empty()) + .ok_or_else(|| format!("{} must contain content", path.display()))?; + let images = object + .get("images") + .and_then(RuntimeValue::as_array) + .map(|values| { + values + .iter() + .map(RuntimeValue::as_string) + .filter(|value| !value.is_empty()) + .collect() + }) + .unwrap_or_default(); + let graph = object.get("graph").map(|value| format!("{value:?}")); + Ok(ContentPage { + title, + body, + images, + graph, + }) +} + +/// Select an explicit title, Markdown heading, or a bounded content prefix. +pub fn choose_title(explicit: Option<&str>, content: &str) -> String { + if let Some(title) = explicit.map(str::trim).filter(|title| !title.is_empty()) { + return title.to_string(); + } + for line in content.lines() { + let trimmed = line.trim(); + if let Some(heading) = trimmed.strip_prefix("# ") { + if !heading.trim().is_empty() { + return heading.trim().to_string(); + } + } + } + content + .split_whitespace() + .take(10) + .collect::>() + .join(" ") +} + +/// Expand scalar, optional and array bindings without enumerating an unbounded space. +fn expand_pattern( + pattern: &str, + params: &BTreeMap, +) -> AppResult> { + let mut variants = vec![String::new()]; + let mut cursor = 0; + while let Some(open_offset) = pattern[cursor..].find('{') { + let open = cursor + open_offset; + let close = pattern[open..] + .find('}') + .map(|offset| open + offset) + .ok_or_else(|| "pattern contains an unterminated parameter".to_string())?; + let name = &pattern[open + 1..close]; + let prefix = &pattern[cursor..open]; + let value = params.get(name); + let optional_terminal = value.is_none() && is_terminal_segment(pattern, open, close); + let replacements = match value { + Some(RuntimeValue::Array(values)) => values + .iter() + .map(path_value) + .collect::>>()?, + Some(value) => vec![path_value(value)?], + None if optional_terminal => vec![String::new()], + None => return Err(format!("missing non-terminal parameter: {name}").into()), + }; + let prefix = if optional_terminal { + prefix.trim_end_matches('/') + } else { + prefix + }; + variants = variants + .into_iter() + .flat_map(|base| { + replacements + .iter() + .map(move |replacement| format!("{base}{prefix}{replacement}")) + }) + .collect(); + cursor = close + 1; + } + let suffix = &pattern[cursor..]; + Ok(variants + .into_iter() + .map(|value| canonical_path(&format!("{value}{suffix}"))) + .collect()) +} + +/// Detect a missing placeholder at the end of a path segment. +fn is_terminal_segment(pattern: &str, open: usize, close: usize) -> bool { + pattern[..open].ends_with('/') && close + 1 == pattern.len() +} + +/// Percent-encode one path value while keeping unreserved URI characters intact. +fn percent_encode_segment(value: &str) -> String { + let mut output = String::with_capacity(value.len()); + for byte in value.as_bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') { + output.push(*byte as char); + } else { + output.push_str(&format!("%{byte:02X}")); + } + } + output +} + +fn path_value(value: &RuntimeValue) -> AppResult { + match value { + RuntimeValue::String(value) | RuntimeValue::Unknown(value) | RuntimeValue::Enum(value) => { + Ok(percent_encode_segment(value)) + } + RuntimeValue::Number(value) => Ok(percent_encode_segment(value)), + RuntimeValue::Boolean(value) => Ok(value.to_string()), + _ => Err("path parameters must be scalar strings, numbers, booleans, or arrays".into()), + } +} + +fn canonical_path(value: &str) -> String { + if value.starts_with('/') { + value.to_string() + } else { + format!("/{value}") + } +} + +fn resolve_site_path(root: &Path, path: &Path) -> AppResult { + let relative = path.strip_prefix("./").unwrap_or(path); + if relative.is_absolute() + || relative + .components() + .any(|component| matches!(component, Component::ParentDir)) + { + return Err(format!("unsafe site path: {}", path.display()).into()); + } + Ok(root.join(relative)) +} + +fn object_string_field( + ast: &Ast, + members: &[fon_parser::MemberId], + name: &str, +) -> AppResult> { + let Some(value) = object_field_value(ast, members, name) else { + return Ok(None); + }; + match value { + Value::String(value) => Ok(Some(unquote_string(&value.raw))), + _ => Err(format!("{name} must be a FON string").into()), + } +} + +fn object_path_field( + ast: &Ast, + members: &[fon_parser::MemberId], + name: &str, +) -> AppResult> { + let Some(value) = object_field_value(ast, members, name) else { + return Ok(None); + }; + match value { + Value::Unknown(value) => Ok(Some(value.raw.clone())), + Value::String(value) => Ok(Some(unquote_string(&value.raw))), + _ => Err(format!("{name} must be a FON path or string").into()), + } +} + +fn object_field_value<'a>( + ast: &'a Ast, + members: &[fon_parser::MemberId], + name: &str, +) -> Option<&'a Value> { + members.iter().find_map(|id| { + let binding = ast.member(*id).and_then(Member::binding)?; + (binding.key.raw == name) + .then(|| ast.value(binding.value)) + .flatten() + }) +} + +fn unquote_string(raw: &str) -> String { + let value = raw + .strip_prefix('`') + .and_then(|value| value.strip_suffix('`')) + .unwrap_or(raw); + let mut output = String::with_capacity(value.len()); + let mut chars = value.chars(); + while let Some(character) = chars.next() { + if character != '\\' { + output.push(character); + continue; + } + match chars.next() { + Some('n') => output.push('\n'), + Some('r') => output.push('\r'), + Some('t') => output.push('\t'), + Some('`') => output.push('`'), + Some('\\') => output.push('\\'), + Some(other) => { + output.push('\\'); + output.push(other); + } + None => output.push('\\'), + } + } + output +} + +/// Convert parsed AST values into a small, explicit runtime representation. +#[derive(Debug, Clone)] +enum RuntimeValue { + Boolean(bool), + Number(String), + String(String), + Enum(String), + Array(Vec), + Object(BTreeMap), + Unknown(String), +} + +impl RuntimeValue { + fn as_string(&self) -> String { + match self { + Self::Boolean(value) => value.to_string(), + Self::Number(value) + | Self::String(value) + | Self::Enum(value) + | Self::Unknown(value) => value.clone(), + Self::Array(_) | Self::Object(_) => String::new(), + } + } + + fn as_array(&self) -> Option<&[RuntimeValue]> { + match self { + Self::Array(values) => Some(values), + _ => None, + } + } +} + +fn parse_root_value(source: &str, path: &Path) -> AppResult { + let result = parse(source); + if result.has_errors() { + return Err(format!("invalid FON {}: {:?}", path.display(), result.diagnostics).into()); + } + if let Some(items) = result.document.ast.root_array_items() { + let values = items + .iter() + .map(|id| value_to_runtime(&result.document.ast, *id)) + .collect::>>()?; + return Ok(RuntimeValue::Array(values)); + } + let members = result + .document + .ast + .object_members() + .ok_or_else(|| format!("{} has no supported root", path.display()))?; + let mut object = BTreeMap::new(); + for member_id in members { + let member = result + .document + .ast + .member(*member_id) + .ok_or_else(|| "invalid member reference".to_string())?; + let binding = member + .binding() + .ok_or_else(|| "data objects cannot contain type declarations".to_string())?; + let value = value_to_runtime(&result.document.ast, binding.value)?; + if object.insert(binding.key.raw.clone(), value).is_some() { + return Err(format!("duplicate FON field: {}", binding.key.raw).into()); + } + } + Ok(RuntimeValue::Object(object)) +} + +fn value_to_runtime(ast: &Ast, value_id: fon_parser::ValueId) -> AppResult { + let value = ast + .value(value_id) + .ok_or_else(|| "invalid value reference".to_string())?; + match value { + Value::Boolean { value, .. } => Ok(RuntimeValue::Boolean(*value)), + Value::Number { raw, .. } => Ok(RuntimeValue::Number(raw.clone())), + Value::String(value) => { + if value + .parts + .iter() + .any(|part| part.kind == StringPartKind::Interpolation) + { + return Err("FON interpolation is not allowed in data files".into()); + } + Ok(RuntimeValue::String(unquote_string(&value.raw))) + } + Value::EnumPath(value) => Ok(RuntimeValue::Enum( + value.path.trim_start_matches('.').to_string(), + )), + Value::Array(value) => Ok(RuntimeValue::Array( + value + .items + .iter() + .map(|id| value_to_runtime(ast, *id)) + .collect::>>()?, + )), + Value::Object(value) => { + let mut object = BTreeMap::new(); + for member_id in &value.members { + let member = ast + .member(*member_id) + .ok_or_else(|| "invalid nested member reference".to_string())?; + let binding = member.binding().ok_or_else(|| { + "nested data objects cannot contain type declarations".to_string() + })?; + let nested = value_to_runtime(ast, binding.value)?; + if object.insert(binding.key.raw.clone(), nested).is_some() { + return Err(format!("duplicate FON field: {}", binding.key.raw).into()); + } + } + Ok(RuntimeValue::Object(object)) + } + Value::Unknown(value) => Ok(RuntimeValue::Unknown(value.raw.clone())), + Value::Regex(_) | Value::Schema(_) | Value::Expression(_) | Value::Error(_) => { + Err("unsupported executable or schema value in data file".into()) + } + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::{choose_title, load_catalog}; + + #[test] + fn loads_repository_mock_catalog() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../.."); + let catalog = load_catalog(&root).expect("repository mock catalog must load"); + assert_eq!(catalog.mappings.len(), 3); + assert_eq!(catalog.pages.len(), 5); + assert!( + catalog + .pages + .iter() + .any(|page| page.url == "/zh-hans/thought/1001") + ); + assert!(catalog.pages.iter().all(|page| page.images.len() == 1)); + } + + #[test] + fn title_falls_back_to_markdown_heading() { + assert_eq!( + choose_title(None, "# A useful title\n\nBody"), + "A useful title" + ); + } + + #[test] + fn title_falls_back_to_first_ten_whitespace_words() { + assert_eq!(choose_title(None, "one two three"), "one two three"); + } +} diff --git a/apps/fetch/back-end/src/main.rs b/apps/fetch/back-end/src/main.rs index 02d620a..792fadd 100644 --- a/apps/fetch/back-end/src/main.rs +++ b/apps/fetch/back-end/src/main.rs @@ -1 +1,32 @@ -// src/main.rs +// apps/fetch/back-end/src/main.rs + +mod fon; +mod model; +mod search; +mod web; + +use std::net::SocketAddr; +use std::path::PathBuf; +use std::sync::Arc; + +use search::SearchEngine; +use web::{AppState, router}; + +/// Start the local Fetch search API with configurable data and index directories. +#[tokio::main] +async fn main() -> Result<(), Box> { + let data_root = std::env::var_os("FETCH_DATA_ROOT") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")); + let index_root = std::env::var_os("FETCH_INDEX_ROOT") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".fetch-index")); + let bind_address = std::env::var("FETCH_BIND").unwrap_or_else(|_| "127.0.0.1:3000".to_string()); + let engine = Arc::new(SearchEngine::open(&index_root)?); + let state = AppState::new(engine, data_root); + let address: SocketAddr = bind_address.parse()?; + let listener = tokio::net::TcpListener::bind(address).await?; + println!("Fetch search API listening on http://{address}"); + axum::serve(listener, router(state)).await?; + Ok(()) +} diff --git a/apps/fetch/back-end/src/model.rs b/apps/fetch/back-end/src/model.rs new file mode 100644 index 0000000..0743d07 --- /dev/null +++ b/apps/fetch/back-end/src/model.rs @@ -0,0 +1,51 @@ +// apps/fetch/back-end/src/model.rs + +use serde::Serialize; + +/// A page materialized from one finite sitemap binding. +#[derive(Debug, Clone)] +pub struct IndexPage { + pub url: String, + pub title: String, + pub body: String, + pub updated_at: String, + pub images: Vec, + pub graph: Option, +} + +/// A search hit returned by the public API and rendered by the CSR client. +#[derive(Debug, Clone, Serialize)] +pub struct SearchHit { + pub url: String, + pub title: String, + pub snippet: String, + pub updated_at: String, + pub images: Vec, + pub score: f32, +} + +/// A stable response envelope for paginated search requests. +#[derive(Debug, Clone, Serialize)] +pub struct SearchResponse { + pub query: String, + pub offset: usize, + pub limit: usize, + pub total: usize, + pub results: Vec, +} + +/// Current index lifecycle state exposed to the mock console. +#[derive(Debug, Clone, Serialize)] +pub struct IndexStatus { + pub documents: usize, + pub mappings: usize, + pub last_rebuilt_at: String, +} + +/// Result of one complete mock sitemap ingestion. +#[derive(Debug, Clone, Serialize)] +pub struct RebuildReport { + pub mappings: usize, + pub rows: usize, + pub documents: usize, +} diff --git a/apps/fetch/back-end/src/search.rs b/apps/fetch/back-end/src/search.rs new file mode 100644 index 0000000..431ca91 --- /dev/null +++ b/apps/fetch/back-end/src/search.rs @@ -0,0 +1,273 @@ +// apps/fetch/back-end/src/search.rs + +use std::path::Path; +use std::sync::{Arc, Mutex}; + +use jieba_rs::Jieba; +use tantivy::collector::{Count, TopDocs}; +use tantivy::directory::MmapDirectory; +use tantivy::query::QueryParser; +use tantivy::schema::{ + Field, IndexRecordOption, STORED, STRING, Schema, TantivyDocument, TextFieldIndexing, + TextOptions, Value as TantivyValue, +}; +use tantivy::tokenizer::{TextAnalyzer, Token, TokenStream, Tokenizer}; +use tantivy::{Index, IndexReader, IndexWriter, ReloadPolicy, doc}; + +use crate::model::{IndexPage, SearchHit, SearchResponse}; + +type AppResult = Result>; + +const MAX_QUERY_CHARS: usize = 256; +const MAX_PAGE_SIZE: usize = 50; +const INDEX_MEMORY_BYTES: usize = 32 * 1024 * 1024; + +/// A shared Jieba tokenizer that preserves byte offsets required by Tantivy. +#[derive(Clone)] +pub struct JiebaTokenizer { + jieba: Arc, +} + +impl Default for JiebaTokenizer { + fn default() -> Self { + Self { + jieba: Arc::new(Jieba::new()), + } + } +} + +impl Tokenizer for JiebaTokenizer { + type TokenStream<'a> = JiebaTokenStream; + + fn token_stream<'a>(&'a mut self, text: &'a str) -> Self::TokenStream<'a> { + let tokens = self + .jieba + .cut_for_search(text, true) + .into_iter() + .map(|token| Token { + offset_from: token.byte_start, + offset_to: token.byte_end, + position: token.byte_start, + text: token.word.to_string(), + position_length: 1, + }) + .collect(); + JiebaTokenStream { + tokens, + index: 0, + current: Token::default(), + } + } +} + +/// A reusable Tantivy token stream backed by owned Jieba token text. +pub struct JiebaTokenStream { + tokens: Vec, + index: usize, + current: Token, +} + +impl TokenStream for JiebaTokenStream { + fn advance(&mut self) -> bool { + let Some(token) = self.tokens.get(self.index).cloned() else { + return false; + }; + self.current = token; + self.index += 1; + true + } + + fn token(&self) -> &Token { + &self.current + } + + fn token_mut(&mut self) -> &mut Token { + &mut self.current + } +} + +/// Field handles kept together so query and document code cannot drift apart. +#[derive(Clone, Copy)] +struct SearchFields { + url: Field, + title: Field, + content: Field, + updated_at: Field, + image_url: Field, + graph: Field, +} + +/// A single-writer Tantivy index with manual reader reloads after commits. +pub struct SearchEngine { + index: Index, + reader: IndexReader, + writer: Mutex, + fields: SearchFields, +} + +impl SearchEngine { + /// Open or create the on-disk index and register the Chinese analyzer. + pub fn open(index_path: &Path) -> AppResult { + std::fs::create_dir_all(index_path)?; + let (schema, fields) = build_schema(); + let index = if index_path.join("meta.json").exists() { + Index::open(MmapDirectory::open(index_path)?)? + } else { + Index::create_in_dir(index_path, schema.clone())? + }; + index.tokenizers().register( + "jieba_search", + TextAnalyzer::builder(JiebaTokenizer::default()).build(), + ); + let reader = index + .reader_builder() + .reload_policy(ReloadPolicy::Manual) + .try_into()?; + let writer = index.writer(INDEX_MEMORY_BYTES)?; + Ok(Self { + index, + reader, + writer: Mutex::new(writer), + fields, + }) + } + + /// Replace the current index atomically at the Tantivy commit boundary. + pub fn replace_all(&self, pages: &[IndexPage]) -> AppResult<()> { + let mut writer = self + .writer + .lock() + .map_err(|_| "index writer lock poisoned")?; + writer.delete_all_documents()?; + for page in pages { + writer.add_document(self.to_document(page))?; + } + writer.commit()?; + self.reader.reload()?; + Ok(()) + } + + /// Search indexed title/content with bounded pagination and stored metadata. + pub fn search(&self, query: &str, offset: usize, limit: usize) -> AppResult { + let query = query.trim(); + if query.chars().count() > MAX_QUERY_CHARS { + return Err("query is too long".into()); + } + let limit = limit.clamp(1, MAX_PAGE_SIZE); + let searcher = self.reader.searcher(); + let parser = + QueryParser::for_index(&self.index, vec![self.fields.title, self.fields.content]); + let parsed_query = parser.parse_query(query)?; + let total = searcher.search(&parsed_query, &Count)?; + let top_docs = searcher.search( + &parsed_query, + &TopDocs::with_limit(limit) + .and_offset(offset) + .order_by_score(), + )?; + let mut results = Vec::with_capacity(top_docs.len()); + for (score, address) in top_docs { + let document = searcher.doc::(address)?; + results.push(SearchHit { + url: first_text(&document, self.fields.url), + title: first_text(&document, self.fields.title), + snippet: first_text(&document, self.fields.content) + .chars() + .take(180) + .collect(), + updated_at: first_text(&document, self.fields.updated_at), + images: document + .get_all(self.fields.image_url) + .filter_map(|value| value.as_str().map(ToOwned::to_owned)) + .collect(), + score, + }); + } + Ok(SearchResponse { + query: query.to_string(), + offset, + limit, + total, + results, + }) + } + + /// Return the current number of live documents after a reader refresh. + pub fn document_count(&self) -> AppResult { + Ok(self + .reader + .searcher() + .search(&tantivy::query::AllQuery, &Count)?) + } + + fn to_document(&self, page: &IndexPage) -> TantivyDocument { + let mut document = doc!( + self.fields.url => page.url.as_str(), + self.fields.title => page.title.as_str(), + self.fields.content => page.body.as_str(), + self.fields.updated_at => page.updated_at.as_str(), + ); + if let Some(graph) = &page.graph { + document.add_text(self.fields.graph, graph); + } + for image in &page.images { + document.add_text(self.fields.image_url, image); + } + document + } +} + +/// Build stored fields and jieba-backed text fields for the MVP schema. +fn build_schema() -> (Schema, SearchFields) { + let mut builder = Schema::builder(); + let text_options = TextOptions::default() + .set_indexing_options( + TextFieldIndexing::default() + .set_tokenizer("jieba_search") + .set_index_option(IndexRecordOption::WithFreqsAndPositions), + ) + .set_stored(); + let url = builder.add_text_field("url", STRING | STORED); + let title = builder.add_text_field("title", text_options.clone()); + let content = builder.add_text_field("content", text_options); + let updated_at = builder.add_text_field("updated_at", STRING | STORED); + let image_url = builder.add_text_field("image_url", STRING | STORED); + let graph = builder.add_text_field("graph", STRING | STORED); + let schema = builder.build(); + ( + schema, + SearchFields { + url, + title, + content, + updated_at, + image_url, + graph, + }, + ) +} + +fn first_text(document: &TantivyDocument, field: Field) -> String { + document + .get_first(field) + .and_then(|value| value.as_str()) + .unwrap_or_default() + .to_string() +} + +#[cfg(test)] +mod tests { + use super::JiebaTokenizer; + use tantivy::tokenizer::{TokenStream, Tokenizer}; + + #[test] + fn jieba_emits_chinese_terms_with_byte_offsets() { + let mut tokenizer = JiebaTokenizer::default(); + let mut stream = tokenizer.token_stream("关于我的动态"); + let mut terms = Vec::new(); + while stream.advance() { + terms.push(stream.token().text.clone()); + } + assert!(terms.iter().any(|term| term == "动态")); + } +} diff --git a/apps/fetch/back-end/src/web.rs b/apps/fetch/back-end/src/web.rs new file mode 100644 index 0000000..859d7d7 --- /dev/null +++ b/apps/fetch/back-end/src/web.rs @@ -0,0 +1,196 @@ +// apps/fetch/back-end/src/web.rs + +use std::sync::{Arc, RwLock}; + +use axum::Router; +use axum::extract::{Query, State}; +use axum::http::StatusCode; +use axum::response::{Html, IntoResponse, Json, Response}; +use axum::routing::{get, post}; +use serde::Deserialize; + +use crate::fon::load_catalog; +use crate::model::{IndexStatus, RebuildReport, SearchResponse}; +use crate::search::SearchEngine; + +/// Shared application state kept behind an immutable Arc for concurrent requests. +#[derive(Clone)] +pub struct AppState { + pub engine: Arc, + pub mock_root: std::path::PathBuf, + metadata: Arc>, +} + +#[derive(Debug, Default)] +struct IndexMetadata { + mappings: usize, + last_rebuilt_at: String, +} + +impl AppState { + pub fn new(engine: Arc, mock_root: std::path::PathBuf) -> Self { + Self { + engine, + mock_root, + metadata: Arc::new(RwLock::new(IndexMetadata::default())), + } + } +} + +/// Build the public API and CSR shell routes. +pub fn router(state: AppState) -> Router { + Router::new() + .route("/", get(index_page)) + .route("/api/v1/search", get(search)) + .route("/api/v1/index/status", get(index_status)) + .route("/api/v1/index/rebuild", post(rebuild_index)) + .with_state(state) +} + +#[derive(Debug, Deserialize)] +pub struct SearchQuery { + pub q: String, + pub offset: Option, + pub limit: Option, +} + +/// Execute a bounded full-text query and return a stable JSON envelope. +async fn search( + State(state): State, + Query(query): Query, +) -> Result, ApiError> { + let response = state.engine.search( + &query.q, + query.offset.unwrap_or(0), + query.limit.unwrap_or(20), + )?; + Ok(Json(response)) +} + +/// Report the number of currently searchable documents. +async fn index_status(State(state): State) -> Result, ApiError> { + let metadata = state + .metadata + .read() + .map_err(|_| "index metadata lock poisoned")?; + Ok(Json(IndexStatus { + documents: state.engine.document_count()?, + mappings: metadata.mappings, + last_rebuilt_at: metadata.last_rebuilt_at.clone(), + })) +} + +/// Rebuild the local mock index from the three FON resources. +async fn rebuild_index(State(state): State) -> Result, ApiError> { + let catalog = load_catalog(&state.mock_root)?; + state.engine.replace_all(&catalog.pages)?; + let mut metadata = state + .metadata + .write() + .map_err(|_| "index metadata lock poisoned")?; + metadata.mappings = catalog.mappings.len(); + metadata.last_rebuilt_at = unix_timestamp().to_string(); + Ok(Json(RebuildReport { + mappings: catalog.mappings.len(), + rows: catalog.pages.len(), + documents: catalog.pages.len(), + })) +} + +/// Return a stable wall-clock marker without adding a date-time dependency. +fn unix_timestamp() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |duration| duration.as_secs()) +} + +/// Serve a dependency-free CSR mock console so the backend can be tested in a browser. +async fn index_page() -> Html<&'static str> { + Html(include_str!("../../../../web/index.html")) +} + +/// Convert internal failures into one JSON error shape without leaking implementation details. +#[derive(Debug)] +struct ApiError(Box); + +impl From> for ApiError { + fn from(error: Box) -> Self { + Self(error) + } +} + +impl From<&'static str> for ApiError { + fn from(error: &'static str) -> Self { + Self(std::io::Error::other(error).into()) + } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + let body = serde_json::json!({ "error": self.0.to_string() }); + (StatusCode::INTERNAL_SERVER_ERROR, Json(body)).into_response() + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + use std::sync::Arc; + + use axum::body::{Body, to_bytes}; + use axum::http::{Request, StatusCode}; + use tower::ServiceExt; + + use super::{AppState, router}; + use crate::search::SearchEngine; + + #[tokio::test] + async fn rebuild_then_search_returns_indexed_mock_content() { + let index_dir = tempfile::tempdir().expect("temporary index directory must be created"); + let engine = + Arc::new(SearchEngine::open(index_dir.path()).expect("temporary index must open")); + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../.."); + let app = router(AppState::new(engine, root)); + + let rebuild = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/v1/index/rebuild") + .body(Body::empty()) + .unwrap(), + ) + .await + .expect("rebuild response must be returned"); + assert_eq!(rebuild.status(), StatusCode::OK); + let rebuild_body = to_bytes(rebuild.into_body(), usize::MAX) + .await + .expect("rebuild body must be readable"); + let rebuild_json: serde_json::Value = + serde_json::from_slice(&rebuild_body).expect("rebuild body must be JSON"); + assert_eq!(rebuild_json["documents"], 5); + + let search = app + .oneshot( + Request::builder() + .uri("/api/v1/search?q=搜索引擎&limit=10") + .body(Body::empty()) + .unwrap(), + ) + .await + .expect("search response must be returned"); + assert_eq!(search.status(), StatusCode::OK); + let search_body = to_bytes(search.into_body(), usize::MAX) + .await + .expect("search body must be readable"); + let search_json: serde_json::Value = + serde_json::from_slice(&search_body).expect("search body must be JSON"); + assert_eq!(search_json["total"], 2); + assert!( + search_json["results"][0]["url"] + .as_str() + .is_some_and(|url| url.contains("thought")) + ); + } +} diff --git a/well-known/content/fuyeor-comments.fon b/well-known/content/fuyeor-comments.fon new file mode 100644 index 0000000..2f4f0c8 --- /dev/null +++ b/well-known/content/fuyeor-comments.fon @@ -0,0 +1,13 @@ +// well-known/content/fuyeor-comments.fon +title = `Fuyeor 的 Comments` +content = `# Comments + +这里展示用户参与讨论的评论索引。正文按纯 Markdown 处理,HTML 不会被解析或执行。` +images = [ + `https://fuyeor.com/assets/comment-thread.webp` +] +graph = { + type = .discussion + name = `Fuyeor comments` + description = `Comments authored by Fuyeor` +} diff --git a/well-known/content/fuyeor-profile.fon b/well-known/content/fuyeor-profile.fon new file mode 100644 index 0000000..64c815e --- /dev/null +++ b/well-known/content/fuyeor-profile.fon @@ -0,0 +1,13 @@ +// well-known/content/fuyeor-profile.fon +title = `Fuyeor (@Fuyeor) « Profile « Ф` +content = `# 关于 Fuyeor + +Fuyeor 是一个关注开放网络、FON、FRL 和 Search Patterns Protocol 的个人主页。` +images = [ + `https://fuyeor.com/assets/fuyeor-avatar.webp` +] +graph = { + type = .person + name = `Fuyeor` + description = `@Fuyeor on Ф social` +} diff --git a/well-known/content/fuyeor-thoughts.fon b/well-known/content/fuyeor-thoughts.fon new file mode 100644 index 0000000..60f1284 --- /dev/null +++ b/well-known/content/fuyeor-thoughts.fon @@ -0,0 +1,13 @@ +// well-known/content/fuyeor-thoughts.fon +title = `Fuyeor 的 Thoughts` +content = `# Thoughts + +这里记录关于搜索模式、开放网络和可组合内容格式的思考。SPP 通过 pattern 和参数约束,让搜索引擎不必枚举海量派生页面。` +images = [ + `https://fuyeor.com/assets/spp-diagram.webp` +] +graph = { + type = .article + name = `Search Patterns Protocol` + description = `Pattern-driven indexing for generated pages` +} diff --git a/well-known/content/thought-en-1002.fon b/well-known/content/thought-en-1002.fon new file mode 100644 index 0000000..67f3b79 --- /dev/null +++ b/well-known/content/thought-en-1002.fon @@ -0,0 +1,13 @@ +// well-known/content/thought-en-1002.fon +title = `Pattern-driven indexing` +content = `# Pattern-driven indexing + +SPP lets a search engine index a deterministic page generator instead of crawling every possible URL. This keeps generated content discoverable without materializing an unbounded sitemap.` +images = [ + `https://fuyeor.com/assets/pattern-indexing.webp` +] +graph = { + type = .article + name = `Pattern-driven indexing` + description = `Deterministic page generation for search` +} diff --git a/well-known/content/thought-zh-1001.fon b/well-known/content/thought-zh-1001.fon new file mode 100644 index 0000000..7841af3 --- /dev/null +++ b/well-known/content/thought-zh-1001.fon @@ -0,0 +1,13 @@ +// well-known/content/thought-zh-1001.fon +title = `SPP 与生成页面` +content = `# SPP 与生成页面 + +当网站可以通过程序生成十亿个页面时,搜索引擎应索引生成规则和参数空间,而不是定时爬取每一个页面。` +images = [ + `https://fuyeor.com/assets/generated-pages.webp` +] +graph = { + type = .article + name = `SPP 与生成页面` + description = `通过参数空间派生页面` +} diff --git a/well-known/search-patterns.fon b/well-known/search-patterns.fon new file mode 100644 index 0000000..240e153 --- /dev/null +++ b/well-known/search-patterns.fon @@ -0,0 +1,26 @@ +// well-known/search-patterns.fon +[ + { + pattern = `/@{username}` + params = struct { + username: string + } + datas = ./well-known/sitemaps/users.fon + } + { + pattern = `/@{username}/{tab}` + params = struct { + username: string + tab: enum { thoughts, comments } + } + datas = ./well-known/sitemaps/user-tabs.fon + } + { + pattern = `/{locale}/thought/{id}` + params = struct { + locale: enum { en, fr, es, zh-hans } + id: int + } + datas = ./well-known/sitemaps/thoughts.fon + } +] diff --git a/well-known/sitemaps/thoughts.fon b/well-known/sitemaps/thoughts.fon new file mode 100644 index 0000000..3d0a2ec --- /dev/null +++ b/well-known/sitemaps/thoughts.fon @@ -0,0 +1,13 @@ +// well-known/sitemaps/thoughts.fon +[ + { + params = { locale = .zh-hans, id = 1001 } + updated-at = `2026-08-30T08:30:00.000Z` + content = ./well-known/content/thought-zh-1001.fon + } + { + params = { locale = .en, id = 1002 } + updated-at = `2026-08-29T16:00:00.000Z` + content = ./well-known/content/thought-en-1002.fon + } +] diff --git a/well-known/sitemaps/user-tabs.fon b/well-known/sitemaps/user-tabs.fon new file mode 100644 index 0000000..e64141a --- /dev/null +++ b/well-known/sitemaps/user-tabs.fon @@ -0,0 +1,13 @@ +// well-known/sitemaps/user-tabs.fon +[ + { + params = { username = `Fuyeor`, tab = .thoughts } + updated-at = `2026-08-30T08:30:00.000Z` + content = ./well-known/content/fuyeor-thoughts.fon + } + { + params = { username = `Fuyeor`, tab = .comments } + updated-at = `2026-08-30T08:30:00.000Z` + content = ./well-known/content/fuyeor-comments.fon + } +] diff --git a/well-known/sitemaps/users.fon b/well-known/sitemaps/users.fon new file mode 100644 index 0000000..9ac4192 --- /dev/null +++ b/well-known/sitemaps/users.fon @@ -0,0 +1,8 @@ +// well-known/sitemaps/users.fon +[ + { + params = { username = `Fuyeor` } + updated-at = `2026-08-30T08:30:00.000Z` + content = ./well-known/content/fuyeor-profile.fon + } +] From 6e06e1b8b8cf4b1463bf4aebc00b891b323219c6 Mon Sep 17 00:00:00 2001 From: Manus AI Date: Thu, 27 Aug 2026 15:53:45 +0000 Subject: [PATCH 2/9] feat: Add CSR search mock console --- web/index.html | 201 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 web/index.html diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..5de9d20 --- /dev/null +++ b/web/index.html @@ -0,0 +1,201 @@ + + + + + + + Fetch · SPP Search + + + +
+
+
+
Fuyeor · Search Patterns Protocol
+

Fetch

+
+

一个以 FON sitemap 为入口、以 Markdown 为内容、由 Tantivy 和 jieba-rs 驱动的 CSR 搜索 mock。

+
+ +
+ + +
+ +
+ 等待索引 + +
+ +
+
输入关键词开始搜索
+
+ + + + From 68fe93b95a72a9650cc727468ab444158182decd Mon Sep 17 00:00:00 2001 From: Fuyeor Date: Thu, 27 Aug 2026 20:33:15 +0000 Subject: [PATCH 3/9] feat: Implement basic front-end --- .localerc.json | 10 + .npmrc | 1 + .prettierrc.json | 7 + .vscode/extensions.json | 11 + .vscode/settings.json | 30 + Cargo.toml | 2 +- README.md | 2 +- apps/engine/.fetch-index/.managed.json | 1 + apps/engine/.fetch-index/.tantivy-meta.lock | 0 apps/engine/.fetch-index/.tantivy-writer.lock | 0 apps/engine/.fetch-index/meta.json | 88 + apps/{fetch/back-end => engine}/Cargo.toml | 0 apps/{fetch/back-end => engine}/src/fon.rs | 0 apps/{fetch/back-end => engine}/src/main.rs | 0 apps/{fetch/back-end => engine}/src/model.rs | 0 apps/{fetch/back-end => engine}/src/search.rs | 0 apps/{fetch/back-end => engine}/src/web.rs | 12 +- apps/front-end/index.html | 114 + apps/front-end/package.json | 31 + apps/front-end/src/App.vue | 32 + apps/front-end/src/api/index.ts | 10 + apps/front-end/src/api/search.ts | 12 + apps/front-end/src/components/Search/Card.vue | 111 + .../front-end/src/components/Search/Input.vue | 89 + apps/front-end/src/components/SearchInput.vue | 101 + .../front-end/src/composables/api/useQuery.ts | 13 + apps/front-end/src/config/locales.ts | 16 + apps/front-end/src/main.ts | 27 + apps/front-end/src/router/index.ts | 101 + apps/front-end/src/types/search.ts | 27 + apps/front-end/src/views/Home.vue | 51 + apps/front-end/src/views/Search.vue | 86 + apps/front-end/tsconfig.json | 10 + apps/front-end/vite.config.js | 27 + package.json | 27 + pnpm-lock.yaml | 2814 +++++++++++++++++ pnpm-workspace.yaml | 10 + 37 files changed, 3865 insertions(+), 8 deletions(-) create mode 100644 .localerc.json create mode 100644 .npmrc create mode 100644 .prettierrc.json create mode 100644 .vscode/extensions.json create mode 100644 .vscode/settings.json create mode 100644 apps/engine/.fetch-index/.managed.json create mode 100644 apps/engine/.fetch-index/.tantivy-meta.lock create mode 100644 apps/engine/.fetch-index/.tantivy-writer.lock create mode 100644 apps/engine/.fetch-index/meta.json rename apps/{fetch/back-end => engine}/Cargo.toml (100%) rename apps/{fetch/back-end => engine}/src/fon.rs (100%) rename apps/{fetch/back-end => engine}/src/main.rs (100%) rename apps/{fetch/back-end => engine}/src/model.rs (100%) rename apps/{fetch/back-end => engine}/src/search.rs (100%) rename apps/{fetch/back-end => engine}/src/web.rs (94%) create mode 100644 apps/front-end/index.html create mode 100644 apps/front-end/package.json create mode 100644 apps/front-end/src/App.vue create mode 100644 apps/front-end/src/api/index.ts create mode 100644 apps/front-end/src/api/search.ts create mode 100644 apps/front-end/src/components/Search/Card.vue create mode 100644 apps/front-end/src/components/Search/Input.vue create mode 100644 apps/front-end/src/components/SearchInput.vue create mode 100644 apps/front-end/src/composables/api/useQuery.ts create mode 100644 apps/front-end/src/config/locales.ts create mode 100644 apps/front-end/src/main.ts create mode 100644 apps/front-end/src/router/index.ts create mode 100644 apps/front-end/src/types/search.ts create mode 100644 apps/front-end/src/views/Home.vue create mode 100644 apps/front-end/src/views/Search.vue create mode 100644 apps/front-end/tsconfig.json create mode 100644 apps/front-end/vite.config.js create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml diff --git a/.localerc.json b/.localerc.json new file mode 100644 index 0000000..41f78ff --- /dev/null +++ b/.localerc.json @@ -0,0 +1,10 @@ +{ + "basic": { + "source": "apps/{id}/front-end/node_modules/@fuyeor/locale/src/common.json", + "outDir": "apps/{id}/front-end/public/assets/locale" + }, + "main": { + "source": "apps/{id}/front-end/src/locale/locale.json", + "outDir": "apps/{id}/front-end/public/assets/locale" + } +} diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..fc21650 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +@fuyeor:registry=https://fpm.fuyeor.com/v1/ \ No newline at end of file diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..01f0bf9 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,7 @@ +{ + "semi": true, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "all", + "printWidth": 80 +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..0e449ce --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,11 @@ +// .vscode/extensions.json +{ + "recommendations": [ + // format FON + "fuyeor.fer", + // Prettier: format JSON, TS, JS + "esbenp.prettier-vscode", + // Rust: language server & analyzer + "rust-lang.rust-analyzer" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..f466737 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,30 @@ +// .vscode/settings.json +{ + // auto formatting when saving + "editor.formatOnSave": true, + // assign default formatting tool + "[fon]": { + "editor.defaultFormatter": "fuyeor.fer" + }, + "[json]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[javascript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[toml]": { + "editor.defaultFormatter": "tamasfe.even-better-toml" + }, + "[rust]": { + "editor.defaultFormatter": "rust-lang.rust-analyzer" + }, + "search.exclude": { + "**/node_modules": true, + "**/dist": true, + "**/build": true, + "**/target": true + } +} diff --git a/Cargo.toml b/Cargo.toml index a516181..35311c7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,4 +1,4 @@ # Cargo.toml [workspace] resolver = "2" -members = ["apps/fetch/back-end"] +members = ["apps/engine"] diff --git a/README.md b/README.md index 791614a..9e1b2fd 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ **Fetch** is the reference implementation of the **Search Patterns Protocol (SPP)**. -The [Search Patterns Protocol](https://fuyeor.com/t/560) is an experimental indexing standard based on pattern matching. Unlike traditional crawling methods, SPP is designed for controlled environments, specifically catering to enterprises and organizations that manage multiple web properties and require a unified, high-precision cross-site search infrastructure. +The [Search Patterns Protocol](https://reference.fuyeor.com/fetch/search-patterns-protocol) is an experimental indexing standard based on pattern matching. Unlike traditional crawling methods, SPP is designed for controlled environments, specifically catering to enterprises and organizations that manage multiple web properties and require a unified, high-precision cross-site search infrastructure. SPP is architecture-agnostic. Whether a site is a Client-Side Rendered (CSR) app, a Multi-Page Application (MPA), or built on any backend stack, it can be seamlessly indexed provided that the Search Patterns Protocol is implemented. diff --git a/apps/engine/.fetch-index/.managed.json b/apps/engine/.fetch-index/.managed.json new file mode 100644 index 0000000..1b67f4e --- /dev/null +++ b/apps/engine/.fetch-index/.managed.json @@ -0,0 +1 @@ +["meta.json"] diff --git a/apps/engine/.fetch-index/.tantivy-meta.lock b/apps/engine/.fetch-index/.tantivy-meta.lock new file mode 100644 index 0000000..e69de29 diff --git a/apps/engine/.fetch-index/.tantivy-writer.lock b/apps/engine/.fetch-index/.tantivy-writer.lock new file mode 100644 index 0000000..e69de29 diff --git a/apps/engine/.fetch-index/meta.json b/apps/engine/.fetch-index/meta.json new file mode 100644 index 0000000..e7a4d1f --- /dev/null +++ b/apps/engine/.fetch-index/meta.json @@ -0,0 +1,88 @@ +{ + "index_settings": { + "docstore_compression": "lz4", + "docstore_blocksize": 16384 + }, + "segments": [], + "schema": [ + { + "name": "url", + "type": "text", + "options": { + "indexing": { + "record": "basic", + "fieldnorms": true, + "tokenizer": "raw" + }, + "stored": true, + "fast": false + } + }, + { + "name": "title", + "type": "text", + "options": { + "indexing": { + "record": "position", + "fieldnorms": true, + "tokenizer": "jieba_search" + }, + "stored": true, + "fast": false + } + }, + { + "name": "content", + "type": "text", + "options": { + "indexing": { + "record": "position", + "fieldnorms": true, + "tokenizer": "jieba_search" + }, + "stored": true, + "fast": false + } + }, + { + "name": "updated_at", + "type": "text", + "options": { + "indexing": { + "record": "basic", + "fieldnorms": true, + "tokenizer": "raw" + }, + "stored": true, + "fast": false + } + }, + { + "name": "image_url", + "type": "text", + "options": { + "indexing": { + "record": "basic", + "fieldnorms": true, + "tokenizer": "raw" + }, + "stored": true, + "fast": false + } + }, + { + "name": "graph", + "type": "text", + "options": { + "indexing": { + "record": "basic", + "fieldnorms": true, + "tokenizer": "raw" + }, + "stored": true, + "fast": false + } + } + ], + "opstamp": 0 +} diff --git a/apps/fetch/back-end/Cargo.toml b/apps/engine/Cargo.toml similarity index 100% rename from apps/fetch/back-end/Cargo.toml rename to apps/engine/Cargo.toml diff --git a/apps/fetch/back-end/src/fon.rs b/apps/engine/src/fon.rs similarity index 100% rename from apps/fetch/back-end/src/fon.rs rename to apps/engine/src/fon.rs diff --git a/apps/fetch/back-end/src/main.rs b/apps/engine/src/main.rs similarity index 100% rename from apps/fetch/back-end/src/main.rs rename to apps/engine/src/main.rs diff --git a/apps/fetch/back-end/src/model.rs b/apps/engine/src/model.rs similarity index 100% rename from apps/fetch/back-end/src/model.rs rename to apps/engine/src/model.rs diff --git a/apps/fetch/back-end/src/search.rs b/apps/engine/src/search.rs similarity index 100% rename from apps/fetch/back-end/src/search.rs rename to apps/engine/src/search.rs diff --git a/apps/fetch/back-end/src/web.rs b/apps/engine/src/web.rs similarity index 94% rename from apps/fetch/back-end/src/web.rs rename to apps/engine/src/web.rs index 859d7d7..a82992f 100644 --- a/apps/fetch/back-end/src/web.rs +++ b/apps/engine/src/web.rs @@ -41,9 +41,9 @@ impl AppState { pub fn router(state: AppState) -> Router { Router::new() .route("/", get(index_page)) - .route("/api/v1/search", get(search)) - .route("/api/v1/index/status", get(index_status)) - .route("/api/v1/index/rebuild", post(rebuild_index)) + .route("/search", get(search)) + .route("/index/status", get(index_status)) + .route("/index/rebuild", post(rebuild_index)) .with_state(state) } @@ -106,7 +106,7 @@ fn unix_timestamp() -> u64 { /// Serve a dependency-free CSR mock console so the backend can be tested in a browser. async fn index_page() -> Html<&'static str> { - Html(include_str!("../../../../web/index.html")) + Html(include_str!("../../../web/index.html")) } /// Convert internal failures into one JSON error shape without leaking implementation details. @@ -157,7 +157,7 @@ mod tests { .oneshot( Request::builder() .method("POST") - .uri("/api/v1/index/rebuild") + .uri("/index/rebuild") .body(Body::empty()) .unwrap(), ) @@ -174,7 +174,7 @@ mod tests { let search = app .oneshot( Request::builder() - .uri("/api/v1/search?q=搜索引擎&limit=10") + .uri("/search?q=搜索引擎&limit=10") .body(Body::empty()) .unwrap(), ) diff --git a/apps/front-end/index.html b/apps/front-end/index.html new file mode 100644 index 0000000..65d28e2 --- /dev/null +++ b/apps/front-end/index.html @@ -0,0 +1,114 @@ + + + + + + + + + + + + + + + + + + + + + + Fetch Search + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/front-end/package.json b/apps/front-end/package.json new file mode 100644 index 0000000..8bcf0ac --- /dev/null +++ b/apps/front-end/package.json @@ -0,0 +1,31 @@ +{ + "name": "@fuyeor/fetch-front-end", + "private": true, + "type": "module", + "license": "MIT", + "author": "Fuyeor ", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "test": "vitest run" + }, + "dependencies": { + "@fuyeor/commons": "^0.1.2", + "@fuyeor/config": "^0.1.1", + "@fuyeor/interactify": "^0.1.1", + "@fuyeor/locale": "^0.1.0", + "@fuyeor/vue-query": "^0.1.0", + "@fuyeor/vue-router": "^0.0.9", + "vue": "^3.5.41" + }, + "devDependencies": { + "@fuyeor/tsconfig": "0.1.0", + "@types/node": "^26.2.0", + "@vitejs/plugin-vue": "^6.0.8", + "html-minifier-terser": "^7.2.0", + "lightningcss": "^1.33.0", + "typescript": "^7.0.2", + "vite": "8.0.1" + } +} diff --git a/apps/front-end/src/App.vue b/apps/front-end/src/App.vue new file mode 100644 index 0000000..11ea79d --- /dev/null +++ b/apps/front-end/src/App.vue @@ -0,0 +1,32 @@ + + + + diff --git a/apps/front-end/src/api/index.ts b/apps/front-end/src/api/index.ts new file mode 100644 index 0000000..1e6d676 --- /dev/null +++ b/apps/front-end/src/api/index.ts @@ -0,0 +1,10 @@ +// @/api/index.ts +import { HttpClient } from '@fuyeor/commons'; + +const apiClient = new HttpClient({ + baseURL: '/v1', + timeout: 10000, + credentials: 'include', +}); + +export default apiClient; diff --git a/apps/front-end/src/api/search.ts b/apps/front-end/src/api/search.ts new file mode 100644 index 0000000..3b0062e --- /dev/null +++ b/apps/front-end/src/api/search.ts @@ -0,0 +1,12 @@ +// @/api/search.ts +import apiClient from '@/api'; +import type { SearchQuery, SearchResults } from '@/types/search'; + +/** Fetches search results based on query parameters. */ +export async function fetchSearchResults( + params: SearchQuery, +): Promise { + return await apiClient.get('/search', { + params, + }); +} diff --git a/apps/front-end/src/components/Search/Card.vue b/apps/front-end/src/components/Search/Card.vue new file mode 100644 index 0000000..5c544bc --- /dev/null +++ b/apps/front-end/src/components/Search/Card.vue @@ -0,0 +1,111 @@ + + + + + + \ No newline at end of file diff --git a/apps/front-end/src/components/Search/Input.vue b/apps/front-end/src/components/Search/Input.vue new file mode 100644 index 0000000..2e1bc77 --- /dev/null +++ b/apps/front-end/src/components/Search/Input.vue @@ -0,0 +1,89 @@ + + + + + + \ No newline at end of file diff --git a/apps/front-end/src/components/SearchInput.vue b/apps/front-end/src/components/SearchInput.vue new file mode 100644 index 0000000..3ad2c7f --- /dev/null +++ b/apps/front-end/src/components/SearchInput.vue @@ -0,0 +1,101 @@ + + + + + + diff --git a/apps/front-end/src/composables/api/useQuery.ts b/apps/front-end/src/composables/api/useQuery.ts new file mode 100644 index 0000000..d55ca50 --- /dev/null +++ b/apps/front-end/src/composables/api/useQuery.ts @@ -0,0 +1,13 @@ +// @/composables/api/useQuery.ts +import { toValue, type MaybeRefOrGetter } from 'vue'; +import { useQuery as useVueQuery } from '@fuyeor/vue-query'; +import { fetchSearchResults } from '@/api/search'; +import type { SearchQuery } from '@/types/search'; + +/** Composable for executing search queries with @fuyeor/vue-query. */ +export function useQuery(params: MaybeRefOrGetter) { + return useVueQuery({ + queryKey: ['search', () => toValue(params)], + queryFn: () => fetchSearchResults(toValue(params)), + }); +} diff --git a/apps/front-end/src/config/locales.ts b/apps/front-end/src/config/locales.ts new file mode 100644 index 0000000..7ca4f97 --- /dev/null +++ b/apps/front-end/src/config/locales.ts @@ -0,0 +1,16 @@ +// @/config/locales.ts + +export type SupportedLocale = (typeof SUPPORTED_LOCALES)[number]; + +/** + * front-end UI supported locales + */ +// prettier-ignore +export const SUPPORTED_LOCALES = [ + 'en', 'fr', 'es', 'pt', 'de', + 'ar', 'ru', 'ja', 'ko', + 'zh-hans', 'zh-hant' +] as const; + +// generate regex string for router +export const LOCALE_REGEX = SUPPORTED_LOCALES.join('|'); diff --git a/apps/front-end/src/main.ts b/apps/front-end/src/main.ts new file mode 100644 index 0000000..48eb719 --- /dev/null +++ b/apps/front-end/src/main.ts @@ -0,0 +1,27 @@ +// @/main.ts +// pnpm -F @fuyeor/fetch-front-end dev +import App from './App.vue'; +import router from './router'; + +import { createApp } from 'vue'; +import { VueQueryPlugin } from '@fuyeor/vue-query'; +import { initializeLocale, createHead } from '@fuyeor/commons'; +import { vRipple, vTooltip } from '@fuyeor/interactify'; + +async function bootstrap() { + const app = createApp(App); + const head = createHead(); + + app.use(router); + app.use(VueQueryPlugin); + app.use(head); + + await initializeLocale({ app }); + + app.directive('ripple', vRipple); + app.directive('tooltip', vTooltip); + + app.mount('#app'); +} + +bootstrap(); \ No newline at end of file diff --git a/apps/front-end/src/router/index.ts b/apps/front-end/src/router/index.ts new file mode 100644 index 0000000..66c5545 --- /dev/null +++ b/apps/front-end/src/router/index.ts @@ -0,0 +1,101 @@ +// @/router/index.ts +import { + createRouter, + RouteLocation, + RouterView, + type RouteRecord, +} from '@fuyeor/vue-router'; +import { useTransitionBar } from '@fuyeor/interactify'; +import { useLocaleStore } from '@fuyeor/commons'; +import { LOCALE_REGEX } from '@/config/locales'; + +const { start, done } = useTransitionBar(); + +const appRoutes: Array = [ + { + // fetch.fuyeor.com/ + path: '', + name: 'Home', + component: () => import('@/views/Home.vue'), + meta: { + public: true, + overrideTitle: ['site.name', ':', 'site.title'], + }, + }, + { + // fetch.fuyeor.com/search + path: 'search', + name: 'Search', + component: () => import('@/views/Search.vue'), + props: true, + meta: { + public: true, + areaKey: 'search', + }, + }, +]; + +// root router +const routes: Array = [ + { + // optional locale prefix wrapper, only allows supported locales + path: `{/:locale(${LOCALE_REGEX})}?`, + component: RouterView, + // 将所有应用路由放入 children + // 注意:子路由的 path 如果不以 / 开头,会拼接在父路由后面 + // 例如:/en/thought/123 或 /thought/123 + children: appRoutes, + }, + + // 404 路由必须放在最外层,且在最后 + // 如果 URL 是 /xx/thought/123 (不支持的语言),正则匹配失败,会落到这里 + { + path: '/*', // 通配符路由,捕获所有未匹配的路径 + name: 'NotFound', + component: () => + import('@fuyeor/interactify/views').then((m) => m.NotFoundView), + meta: { + public: true, + titleKey: 'notFound.title', + }, + }, +]; + +// 创建路由实例 +const router = createRouter({ routes }); + +// 路由守卫 +router.beforeEach(async (to, from) => { + // 启动顶部进度条 + start(); + + // 获取语言 store + const localeStore = useLocaleStore(); + + // 从路由参数中获取 locale (例如 'en' 或 undefined) + const routeLocale = to.params.locale as string | undefined; + + // 如果路由中有语言参数,且与当前 store 中的不一致,强制切换 + // 这会触发 initializeLocale 中的 loadLocaleMessages,加载新语言包 + if (routeLocale && routeLocale !== localeStore.locale) { + try { + await localeStore.setLocale(routeLocale); + } catch (e) { + console.error('[RouterGuard] Failed to load locale:', e); + // 加载语言失败不应该阻塞跳转,只不过界面可能显示默认语言 + } + } + + // 如果代码能执行到这里,说明所有检查都通过了,允许导航 + return true; +}); + +router.afterEach(() => { + done(); +}); + +router.onError(() => { + done(); +}); + +export default router; diff --git a/apps/front-end/src/types/search.ts b/apps/front-end/src/types/search.ts new file mode 100644 index 0000000..918c61b --- /dev/null +++ b/apps/front-end/src/types/search.ts @@ -0,0 +1,27 @@ +// @/types/search.ts + +export interface SearchQuery extends Record< + string, + string | number | boolean | undefined +> { + q: string; + offset?: number; + limit?: number; +} + +export interface SearchResults { + query: string; + offset: number; + limit: number; + total: number; + results: SearchItem[]; +} + +export interface SearchItem { + url: string; + title: string; + snippet: string; + updated_at: string; + images: string[]; + score: number; +} diff --git a/apps/front-end/src/views/Home.vue b/apps/front-end/src/views/Home.vue new file mode 100644 index 0000000..821dbc5 --- /dev/null +++ b/apps/front-end/src/views/Home.vue @@ -0,0 +1,51 @@ + + + + + + diff --git a/apps/front-end/src/views/Search.vue b/apps/front-end/src/views/Search.vue new file mode 100644 index 0000000..20b517b --- /dev/null +++ b/apps/front-end/src/views/Search.vue @@ -0,0 +1,86 @@ + + + + + + diff --git a/apps/front-end/tsconfig.json b/apps/front-end/tsconfig.json new file mode 100644 index 0000000..9461a74 --- /dev/null +++ b/apps/front-end/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@fuyeor/tsconfig/base.json", + "compilerOptions": { + "paths": { + "@/*": ["./src/*"] + }, + "types": ["vite/client", "node"] + }, + "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"] +} diff --git a/apps/front-end/vite.config.js b/apps/front-end/vite.config.js new file mode 100644 index 0000000..eaad1fd --- /dev/null +++ b/apps/front-end/vite.config.js @@ -0,0 +1,27 @@ +// @fuyeor/fetch-front-end/vite.config.js +import { defineConfig } from 'vite'; +import { createViteConfig } from '@fuyeor/config/vite.config.js'; + +export default defineConfig(() => { + return createViteConfig( + { + server: { + host: '0.0.0.0', + port: 6040, + allowedHosts: ['fetch.localhost'], + proxy: { + '/v1': { + target: 'http://localhost:3000', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/v1/, ''), + }, + '/docs': { + target: 'http://localhost:3000', + changeOrigin: true, + }, + }, + }, + }, + import.meta.dirname, + ); +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..67aec24 --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "fpm-workspace", + "private": true, + "license": "MIT", + "author": "Fuyeor ", + "packageManager": "pnpm@11.22.0", + "scripts": { + "build": "pnpm -r build", + "lint": "pnpm -r lint", + "test": "vitest run", + "test:watch": "vitest", + "test:cov": "vitest run --coverage", + "test:ui": "vitest --ui", + "test:e2e": "vitest run --dir test/e2e --exclude test/unit", + "locale": "locale-cli" + }, + "devDependencies": { + "@fuyeor/locale-cli": "^0.1.1", + "@types/supertest": "^6.0.3", + "@vitest/coverage-v8": "^4.1.11", + "@vitest/ui": "^4.1.11", + "jsdom": "^30.0.1", + "supertest": "^7.2.2", + "typescript": "^7.0.2", + "vitest": "^4.1.11" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..b724bea --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,2814 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@fuyeor/locale-cli': + specifier: ^0.1.1 + version: 0.1.1 + '@types/supertest': + specifier: ^6.0.3 + version: 6.0.3 + '@vitest/coverage-v8': + specifier: ^4.1.11 + version: 4.1.11(vitest@4.1.11) + '@vitest/ui': + specifier: ^4.1.11 + version: 4.1.11(vitest@4.1.11) + jsdom: + specifier: ^30.0.1 + version: 30.0.1(@noble/hashes@1.8.0) + supertest: + specifier: ^7.2.2 + version: 7.2.2(supports-color@7.2.0) + typescript: + specifier: ^7.0.2 + version: 7.0.2 + vitest: + specifier: ^4.1.11 + version: 4.1.11(@types/node@26.3.0)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(jsdom@30.0.1(@noble/hashes@1.8.0))(vite@8.2.2(@types/node@26.3.0)(terser@5.51.0)) + + apps/front-end: + dependencies: + '@fuyeor/commons': + specifier: ^0.1.2 + version: 0.1.2(vue@3.5.41(typescript@7.0.2)) + '@fuyeor/config': + specifier: ^0.1.1 + version: 0.1.1(vite@8.0.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@26.3.0)(terser@5.51.0))(vue@3.5.41(typescript@7.0.2)) + '@fuyeor/interactify': + specifier: ^0.1.1 + version: 0.1.1(vue@3.5.41(typescript@7.0.2)) + '@fuyeor/locale': + specifier: ^0.1.0 + version: 0.1.0(vue@3.5.41(typescript@7.0.2)) + '@fuyeor/vue-query': + specifier: ^0.1.0 + version: 0.1.0(vue@3.5.41(typescript@7.0.2)) + '@fuyeor/vue-router': + specifier: ^0.0.9 + version: 0.0.9(vue@3.5.41(typescript@7.0.2)) + vue: + specifier: ^3.5.41 + version: 3.5.41(typescript@7.0.2) + devDependencies: + '@fuyeor/tsconfig': + specifier: 0.1.0 + version: 0.1.0 + '@types/node': + specifier: ^26.2.0 + version: 26.3.0 + '@vitejs/plugin-vue': + specifier: ^6.0.8 + version: 6.0.8(vite@8.0.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@26.3.0)(terser@5.51.0))(vue@3.5.41(typescript@7.0.2)) + html-minifier-terser: + specifier: ^7.2.0 + version: 7.2.0 + lightningcss: + specifier: ^1.33.0 + version: 1.33.0 + typescript: + specifier: ^7.0.2 + version: 7.0.2 + vite: + specifier: 8.0.1 + version: 8.0.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@26.3.0)(terser@5.51.0) + +packages: + + '@asamuzakjp/css-color@6.0.7': + resolution: {integrity: sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==} + engines: {node: ^22.13.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@8.3.2': + resolution: {integrity: sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==} + engines: {node: ^22.13.0 || >=24.0.0} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + + '@csstools/color-helpers@6.1.1': + resolution: {integrity: sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.3.0': + resolution: {integrity: sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.2.1': + resolution: {integrity: sha512-YpAJZhaHplYQkG8ib+/Fx5Y0eF2lVWi3tIvMJA6i39TLyUNp2439cifzW8VMjhlqrBjHzK5hVGugRRm2zTKI/A==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.9': + resolution: {integrity: sha512-iGGw4OsAYsS6pD29MdJ2bX/nJx65a04ZZiw6x+VwWlP2DdXf6f++Zmuv/OzALpdyfVhjbduIIF2cXM7HWBIe9A==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + + '@emnapi/core@1.11.3': + resolution: {integrity: sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==} + + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + + '@emnapi/wasi-threads@1.2.3': + resolution: {integrity: sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==} + + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + + '@floating-ui/core@1.8.0': + resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} + + '@floating-ui/dom@1.8.0': + resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==} + + '@floating-ui/utils@0.2.12': + resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + + '@floating-ui/vue@1.1.11': + resolution: {integrity: sha512-HzHKCNVxnGS35r9fCHBc3+uCnjw9IWIlCPL683cGgM9Kgj2BiAl8x1mS7vtvP6F9S/e/q4O6MApwSHj8hNLGfw==} + + '@fuyeor/commons@0.1.0': + resolution: {integrity: sha256-0ftDuLXPoQiLotiSRC2cmVF/+cViXO11ZV2pTpbkXbM=, tarball: https://fpm.fuyeor.net/packages/@fuyeor/commons/0.1.0.tgz} + peerDependencies: + vue: '*' + + '@fuyeor/commons@0.1.2': + resolution: {integrity: sha256-X1Ejy4MgCaO8iFkvph8SavvY2kFJtaCXdJ6OYG8ceJE=, tarball: https://fpm.fuyeor.net/packages/@fuyeor/commons/0.1.2.tgz} + peerDependencies: + vue: '*' + + '@fuyeor/config@0.1.1': + resolution: {integrity: sha256-YdN7bfe71NeQWfak1cpnKna7byyJ3PYhnBbqDFCDRVo=, tarball: https://fpm.fuyeor.net/packages/@fuyeor/config/0.1.1.tgz} + peerDependencies: + vite: ' ^8.0.0' + + '@fuyeor/interactify@0.1.1': + resolution: {integrity: sha256-rz+0xlSjMcNjFnmliyDT3TkB8Y08XLiuRiTfZz7FqTQ=, tarball: https://fpm.fuyeor.net/packages/@fuyeor/interactify/0.1.1.tgz} + peerDependencies: + vue: '*' + + '@fuyeor/locale-cli-linux-x64@0.1.1': + resolution: {integrity: sha256-yUuBN7NGq6kvNjAx+gK/FjSAAQIsQXdTrUOIPmDAlPU=, tarball: https://fpm.fuyeor.net/packages/@fuyeor/locale-cli-linux-x64/0.1.1.tgz} + cpu: [x64] + os: [linux] + + '@fuyeor/locale-cli-win32-x64@0.1.1': + resolution: {integrity: sha256-kMoqlLWBKOyK1QSmiu50OCPt+oqfFRX1CF702XSum94=, tarball: https://fpm.fuyeor.net/packages/@fuyeor/locale-cli-win32-x64/0.1.1.tgz} + cpu: [x64] + os: [win32] + + '@fuyeor/locale-cli@0.1.1': + resolution: {integrity: sha256-i4zBm0Akv/BHJaBQmt/kXHqNYcBcCVQYVFcLVqpO6Fw=, tarball: https://fpm.fuyeor.net/packages/@fuyeor/locale-cli/0.1.1.tgz} + hasBin: true + + '@fuyeor/locale@0.1.0': + resolution: {integrity: sha256-ph3M1g9OPWb8WVAt+W0NeoDJT2uhb9ALn/NnCN0wvZU=, tarball: https://fpm.fuyeor.net/packages/@fuyeor/locale/0.1.0.tgz} + peerDependencies: + vue: '*' + + '@fuyeor/tsconfig@0.1.0': + resolution: {integrity: sha256-j5XnwkpNS2BAC5xfOcxuE25IxO8TPT0XDjtQ7j8r2HU=, tarball: https://fpm.fuyeor.net/packages/@fuyeor/tsconfig/0.1.0.tgz} + + '@fuyeor/vue-query@0.1.0': + resolution: {integrity: sha256-3yyC1yKBTyL+VzhrXMUEjvdZZmaUhlnH9pyGHbzxutU=, tarball: https://fpm.fuyeor.net/packages/@fuyeor/vue-query/0.1.0.tgz} + peerDependencies: + vue: '*' + + '@fuyeor/vue-router@0.0.9': + resolution: {integrity: sha256-Hhbeyxi0tBIO7qPueEvCC1uzWdO2S3yCPZ8hPAQCMdM=, tarball: https://fpm.fuyeor.net/packages/@fuyeor/vue-router/0.0.9.tgz} + peerDependencies: + vue: '*' + + '@fuyeor/vue-router@0.1.0': + resolution: {integrity: sha256-+y1kO8p9TdcQaYtZFjc/PJBs+Yykhr8h8mPjbGzJ95k=, tarball: https://fpm.fuyeor.net/packages/@fuyeor/vue-router/0.1.0.tgz} + peerDependencies: + vue: '*' + + '@fuyeor/vue-vaul@0.4.8': + resolution: {integrity: sha256-0dlkmUCIbw4hFtYoMO678Si2tTOF3dfSVN8fWWau2Eg=, tarball: https://fpm.fuyeor.net/packages/@fuyeor/vue-vaul/0.4.8.tgz} + peerDependencies: + vue: ^3.4.0 + + '@internationalized/date@3.12.3': + resolution: {integrity: sha512-fuLX+3ZKLsxI73y8b01EG/WjHb6gE6weCqlfawPO27kBWGMh9G1yH6Csv1uU7/cac9H2GHmOMt6CjmuQ1aia4Q==} + + '@internationalized/number@3.6.7': + resolution: {integrity: sha512-3ji1fcrT+FPAK86UqEhB/psHixYo6niWPJtt7+qRaYFynt/BaJG8GhAPimtWUpEiVSTq8ZM8L5psMxGquiB/Vg==} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/source-map@0.3.11': + resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@napi-rs/wasm-runtime@1.2.3': + resolution: {integrity: sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + peerDependencies: + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + + '@oxc-project/types@0.120.0': + resolution: {integrity: sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg==} + + '@oxc-project/types@0.147.0': + resolution: {integrity: sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==} + + '@paralleldrive/cuid2@2.3.1': + resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} + + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + + '@rolldown/binding-android-arm-eabi@1.2.6': + resolution: {integrity: sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.0.0-rc.10': + resolution: {integrity: sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-android-arm64@1.2.6': + resolution: {integrity: sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.0-rc.10': + resolution: {integrity: sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-arm64@1.2.6': + resolution: {integrity: sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0-rc.10': + resolution: {integrity: sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.6': + resolution: {integrity: sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.0-rc.10': + resolution: {integrity: sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-freebsd-x64@1.2.6': + resolution: {integrity: sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.10': + resolution: {integrity: sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.6': + resolution: {integrity: sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.10': + resolution: {integrity: sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-gnu@1.2.6': + resolution: {integrity: sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.10': + resolution: {integrity: sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-arm64-musl@1.2.6': + resolution: {integrity: sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.10': + resolution: {integrity: sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-ppc64-gnu@1.2.6': + resolution: {integrity: sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.10': + resolution: {integrity: sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.6': + resolution: {integrity: sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.10': + resolution: {integrity: sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.6': + resolution: {integrity: sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.10': + resolution: {integrity: sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-x64-musl@1.2.6': + resolution: {integrity: sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.10': + resolution: {integrity: sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-openharmony-arm64@1.2.6': + resolution: {integrity: sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.10': + resolution: {integrity: sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.10': + resolution: {integrity: sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-arm64-msvc@1.2.6': + resolution: {integrity: sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.10': + resolution: {integrity: sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.6': + resolution: {integrity: sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0-rc.10': + resolution: {integrity: sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg==} + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@swc/helpers@0.5.23': + resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} + + '@tanstack/virtual-core@3.17.8': + resolution: {integrity: sha512-BfEvehNpOT75r5Ksc5xW6NZuXujTfb7nlSEyVu4XHG3gdxNg1KqXruWbDewXOUaUYIo4oRbSfkjIajz4MAT8tA==} + + '@tanstack/vue-virtual@3.13.36': + resolution: {integrity: sha512-gKpExv4RbB9luVG+SucTXoqPZv/gzu/Yvz6BNO+8kpNxJ2x+I/ulryzl5W9BRciahZGp5Tls3Dp5XP1ztVGbMw==} + peerDependencies: + vue: ^2.7.0 || ^3.0.0 + + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/cookiejar@2.1.5': + resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/methods@1.1.4': + resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} + + '@types/node@26.3.0': + resolution: {integrity: sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw==} + + '@types/superagent@8.1.11': + resolution: {integrity: sha512-KA7srSW/HENDtOw9DOqaFLgWuMqN9WgjEw62lh9dpvRaZDkhdOkazASd7X7i2eMUYLHa1U37ZttnePsH5zTDHw==} + + '@types/supertest@6.0.3': + resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==} + + '@types/web-bluetooth@0.0.21': + resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + + '@vitejs/plugin-vue@6.0.8': + resolution: {integrity: sha512-0ZjgOg7oO6farnNGup7yvoM/YXZV84OZxHAwtflItNa/6zzQyVb5LNxyea3FEKEX2XlagIKzrlH7wwxkKgtiew==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + vue: ^3.2.25 + + '@vitest/coverage-v8@4.1.11': + resolution: {integrity: sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==} + peerDependencies: + '@vitest/browser': 4.1.11 + vitest: 4.1.11 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/expect@4.1.11': + resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} + + '@vitest/mocker@4.1.11': + resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.11': + resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==} + + '@vitest/runner@4.1.11': + resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==} + + '@vitest/snapshot@4.1.11': + resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==} + + '@vitest/spy@4.1.11': + resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==} + + '@vitest/ui@4.1.11': + resolution: {integrity: sha512-r/rwyKoev21mWdRGSEkZOqkQ2BYy68mwjihg9M90nNRbf4NGrgzZ4cj6JNCEwlOGJkbKeMgsjlykvwKUbRr7gw==} + peerDependencies: + vitest: 4.1.11 + + '@vitest/utils@4.1.11': + resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} + + '@vue/compiler-core@3.5.41': + resolution: {integrity: sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg==} + + '@vue/compiler-dom@3.5.41': + resolution: {integrity: sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw==} + + '@vue/compiler-sfc@3.5.41': + resolution: {integrity: sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ==} + + '@vue/compiler-ssr@3.5.41': + resolution: {integrity: sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A==} + + '@vue/reactivity@3.5.41': + resolution: {integrity: sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA==} + + '@vue/runtime-core@3.5.41': + resolution: {integrity: sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg==} + + '@vue/runtime-dom@3.5.41': + resolution: {integrity: sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw==} + + '@vue/server-renderer@3.5.41': + resolution: {integrity: sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ==} + + '@vue/shared@3.5.41': + resolution: {integrity: sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==} + + '@vueuse/core@14.4.0': + resolution: {integrity: sha512-X4WHz1HlCzCBoYXesUkifzzWBAcZgXG8Fi5iNPQg/epdzOB3gu8Fawj3hvuwYR1nGcXGnvxwYYcUC/71++svtQ==} + peerDependencies: + vue: ^3.5.0 + + '@vueuse/metadata@14.4.0': + resolution: {integrity: sha512-swx/255R6JyHZFJhx845iz5CRWDZdCfvkZOpACWc5+c5WHcG24mv8gUT1WIdFQaHt6dq79rvILd9QnCWiyVm9g==} + + '@vueuse/shared@14.4.0': + resolution: {integrity: sha512-JRgY90Sz8DDtPMsaDflvPMp9xYk69JZAmbuDvAquUVXKr2gEjqtzGNTTthLfckH0BzBqvnu31gb4a8TGLRe79g==} + peerDependencies: + vue: ^3.5.0 + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + + asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-v8-to-istanbul@1.0.5: + resolution: {integrity: sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + camel-case@4.1.2: + resolution: {integrity: sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + clean-css@5.3.3: + resolution: {integrity: sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==} + engines: {node: '>= 10.0'} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + commander@10.0.1: + resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} + engines: {node: '>=14'} + + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + + component-emitter@1.3.1: + resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookiejar@2.1.4: + resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dezalgo@1.0.4: + resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} + + dot-case@3.0.4: + resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} + + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + + formidable@3.5.4: + resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==} + engines: {node: '>=14.0.0'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + html-minifier-terser@7.2.0: + resolution: {integrity: sha512-tXgn3QfqPIpGl9o+K5tpcj3/MN4SfLtsx2GWwBC3SSd0tXQGyF3gsSqad8loJgKZGM3ZxbYDd5yhiBIdWpmvLA==} + engines: {node: ^14.13.1 || >=16.0.0} + hasBin: true + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + + jsdom@30.0.1: + resolution: {integrity: sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + peerDependencies: + canvas: ^3.2.3 + peerDependenciesMeta: + canvas: + optional: true + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + linkify-it@5.0.2: + resolution: {integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==} + + lower-case@2.0.2: + resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} + + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magicast@0.5.4: + resolution: {integrity: sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + markdown-it@14.3.0: + resolution: {integrity: sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==} + hasBin: true + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + mdurl@2.1.0: + resolution: {integrity: sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==} + + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + no-case@3.0.4: + resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + + ohash@2.0.12: + resolution: {integrity: sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + param-case@3.0.4: + resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==} + + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + + pascal-case@3.1.2: + resolution: {integrity: sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + photoswipe@5.4.4: + resolution: {integrity: sha512-WNFHoKrkZNnvFFhbHL93WDkW3ifwVOXSW3w1UuZZelSmgXpIGiZSNlZJq37rR8YejqME2rHs9EhH9ZvlvFH2NA==} + engines: {node: '>= 0.12.0'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + engines: {node: ^10 || ^12 || >=14} + + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + qs@6.15.3: + resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + engines: {node: '>=0.6'} + + reka-ui@2.10.4: + resolution: {integrity: sha512-kbS5GAbkHkYj0EVKAg5ZPEPndhBjeUFSa1Aq5m5ftQwyHnB1v5tw8X6fcwfKH/ry3StPXblMWM1DW82MVS71cw==} + peerDependencies: + vue: '>= 3.4.0' + + relateurl@0.2.7: + resolution: {integrity: sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==} + engines: {node: '>= 0.10'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + rolldown@1.0.0-rc.10: + resolution: {integrity: sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + rolldown@1.2.6: + resolution: {integrity: sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + + superagent@10.3.0: + resolution: {integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==} + engines: {node: '>=14.18.0'} + + supertest@7.2.2: + resolution: {integrity: sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==} + engines: {node: '>=14.18.0'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + terser@5.51.0: + resolution: {integrity: sha512-myiQ6aFnxDOjdiXdTlC8ngVccQD88uHXTx5RUQOSEnarDYgJMjDagwAQszcOusjvmf4YqWsxoD1+MY+oAJRmpw==} + engines: {node: '>=10'} + hasBin: true + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.3.0: + resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + + tldts-core@7.4.11: + resolution: {integrity: sha512-CW3WN2rIIE/Of21mulhgnGOwoDyEFNygyIBOONSdyAuSATgMMUCpLeUlB+E8sAwA5xRV9hYPl+kyZ9citHCaKg==} + + tldts@7.4.11: + resolution: {integrity: sha512-aBiNayCfTQxuIJBm06M+xR14cYaYlDlSXZbgsnKzKNxDKUVq7KFwTjwBSsb7m9Y5xO8WfPnBc63WaYFMTGlvqw==} + hasBin: true + + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} + hasBin: true + + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + + undici@8.10.0: + resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==} + engines: {node: '>=22.19.0'} + + vite@8.0.1: + resolution: {integrity: sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.0 + esbuild: ^0.27.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vite@8.2.2: + resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.4.0 || ^0.5.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.11: + resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.11 + '@vitest/browser-preview': 4.1.11 + '@vitest/browser-webdriverio': 4.1.11 + '@vitest/coverage-istanbul': 4.1.11 + '@vitest/coverage-v8': 4.1.11 + '@vitest/ui': 4.1.11 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + vue-demi@0.14.10: + resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} + engines: {node: '>=12'} + hasBin: true + peerDependencies: + '@vue/composition-api': ^1.0.0-rc.1 + vue: ^3.0.0-0 || ^2.6.0 + peerDependenciesMeta: + '@vue/composition-api': + optional: true + + vue@3.5.41: + resolution: {integrity: sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + whatwg-url@17.1.0: + resolution: {integrity: sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==} + engines: {node: ^22.14.0 || >=24.0.0} + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + +snapshots: + + '@asamuzakjp/css-color@6.0.7': + dependencies: + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + lru-cache: 11.5.2 + + '@asamuzakjp/dom-selector@8.3.2': + dependencies: + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@bcoe/v8-coverage@1.0.2': {} + + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + + '@csstools/color-helpers@6.1.1': {} + + '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.1.1 + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.9(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + + '@emnapi/core@1.11.3': + dependencies: + '@emnapi/wasi-threads': 1.2.3 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@exodus/bytes@1.15.1(@noble/hashes@1.8.0)': + optionalDependencies: + '@noble/hashes': 1.8.0 + + '@floating-ui/core@1.8.0': + dependencies: + '@floating-ui/utils': 0.2.12 + + '@floating-ui/dom@1.8.0': + dependencies: + '@floating-ui/core': 1.8.0 + '@floating-ui/utils': 0.2.12 + + '@floating-ui/utils@0.2.12': {} + + '@floating-ui/vue@1.1.11(vue@3.5.41(typescript@7.0.2))': + dependencies: + '@floating-ui/dom': 1.8.0 + '@floating-ui/utils': 0.2.12 + vue-demi: 0.14.10(vue@3.5.41(typescript@7.0.2)) + transitivePeerDependencies: + - '@vue/composition-api' + - vue + + '@fuyeor/commons@0.1.0(vue@3.5.41(typescript@7.0.2))': + dependencies: + '@fuyeor/locale': 0.1.0(vue@3.5.41(typescript@7.0.2)) + '@fuyeor/vue-router': 0.1.0(vue@3.5.41(typescript@7.0.2)) + vue: 3.5.41(typescript@7.0.2) + + '@fuyeor/commons@0.1.2(vue@3.5.41(typescript@7.0.2))': + dependencies: + '@fuyeor/locale': 0.1.0(vue@3.5.41(typescript@7.0.2)) + '@fuyeor/vue-router': 0.0.9(vue@3.5.41(typescript@7.0.2)) + vue: 3.5.41(typescript@7.0.2) + + '@fuyeor/config@0.1.1(vite@8.0.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@26.3.0)(terser@5.51.0))(vue@3.5.41(typescript@7.0.2))': + dependencies: + '@vitejs/plugin-vue': 6.0.8(vite@8.0.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@26.3.0)(terser@5.51.0))(vue@3.5.41(typescript@7.0.2)) + html-minifier-terser: 7.2.0 + lightningcss: 1.33.0 + vite: 8.0.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@26.3.0)(terser@5.51.0) + transitivePeerDependencies: + - vue + + '@fuyeor/interactify@0.1.1(vue@3.5.41(typescript@7.0.2))': + dependencies: + '@fuyeor/commons': 0.1.0(vue@3.5.41(typescript@7.0.2)) + '@fuyeor/locale': 0.1.0(vue@3.5.41(typescript@7.0.2)) + '@fuyeor/vue-router': 0.0.9(vue@3.5.41(typescript@7.0.2)) + '@fuyeor/vue-vaul': 0.4.8(vue@3.5.41(typescript@7.0.2)) + markdown-it: 14.3.0 + photoswipe: 5.4.4 + vue: 3.5.41(typescript@7.0.2) + transitivePeerDependencies: + - '@vue/composition-api' + + '@fuyeor/locale-cli-linux-x64@0.1.1': + optional: true + + '@fuyeor/locale-cli-win32-x64@0.1.1': + optional: true + + '@fuyeor/locale-cli@0.1.1': + optionalDependencies: + '@fuyeor/locale-cli-linux-x64': 0.1.1 + '@fuyeor/locale-cli-win32-x64': 0.1.1 + + '@fuyeor/locale@0.1.0(vue@3.5.41(typescript@7.0.2))': + dependencies: + vue: 3.5.41(typescript@7.0.2) + + '@fuyeor/tsconfig@0.1.0': {} + + '@fuyeor/vue-query@0.1.0(vue@3.5.41(typescript@7.0.2))': + dependencies: + vue: 3.5.41(typescript@7.0.2) + + '@fuyeor/vue-router@0.0.9(vue@3.5.41(typescript@7.0.2))': + dependencies: + vue: 3.5.41(typescript@7.0.2) + + '@fuyeor/vue-router@0.1.0(vue@3.5.41(typescript@7.0.2))': + dependencies: + vue: 3.5.41(typescript@7.0.2) + + '@fuyeor/vue-vaul@0.4.8(vue@3.5.41(typescript@7.0.2))': + dependencies: + reka-ui: 2.10.4(vue@3.5.41(typescript@7.0.2)) + vue: 3.5.41(typescript@7.0.2) + transitivePeerDependencies: + - '@vue/composition-api' + + '@internationalized/date@3.12.3': + dependencies: + '@swc/helpers': 0.5.23 + + '@internationalized/number@3.6.7': + dependencies: + '@swc/helpers': 0.5.23 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/source-map@0.3.11': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)': + dependencies: + '@emnapi/core': 1.11.3 + '@emnapi/runtime': 1.11.3 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@noble/hashes@1.8.0': {} + + '@oxc-project/types@0.120.0': {} + + '@oxc-project/types@0.147.0': {} + + '@paralleldrive/cuid2@2.3.1': + dependencies: + '@noble/hashes': 1.8.0 + + '@polka/url@1.0.0-next.29': {} + + '@rolldown/binding-android-arm-eabi@1.2.6': + optional: true + + '@rolldown/binding-android-arm64@1.0.0-rc.10': + optional: true + + '@rolldown/binding-android-arm64@1.2.6': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-rc.10': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.6': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-rc.10': + optional: true + + '@rolldown/binding-darwin-x64@1.2.6': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-rc.10': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.6': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.10': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.6': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.10': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.6': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.10': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.6': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.10': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.6': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.10': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.6': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.10': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.6': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.10': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.6': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.10': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.6': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.10(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)': + dependencies: + '@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.10': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.6': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.10': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.6': + optional: true + + '@rolldown/pluginutils@1.0.0-rc.10': {} + + '@rolldown/pluginutils@1.0.1': {} + + '@standard-schema/spec@1.1.0': {} + + '@swc/helpers@0.5.23': + dependencies: + tslib: 2.8.1 + + '@tanstack/virtual-core@3.17.8': {} + + '@tanstack/vue-virtual@3.13.36(vue@3.5.41(typescript@7.0.2))': + dependencies: + '@tanstack/virtual-core': 3.17.8 + vue: 3.5.41(typescript@7.0.2) + + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/cookiejar@2.1.5': {} + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/methods@1.1.4': {} + + '@types/node@26.3.0': + dependencies: + undici-types: 8.3.0 + + '@types/superagent@8.1.11': + dependencies: + '@types/cookiejar': 2.1.5 + '@types/methods': 1.1.4 + '@types/node': 26.3.0 + form-data: 4.0.6 + + '@types/supertest@6.0.3': + dependencies: + '@types/methods': 1.1.4 + '@types/superagent': 8.1.11 + + '@types/web-bluetooth@0.0.21': {} + + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true + + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true + + '@typescript/typescript-darwin-x64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm@7.0.2': + optional: true + + '@typescript/typescript-linux-loong64@7.0.2': + optional: true + + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true + + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + + '@vitejs/plugin-vue@6.0.8(vite@8.0.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@26.3.0)(terser@5.51.0))(vue@3.5.41(typescript@7.0.2))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.0.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@26.3.0)(terser@5.51.0) + vue: 3.5.41(typescript@7.0.2) + + '@vitest/coverage-v8@4.1.11(vitest@4.1.11)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.11 + ast-v8-to-istanbul: 1.0.5 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.4 + obug: 2.1.4 + std-env: 4.2.0 + tinyrainbow: 3.1.1 + vitest: 4.1.11(@types/node@26.3.0)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(jsdom@30.0.1(@noble/hashes@1.8.0))(vite@8.2.2(@types/node@26.3.0)(terser@5.51.0)) + + '@vitest/expect@4.1.11': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + chai: 6.2.2 + tinyrainbow: 3.1.1 + + '@vitest/mocker@4.1.11(vite@8.2.2(@types/node@26.3.0)(terser@5.51.0))': + dependencies: + '@vitest/spy': 4.1.11 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.2.2(@types/node@26.3.0)(terser@5.51.0) + + '@vitest/pretty-format@4.1.11': + dependencies: + tinyrainbow: 3.1.1 + + '@vitest/runner@4.1.11': + dependencies: + '@vitest/utils': 4.1.11 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + '@vitest/utils': 4.1.11 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.11': {} + + '@vitest/ui@4.1.11(vitest@4.1.11)': + dependencies: + '@vitest/utils': 4.1.11 + fflate: 0.8.3 + flatted: 3.4.4 + pathe: 2.0.3 + sirv: 3.0.2 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vitest: 4.1.11(@types/node@26.3.0)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(jsdom@30.0.1(@noble/hashes@1.8.0))(vite@8.2.2(@types/node@26.3.0)(terser@5.51.0)) + + '@vitest/utils@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + + '@vue/compiler-core@3.5.41': + dependencies: + '@babel/parser': 7.29.8 + '@vue/shared': 3.5.41 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.41': + dependencies: + '@vue/compiler-core': 3.5.41 + '@vue/shared': 3.5.41 + + '@vue/compiler-sfc@3.5.41': + dependencies: + '@babel/parser': 7.29.8 + '@vue/compiler-core': 3.5.41 + '@vue/compiler-dom': 3.5.41 + '@vue/compiler-ssr': 3.5.41 + '@vue/shared': 3.5.41 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.26 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.41': + dependencies: + '@vue/compiler-dom': 3.5.41 + '@vue/shared': 3.5.41 + + '@vue/reactivity@3.5.41': + dependencies: + '@vue/shared': 3.5.41 + + '@vue/runtime-core@3.5.41': + dependencies: + '@vue/reactivity': 3.5.41 + '@vue/shared': 3.5.41 + + '@vue/runtime-dom@3.5.41': + dependencies: + '@vue/reactivity': 3.5.41 + '@vue/runtime-core': 3.5.41 + '@vue/shared': 3.5.41 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.41': + dependencies: + '@vue/compiler-ssr': 3.5.41 + '@vue/runtime-dom': 3.5.41 + '@vue/shared': 3.5.41 + + '@vue/shared@3.5.41': {} + + '@vueuse/core@14.4.0(vue@3.5.41(typescript@7.0.2))': + dependencies: + '@types/web-bluetooth': 0.0.21 + '@vueuse/metadata': 14.4.0 + '@vueuse/shared': 14.4.0(vue@3.5.41(typescript@7.0.2)) + vue: 3.5.41(typescript@7.0.2) + + '@vueuse/metadata@14.4.0': {} + + '@vueuse/shared@14.4.0(vue@3.5.41(typescript@7.0.2))': + dependencies: + vue: 3.5.41(typescript@7.0.2) + + acorn@8.18.0: {} + + argparse@2.0.1: {} + + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + + asap@2.0.6: {} + + assertion-error@2.0.1: {} + + ast-v8-to-istanbul@1.0.5: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + + asynckit@0.4.0: {} + + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + + buffer-from@1.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + camel-case@4.1.2: + dependencies: + pascal-case: 3.1.2 + tslib: 2.8.1 + + chai@6.2.2: {} + + clean-css@5.3.3: + dependencies: + source-map: 0.6.1 + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@10.0.1: {} + + commander@2.20.3: {} + + component-emitter@1.3.1: {} + + convert-source-map@2.0.0: {} + + cookie-signature@1.2.2: {} + + cookiejar@2.1.4: {} + + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + csstype@3.2.3: {} + + data-urls@7.0.0(@noble/hashes@1.8.0): + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1(@noble/hashes@1.8.0) + transitivePeerDependencies: + - '@noble/hashes' + + debug@4.4.3(supports-color@7.2.0): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 7.2.0 + + decimal.js@10.6.0: {} + + defu@6.1.7: {} + + delayed-stream@1.0.0: {} + + detect-libc@2.1.2: {} + + dezalgo@1.0.4: + dependencies: + asap: 2.0.6 + wrappy: 1.0.2 + + dot-case@3.0.4: + dependencies: + no-case: 3.0.4 + tslib: 2.8.1 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + entities@4.5.0: {} + + entities@7.0.1: {} + + entities@8.0.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@2.3.2: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + estree-walker@2.0.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + + fast-safe-stringify@2.1.1: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fflate@0.8.3: {} + + flatted@3.4.4: {} + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + formidable@3.5.4: + dependencies: + '@paralleldrive/cuid2': 2.3.1 + dezalgo: 1.0.4 + once: 1.4.0 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + gopd@1.2.0: {} + + has-flag@4.0.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + html-encoding-sniffer@6.0.0(@noble/hashes@1.8.0): + dependencies: + '@exodus/bytes': 1.15.1(@noble/hashes@1.8.0) + transitivePeerDependencies: + - '@noble/hashes' + + html-escaper@2.0.2: {} + + html-minifier-terser@7.2.0: + dependencies: + camel-case: 4.1.2 + clean-css: 5.3.3 + commander: 10.0.1 + entities: 4.5.0 + param-case: 3.0.4 + relateurl: 0.2.7 + terser: 5.51.0 + + is-potential-custom-element-name@1.0.1: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + js-tokens@10.0.0: {} + + jsdom@30.0.1(@noble/hashes@1.8.0): + dependencies: + '@asamuzakjp/css-color': 6.0.7 + '@asamuzakjp/dom-selector': 8.3.2 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.9(css-tree@3.2.1) + '@exodus/bytes': 1.15.1(@noble/hashes@1.8.0) + css-tree: 3.2.1 + data-urls: 7.0.0(@noble/hashes@1.8.0) + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0(@noble/hashes@1.8.0) + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.2 + undici: 8.10.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 17.1.0(@noble/hashes@1.8.0) + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + linkify-it@5.0.2: + dependencies: + uc.micro: 2.1.0 + + lower-case@2.0.2: + dependencies: + tslib: 2.8.1 + + lru-cache@11.5.2: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magicast@0.5.4: + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.8.5 + + markdown-it@14.3.0: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.2 + mdurl: 2.1.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + + math-intrinsics@1.1.0: {} + + mdn-data@2.27.1: {} + + mdurl@2.1.0: {} + + methods@1.1.2: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime@2.6.0: {} + + mrmime@2.0.1: {} + + ms@2.1.3: {} + + nanoid@3.3.18: {} + + no-case@3.0.4: + dependencies: + lower-case: 2.0.2 + tslib: 2.8.1 + + object-inspect@1.13.4: {} + + obug@2.1.4: {} + + ohash@2.0.12: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + param-case@3.0.4: + dependencies: + dot-case: 3.0.4 + tslib: 2.8.1 + + parse5@8.0.1: + dependencies: + entities: 8.0.0 + + pascal-case@3.1.2: + dependencies: + no-case: 3.0.4 + tslib: 2.8.1 + + pathe@2.0.3: {} + + photoswipe@5.4.4: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + postcss@8.5.26: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + punycode.js@2.3.1: {} + + punycode@2.3.1: {} + + qs@6.15.3: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + reka-ui@2.10.4(vue@3.5.41(typescript@7.0.2)): + dependencies: + '@floating-ui/dom': 1.8.0 + '@floating-ui/vue': 1.1.11(vue@3.5.41(typescript@7.0.2)) + '@internationalized/date': 3.12.3 + '@internationalized/number': 3.6.7 + '@tanstack/vue-virtual': 3.13.36(vue@3.5.41(typescript@7.0.2)) + '@vueuse/core': 14.4.0(vue@3.5.41(typescript@7.0.2)) + '@vueuse/shared': 14.4.0(vue@3.5.41(typescript@7.0.2)) + aria-hidden: 1.2.6 + defu: 6.1.7 + ohash: 2.0.12 + vue: 3.5.41(typescript@7.0.2) + transitivePeerDependencies: + - '@vue/composition-api' + + relateurl@0.2.7: {} + + require-from-string@2.0.2: {} + + rolldown@1.0.0-rc.10(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3): + dependencies: + '@oxc-project/types': 0.120.0 + '@rolldown/pluginutils': 1.0.0-rc.10 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-rc.10 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.10 + '@rolldown/binding-darwin-x64': 1.0.0-rc.10 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.10 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.10 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.10 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.10 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.10 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.10 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.10 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.10 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.10 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.10(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.10 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.10 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + rolldown@1.2.6: + dependencies: + '@oxc-project/types': 0.147.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.6 + '@rolldown/binding-android-arm64': 1.2.6 + '@rolldown/binding-darwin-arm64': 1.2.6 + '@rolldown/binding-darwin-x64': 1.2.6 + '@rolldown/binding-freebsd-x64': 1.2.6 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.6 + '@rolldown/binding-linux-arm64-gnu': 1.2.6 + '@rolldown/binding-linux-arm64-musl': 1.2.6 + '@rolldown/binding-linux-ppc64-gnu': 1.2.6 + '@rolldown/binding-linux-s390x-gnu': 1.2.6 + '@rolldown/binding-linux-x64-gnu': 1.2.6 + '@rolldown/binding-linux-x64-musl': 1.2.6 + '@rolldown/binding-openharmony-arm64': 1.2.6 + '@rolldown/binding-win32-arm64-msvc': 1.2.6 + '@rolldown/binding-win32-x64-msvc': 1.2.6 + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + semver@7.8.5: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + siginfo@2.0.0: {} + + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + + source-map-js@1.2.1: {} + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + stackback@0.0.2: {} + + std-env@4.2.0: {} + + superagent@10.3.0(supports-color@7.2.0): + dependencies: + component-emitter: 1.3.1 + cookiejar: 2.1.4 + debug: 4.4.3(supports-color@7.2.0) + fast-safe-stringify: 2.1.1 + form-data: 4.0.6 + formidable: 3.5.4 + methods: 1.1.2 + mime: 2.6.0 + qs: 6.15.3 + transitivePeerDependencies: + - supports-color + + supertest@7.2.2(supports-color@7.2.0): + dependencies: + cookie-signature: 1.2.2 + methods: 1.1.2 + superagent: 10.3.0(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + symbol-tree@3.2.4: {} + + terser@5.51.0: + dependencies: + '@jridgewell/source-map': 0.3.11 + acorn: 8.18.0 + commander: 2.20.3 + source-map-support: 0.5.21 + + tinybench@2.9.0: {} + + tinyexec@1.3.0: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tinyrainbow@3.1.1: {} + + tldts-core@7.4.11: {} + + tldts@7.4.11: + dependencies: + tldts-core: 7.4.11 + + totalist@3.0.1: {} + + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.11 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + + tslib@2.8.1: {} + + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 + + uc.micro@2.1.0: {} + + undici-types@8.3.0: {} + + undici@8.10.0: {} + + vite@8.0.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@26.3.0)(terser@5.51.0): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.26 + rolldown: 1.0.0-rc.10(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.3.0 + fsevents: 2.3.3 + terser: 5.51.0 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + vite@8.2.2(@types/node@26.3.0)(terser@5.51.0): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.26 + rolldown: 1.2.6 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.3.0 + fsevents: 2.3.3 + terser: 5.51.0 + + vitest@4.1.11(@types/node@26.3.0)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(jsdom@30.0.1(@noble/hashes@1.8.0))(vite@8.2.2(@types/node@26.3.0)(terser@5.51.0)): + dependencies: + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@26.3.0)(terser@5.51.0)) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.7 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 8.2.2(@types/node@26.3.0)(terser@5.51.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 26.3.0 + '@vitest/coverage-v8': 4.1.11(vitest@4.1.11) + '@vitest/ui': 4.1.11(vitest@4.1.11) + jsdom: 30.0.1(@noble/hashes@1.8.0) + transitivePeerDependencies: + - msw + + vue-demi@0.14.10(vue@3.5.41(typescript@7.0.2)): + dependencies: + vue: 3.5.41(typescript@7.0.2) + + vue@3.5.41(typescript@7.0.2): + dependencies: + '@vue/compiler-dom': 3.5.41 + '@vue/compiler-sfc': 3.5.41 + '@vue/runtime-dom': 3.5.41 + '@vue/server-renderer': 3.5.41 + '@vue/shared': 3.5.41 + optionalDependencies: + typescript: 7.0.2 + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1(@noble/hashes@1.8.0): + dependencies: + '@exodus/bytes': 1.15.1(@noble/hashes@1.8.0) + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + whatwg-url@17.1.0(@noble/hashes@1.8.0): + dependencies: + '@exodus/bytes': 1.15.1(@noble/hashes@1.8.0) + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + wrappy@1.0.2: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..6641584 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,10 @@ +# /pnpm-workspace.yaml +packages: + - 'apps/front-end' + +allowBuilds: + '@prisma/client': true + '@prisma/engines': true + esbuild: true + prisma: true + vue-demi: true From afb4c8a003a55435ac654a9bd00ac29685d6d655 Mon Sep 17 00:00:00 2001 From: Manus AI Date: Thu, 27 Aug 2026 20:42:53 +0000 Subject: [PATCH 4/9] fix: Restore engine fixtures after directory move --- .gitignore | 5 +- apps/engine/.fetch-index/.managed.json | 1 - apps/engine/.fetch-index/.tantivy-meta.lock | 0 apps/engine/.fetch-index/.tantivy-writer.lock | 0 apps/engine/.fetch-index/meta.json | 88 ------------------- apps/engine/src/fon.rs | 2 +- apps/engine/src/web.rs | 2 +- 7 files changed, 6 insertions(+), 92 deletions(-) delete mode 100644 apps/engine/.fetch-index/.managed.json delete mode 100644 apps/engine/.fetch-index/.tantivy-meta.lock delete mode 100644 apps/engine/.fetch-index/.tantivy-writer.lock delete mode 100644 apps/engine/.fetch-index/meta.json diff --git a/.gitignore b/.gitignore index 42e6ff9..50d6927 100644 --- a/.gitignore +++ b/.gitignore @@ -62,4 +62,7 @@ # ======================== .DS_Store -Thumbs.db \ No newline at end of file +Thumbs.db + +# Tantivy runtime state +/apps/engine/.fetch-index/ diff --git a/apps/engine/.fetch-index/.managed.json b/apps/engine/.fetch-index/.managed.json deleted file mode 100644 index 1b67f4e..0000000 --- a/apps/engine/.fetch-index/.managed.json +++ /dev/null @@ -1 +0,0 @@ -["meta.json"] diff --git a/apps/engine/.fetch-index/.tantivy-meta.lock b/apps/engine/.fetch-index/.tantivy-meta.lock deleted file mode 100644 index e69de29..0000000 diff --git a/apps/engine/.fetch-index/.tantivy-writer.lock b/apps/engine/.fetch-index/.tantivy-writer.lock deleted file mode 100644 index e69de29..0000000 diff --git a/apps/engine/.fetch-index/meta.json b/apps/engine/.fetch-index/meta.json deleted file mode 100644 index e7a4d1f..0000000 --- a/apps/engine/.fetch-index/meta.json +++ /dev/null @@ -1,88 +0,0 @@ -{ - "index_settings": { - "docstore_compression": "lz4", - "docstore_blocksize": 16384 - }, - "segments": [], - "schema": [ - { - "name": "url", - "type": "text", - "options": { - "indexing": { - "record": "basic", - "fieldnorms": true, - "tokenizer": "raw" - }, - "stored": true, - "fast": false - } - }, - { - "name": "title", - "type": "text", - "options": { - "indexing": { - "record": "position", - "fieldnorms": true, - "tokenizer": "jieba_search" - }, - "stored": true, - "fast": false - } - }, - { - "name": "content", - "type": "text", - "options": { - "indexing": { - "record": "position", - "fieldnorms": true, - "tokenizer": "jieba_search" - }, - "stored": true, - "fast": false - } - }, - { - "name": "updated_at", - "type": "text", - "options": { - "indexing": { - "record": "basic", - "fieldnorms": true, - "tokenizer": "raw" - }, - "stored": true, - "fast": false - } - }, - { - "name": "image_url", - "type": "text", - "options": { - "indexing": { - "record": "basic", - "fieldnorms": true, - "tokenizer": "raw" - }, - "stored": true, - "fast": false - } - }, - { - "name": "graph", - "type": "text", - "options": { - "indexing": { - "record": "basic", - "fieldnorms": true, - "tokenizer": "raw" - }, - "stored": true, - "fast": false - } - } - ], - "opstamp": 0 -} diff --git a/apps/engine/src/fon.rs b/apps/engine/src/fon.rs index 9f289ee..9ff27b8 100644 --- a/apps/engine/src/fon.rs +++ b/apps/engine/src/fon.rs @@ -488,7 +488,7 @@ mod tests { #[test] fn loads_repository_mock_catalog() { - let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../.."); + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); let catalog = load_catalog(&root).expect("repository mock catalog must load"); assert_eq!(catalog.mappings.len(), 3); assert_eq!(catalog.pages.len(), 5); diff --git a/apps/engine/src/web.rs b/apps/engine/src/web.rs index a82992f..22ab55f 100644 --- a/apps/engine/src/web.rs +++ b/apps/engine/src/web.rs @@ -149,7 +149,7 @@ mod tests { let index_dir = tempfile::tempdir().expect("temporary index directory must be created"); let engine = Arc::new(SearchEngine::open(index_dir.path()).expect("temporary index must open")); - let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../.."); + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); let app = router(AppState::new(engine, root)); let rebuild = app From 25a725903cc5247eb99a23d5a0c956146c5b6d65 Mon Sep 17 00:00:00 2001 From: Manus AI Date: Thu, 27 Aug 2026 22:37:16 +0000 Subject: [PATCH 5/9] feat: Add stable search document generations --- Cargo.lock | 74 +++++- apps/engine/Cargo.toml | 5 +- apps/engine/src/config.rs | 36 +++ apps/engine/src/core/ast.rs | 203 +++++++++++++++ apps/engine/src/core/document.rs | 156 ++++++++++++ apps/engine/src/core/mod.rs | 5 + apps/engine/src/core/pattern.rs | 195 +++++++++++++++ apps/engine/src/fon.rs | 389 +++++++---------------------- apps/engine/src/index/mod.rs | 9 + apps/engine/src/index/reader.rs | 12 + apps/engine/src/index/schema.rs | 80 ++++++ apps/engine/src/index/writer.rs | 58 +++++ apps/engine/src/main.rs | 28 +-- apps/engine/src/model.rs | 26 +- apps/engine/src/search.rs | 272 ++++++++++++++------ apps/engine/src/web.rs | 46 +++- apps/front-end/src/types/search.ts | 5 + web/index.html | 201 --------------- well-known/search-patterns.fon | 3 + 19 files changed, 1193 insertions(+), 610 deletions(-) create mode 100644 apps/engine/src/config.rs create mode 100644 apps/engine/src/core/ast.rs create mode 100644 apps/engine/src/core/document.rs create mode 100644 apps/engine/src/core/mod.rs create mode 100644 apps/engine/src/core/pattern.rs create mode 100644 apps/engine/src/index/mod.rs create mode 100644 apps/engine/src/index/reader.rs create mode 100644 apps/engine/src/index/schema.rs create mode 100644 apps/engine/src/index/writer.rs delete mode 100644 web/index.html diff --git a/Cargo.lock b/Cargo.lock index 9545e09..03d757d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -128,6 +128,15 @@ dependencies = [ "crunchy", ] +[[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 = "bon" version = "3.10.0" @@ -208,6 +217,15 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.1" @@ -257,6 +275,16 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[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 = "darling" version = "0.24.1" @@ -312,6 +340,16 @@ dependencies = [ "serde_core", ] +[[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 = "downcast-rs" version = "2.0.2" @@ -364,7 +402,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] -name = "fetch-backend" +name = "fetch-engine" version = "0.1.0" dependencies = [ "axum", @@ -372,6 +410,7 @@ dependencies = [ "jieba-rs", "serde", "serde_json", + "sha2", "tantivy", "tempfile", "tokio", @@ -465,6 +504,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.4.3" @@ -1148,6 +1197,17 @@ dependencies = [ "serde", ] +[[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 = "shlex" version = "2.0.1" @@ -1521,6 +1581,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "typetag" version = "0.2.23" @@ -1569,6 +1635,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" diff --git a/apps/engine/Cargo.toml b/apps/engine/Cargo.toml index fa188fd..e093948 100644 --- a/apps/engine/Cargo.toml +++ b/apps/engine/Cargo.toml @@ -1,6 +1,6 @@ -# apps/fetch/back-end/Cargo.toml +# apps/engine/Cargo.toml [package] -name = "fetch-backend" +name = "fetch-engine" version = "0.1.0" edition = "2024" rust-version = "1.86" @@ -12,6 +12,7 @@ fon-parser = { git = "https://github.com/Fuyeor/fon-parser.git", branch = "main" jieba-rs = "0.10.3" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +sha2 = "0.10" tantivy = "0.26.1" tokio = { version = "1.48", features = ["macros", "net", "rt-multi-thread"] } diff --git a/apps/engine/src/config.rs b/apps/engine/src/config.rs new file mode 100644 index 0000000..58e3d9e --- /dev/null +++ b/apps/engine/src/config.rs @@ -0,0 +1,36 @@ +// apps/engine/src/config.rs + +use std::net::SocketAddr; +use std::path::PathBuf; + +pub type ConfigResult = Result>; + +/// Runtime configuration loaded from environment variables with deterministic defaults. +#[derive(Debug, Clone)] +pub struct Config { + pub data_root: PathBuf, + pub index_root: PathBuf, + pub bind_address: SocketAddr, +} + +impl Config { + /// Load engine configuration without hiding malformed environment values. + pub fn from_env() -> ConfigResult { + let data_root = env_path("FETCH_DATA_ROOT", ".")?; + let index_root = env_path("FETCH_INDEX_ROOT", ".fetch-index")?; + let bind_address = std::env::var("FETCH_BIND") + .unwrap_or_else(|_| "127.0.0.1:3000".to_string()) + .parse()?; + Ok(Self { + data_root, + index_root, + bind_address, + }) + } +} + +fn env_path(name: &str, default: &str) -> ConfigResult { + Ok(std::env::var_os(name) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(default))) +} diff --git a/apps/engine/src/core/ast.rs b/apps/engine/src/core/ast.rs new file mode 100644 index 0000000..262f030 --- /dev/null +++ b/apps/engine/src/core/ast.rs @@ -0,0 +1,203 @@ +// apps/engine/src/core/ast.rs + +use std::collections::BTreeMap; +use std::path::Path; + +use fon_parser::{Ast, Member, StringPartKind, Value, parse}; + +pub type CoreResult = Result>; + +/// Convert one FON source document into the supported object or array runtime value. +pub fn parse_root_value(source: &str, path: &Path) -> CoreResult { + let result = parse(source); + if result.has_errors() { + return Err(format!("invalid FON {}: {:?}", path.display(), result.diagnostics).into()); + } + if let Some(items) = result.document.ast.root_array_items() { + let values = items + .iter() + .map(|id| value_to_runtime(&result.document.ast, *id)) + .collect::>>()?; + return Ok(RuntimeValue::Array(values)); + } + let members = result + .document + .ast + .object_members() + .ok_or_else(|| format!("{} has no supported root", path.display()))?; + let mut object = BTreeMap::new(); + for member_id in members { + let member = result + .document + .ast + .member(*member_id) + .ok_or_else(|| "invalid member reference".to_string())?; + let binding = member + .binding() + .ok_or_else(|| "data objects cannot contain type declarations".to_string())?; + let value = value_to_runtime(&result.document.ast, binding.value)?; + if object.insert(binding.key.raw.clone(), value).is_some() { + return Err(format!("duplicate FON field: {}", binding.key.raw).into()); + } + } + Ok(RuntimeValue::Object(object)) +} + +/// Extract a typed string field from an object member list. +pub fn object_string_field( + ast: &Ast, + members: &[fon_parser::MemberId], + name: &str, +) -> CoreResult> { + let Some(value) = object_field_value(ast, members, name) else { + return Ok(None); + }; + match value { + Value::String(value) => Ok(Some(unquote_string(&value.raw))), + _ => Err(format!("{name} must be a FON string").into()), + } +} + +/// Extract a path-like or string field from an object member list. +pub fn object_path_field( + ast: &Ast, + members: &[fon_parser::MemberId], + name: &str, +) -> CoreResult> { + let Some(value) = object_field_value(ast, members, name) else { + return Ok(None); + }; + match value { + Value::Unknown(value) => Ok(Some(value.raw.clone())), + Value::String(value) => Ok(Some(unquote_string(&value.raw))), + _ => Err(format!("{name} must be a FON path or string").into()), + } +} + +/// Find a value by its raw FON member key. +pub fn object_field_value<'a>( + ast: &'a Ast, + members: &[fon_parser::MemberId], + name: &str, +) -> Option<&'a Value> { + members.iter().find_map(|id| { + let binding = ast.member(*id).and_then(Member::binding)?; + (binding.key.raw == name) + .then(|| ast.value(binding.value)) + .flatten() + }) +} + +/// Convert parser values into the small, explicit runtime representation used by the engine. +pub fn value_to_runtime(ast: &Ast, value_id: fon_parser::ValueId) -> CoreResult { + let value = ast + .value(value_id) + .ok_or_else(|| "invalid value reference".to_string())?; + match value { + Value::Boolean { value, .. } => Ok(RuntimeValue::Boolean(*value)), + Value::Number { raw, .. } => Ok(RuntimeValue::Number(raw.clone())), + Value::String(value) => { + if value + .parts + .iter() + .any(|part| part.kind == StringPartKind::Interpolation) + { + return Err("FON interpolation is not allowed in data files".into()); + } + Ok(RuntimeValue::String(unquote_string(&value.raw))) + } + Value::EnumPath(value) => Ok(RuntimeValue::Enum( + value.path.trim_start_matches('.').to_string(), + )), + Value::Array(value) => Ok(RuntimeValue::Array( + value + .items + .iter() + .map(|id| value_to_runtime(ast, *id)) + .collect::>>()?, + )), + Value::Object(value) => { + let mut object = BTreeMap::new(); + for member_id in &value.members { + let member = ast + .member(*member_id) + .ok_or_else(|| "invalid nested member reference".to_string())?; + let binding = member.binding().ok_or_else(|| { + "nested data objects cannot contain type declarations".to_string() + })?; + let nested = value_to_runtime(ast, binding.value)?; + if object.insert(binding.key.raw.clone(), nested).is_some() { + return Err(format!("duplicate FON field: {}", binding.key.raw).into()); + } + } + Ok(RuntimeValue::Object(object)) + } + Value::Unknown(value) => Ok(RuntimeValue::Unknown(value.raw.clone())), + Value::Regex(_) | Value::Schema(_) | Value::Expression(_) | Value::Error(_) => { + Err("unsupported executable or schema value in data file".into()) + } + } +} + +/// Decode the FON string envelope and only the escape sequences supported by the data layer. +pub fn unquote_string(raw: &str) -> String { + let value = raw + .strip_prefix('`') + .and_then(|value| value.strip_suffix('`')) + .unwrap_or(raw); + let mut output = String::with_capacity(value.len()); + let mut chars = value.chars(); + while let Some(character) = chars.next() { + if character != '\\' { + output.push(character); + continue; + } + match chars.next() { + Some('n') => output.push('\n'), + Some('r') => output.push('\r'), + Some('t') => output.push('\t'), + Some('`') => output.push('`'), + Some('\\') => output.push('\\'), + Some(other) => { + output.push('\\'); + output.push(other); + } + None => output.push('\\'), + } + } + output +} + +/// Runtime values accepted by sitemap bindings and content metadata. +#[derive(Debug, Clone)] +pub enum RuntimeValue { + Boolean(bool), + Number(String), + String(String), + Enum(String), + Array(Vec), + Object(BTreeMap), + Unknown(String), +} + +impl RuntimeValue { + /// Return a scalar value as text while rejecting compound values by returning an empty string. + pub fn as_string(&self) -> String { + match self { + Self::Boolean(value) => value.to_string(), + Self::Number(value) + | Self::String(value) + | Self::Enum(value) + | Self::Unknown(value) => value.clone(), + Self::Array(_) | Self::Object(_) => String::new(), + } + } + + /// Borrow an array when the value is a collection. + pub fn as_array(&self) -> Option<&[RuntimeValue]> { + match self { + Self::Array(values) => Some(values), + _ => None, + } + } +} diff --git a/apps/engine/src/core/document.rs b/apps/engine/src/core/document.rs new file mode 100644 index 0000000..d018ad8 --- /dev/null +++ b/apps/engine/src/core/document.rs @@ -0,0 +1,156 @@ +// apps/engine/src/core/document.rs + +use sha2::{Digest, Sha256}; + +/// A canonical searchable document independent of the index implementation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SearchDocument { + pub document_id: String, + pub mapping_id: String, + pub source: String, + pub url: String, + pub title: String, + pub body: String, + pub updated_at: String, + pub images: Vec, + pub graph: Option, + pub content_hash: String, + pub generation: u64, +} + +impl SearchDocument { + /// Build a document and derive identity from its mapping and canonical URL. + #[allow(clippy::too_many_arguments)] + pub fn new( + mapping_id: impl Into, + source: impl Into, + url: impl Into, + title: impl Into, + body: impl Into, + updated_at: impl Into, + images: Vec, + graph: Option, + ) -> Self { + let mapping_id = mapping_id.into(); + let source = source.into(); + let url = url.into(); + let title = title.into(); + let body = body.into(); + let updated_at = updated_at.into(); + let document_id = stable_document_id(&mapping_id, &url); + let content_hash = content_hash(&title, &body, &updated_at, &images, graph.as_deref()); + Self { + document_id, + mapping_id, + source, + url, + title, + body, + updated_at, + images, + graph, + content_hash, + generation: 0, + } + } + + /// Attach the immutable index generation that published this document. + pub fn with_generation(mut self, generation: u64) -> Self { + self.generation = generation; + self + } +} + +/// Derive a stable ID from immutable mapping identity. +pub fn stable_mapping_id(pattern: &str, datas: &str) -> String { + let mut hasher = Sha256::new(); + update_length_prefixed(&mut hasher, pattern.as_bytes()); + update_length_prefixed(&mut hasher, datas.as_bytes()); + format!("map_{}", hex_digest(hasher.finalize())) +} + +/// Derive a stable ID from protocol identity, not from mutable page content. +pub fn stable_document_id(mapping_id: &str, canonical_url: &str) -> String { + let mut hasher = Sha256::new(); + update_length_prefixed(&mut hasher, mapping_id.as_bytes()); + update_length_prefixed(&mut hasher, canonical_url.as_bytes()); + format!("doc_{}", hex_digest(hasher.finalize())) +} + +/// Hash all indexed content and metadata using an unambiguous length-prefixed encoding. +pub fn content_hash( + title: &str, + body: &str, + updated_at: &str, + images: &[String], + graph: Option<&str>, +) -> String { + let mut hasher = Sha256::new(); + for value in [title, body, updated_at] { + update_length_prefixed(&mut hasher, value.as_bytes()); + } + hasher.update((images.len() as u64).to_be_bytes()); + for image in images { + update_length_prefixed(&mut hasher, image.as_bytes()); + } + match graph { + Some(graph) => { + hasher.update([1]); + update_length_prefixed(&mut hasher, graph.as_bytes()); + } + None => hasher.update([0]), + } + format!("sha256:{}", hex_digest(hasher.finalize())) +} + +fn update_length_prefixed(hasher: &mut Sha256, value: &[u8]) { + hasher.update((value.len() as u64).to_be_bytes()); + hasher.update(value); +} + +fn hex_digest>(digest: D) -> String { + digest + .as_ref() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +#[cfg(test)] +mod tests { + use super::{SearchDocument, content_hash, stable_document_id}; + + #[test] + fn document_id_depends_on_mapping_and_url_only() { + let first = stable_document_id("thoughts", "/zh-hans/thought/1001"); + let second = stable_document_id("thoughts", "/zh-hans/thought/1001"); + assert_eq!(first, second); + assert_ne!( + first, + stable_document_id("thoughts", "/zh-hans/thought/1002") + ); + } + + #[test] + fn content_hash_changes_when_indexed_content_changes() { + let base = content_hash("Title", "Body", "2026-08-30", &[], None); + let changed = content_hash("Title", "Changed", "2026-08-30", &[], None); + assert_ne!(base, changed); + } + + #[test] + fn new_document_starts_before_publication_generation() { + let document = SearchDocument::new( + "profile", + "profile.fon", + "/@Fuyeor", + "Fuyeor", + "Body", + "2026-08-30", + Vec::new(), + None, + ); + assert_eq!(document.generation, 0); + assert!(document.content_hash.starts_with("sha256:")); + } +} diff --git a/apps/engine/src/core/mod.rs b/apps/engine/src/core/mod.rs new file mode 100644 index 0000000..051ce43 --- /dev/null +++ b/apps/engine/src/core/mod.rs @@ -0,0 +1,5 @@ +// apps/engine/src/core/mod.rs + +pub mod ast; +pub mod document; +pub mod pattern; diff --git a/apps/engine/src/core/pattern.rs b/apps/engine/src/core/pattern.rs new file mode 100644 index 0000000..d389891 --- /dev/null +++ b/apps/engine/src/core/pattern.rs @@ -0,0 +1,195 @@ +// apps/engine/src/core/pattern.rs + +use super::ast::{CoreResult, RuntimeValue}; + +/// Extract and validate unique placeholder names from one FRL pattern. +pub fn placeholder_names(pattern: &str) -> CoreResult> { + if pattern.is_empty() { + return Err("pattern must not be empty".into()); + } + let mut names = Vec::new(); + let mut cursor = 0; + while cursor < pattern.len() { + let Some((offset, delimiter)) = pattern[cursor..] + .char_indices() + .find(|(_, character)| *character == '{' || *character == '}') + else { + break; + }; + let position = cursor + offset; + if delimiter == '}' { + return Err("pattern contains an unmatched closing brace".into()); + } + let close = pattern[position + 1..] + .find('}') + .map(|offset| position + 1 + offset) + .ok_or_else(|| "pattern contains an unterminated parameter".to_string())?; + let name = &pattern[position + 1..close]; + if name.is_empty() + || !name.chars().all(|character| { + character.is_ascii_alphanumeric() || character == '_' || character == '-' + }) + { + return Err("pattern contains an invalid parameter name".into()); + } + if names.iter().any(|existing| existing == name) { + return Err(format!("pattern contains duplicate parameter: {name}").into()); + } + names.push(name.to_string()); + cursor = close + 1; + } + Ok(names) +} + +/// Expand one finite set of bindings into canonical FRL paths. +pub fn expand_pattern( + pattern: &str, + params: &std::collections::BTreeMap, +) -> CoreResult> { + let _ = placeholder_names(pattern)?; + let mut variants = vec![String::new()]; + let mut cursor = 0; + while let Some(open_offset) = pattern[cursor..].find('{') { + let open = cursor + open_offset; + let close = pattern[open..] + .find('}') + .map(|offset| open + offset) + .ok_or_else(|| "pattern contains an unterminated parameter".to_string())?; + let name = &pattern[open + 1..close]; + if name.is_empty() || name.contains('{') || name.contains('}') { + return Err("pattern contains an invalid parameter name".into()); + } + let prefix = &pattern[cursor..open]; + let value = params.get(name); + let optional_terminal = value.is_none() && is_terminal_segment(pattern, open, close); + let replacements = match value { + Some(RuntimeValue::Array(values)) => values + .iter() + .map(path_value) + .collect::>>()?, + Some(value) => vec![path_value(value)?], + None if optional_terminal => vec![String::new()], + None => return Err(format!("missing non-terminal parameter: {name}").into()), + }; + let prefix = if optional_terminal { + prefix.trim_end_matches('/') + } else { + prefix + }; + variants = variants + .into_iter() + .flat_map(|base| { + replacements + .iter() + .map(move |replacement| format!("{base}{prefix}{replacement}")) + }) + .collect(); + cursor = close + 1; + } + if pattern[cursor..].contains('{') || pattern[cursor..].contains('}') { + return Err("pattern contains an unmatched brace".into()); + } + let suffix = &pattern[cursor..]; + Ok(variants + .into_iter() + .map(|value| canonical_path(&format!("{value}{suffix}"))) + .collect()) +} + +/// Detect a missing placeholder at the end of a path segment. +fn is_terminal_segment(pattern: &str, open: usize, close: usize) -> bool { + pattern[..open].ends_with('/') && close + 1 == pattern.len() +} + +/// Percent-encode one path value while keeping unreserved URI characters intact. +fn percent_encode_segment(value: &str) -> String { + let mut output = String::with_capacity(value.len()); + for byte in value.as_bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') { + output.push(*byte as char); + } else { + output.push_str(&format!("%{byte:02X}")); + } + } + output +} + +fn path_value(value: &RuntimeValue) -> CoreResult { + match value { + RuntimeValue::String(value) | RuntimeValue::Unknown(value) | RuntimeValue::Enum(value) => { + Ok(percent_encode_segment(value)) + } + RuntimeValue::Number(value) => Ok(percent_encode_segment(value)), + RuntimeValue::Boolean(value) => Ok(value.to_string()), + RuntimeValue::Array(_) | RuntimeValue::Object(_) => { + Err("path parameters must be scalar values or finite arrays".into()) + } + } +} + +/// Normalize a generated FRL to one absolute path. +pub fn canonical_path(value: &str) -> String { + if value.starts_with('/') { + value.to_string() + } else { + format!("/{value}") + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use super::expand_pattern; + use crate::core::ast::RuntimeValue; + + #[test] + fn expands_array_parameters_to_multiple_paths() { + let mut params = BTreeMap::new(); + params.insert( + "tab".to_string(), + RuntimeValue::Array(vec![ + RuntimeValue::Enum("thoughts".to_string()), + RuntimeValue::Enum("comments".to_string()), + ]), + ); + params.insert( + "username".to_string(), + RuntimeValue::String("Fuyeor".to_string()), + ); + let paths = expand_pattern("/@{username}/{tab}", ¶ms).unwrap(); + assert_eq!(paths, ["/@Fuyeor/thoughts", "/@Fuyeor/comments"]); + } + + #[test] + fn missing_terminal_parameter_is_optional() { + let mut params = BTreeMap::new(); + params.insert( + "username".to_string(), + RuntimeValue::String("Alice".to_string()), + ); + assert_eq!( + expand_pattern("/@{username}/{tab}", ¶ms).unwrap(), + ["/@Alice"] + ); + } + + #[test] + fn missing_non_terminal_parameter_is_rejected() { + let params = BTreeMap::new(); + assert!(expand_pattern("/@{username}/profile", ¶ms).is_err()); + } + + #[test] + fn percent_encodes_path_segments() { + let mut params = BTreeMap::new(); + params.insert( + "username".to_string(), + RuntimeValue::String("A B".to_string()), + ); + assert_eq!( + expand_pattern("/@{username}", ¶ms).unwrap(), + ["/@A%20B"] + ); + } +} diff --git a/apps/engine/src/fon.rs b/apps/engine/src/fon.rs index 9ff27b8..b2c6f7b 100644 --- a/apps/engine/src/fon.rs +++ b/apps/engine/src/fon.rs @@ -1,17 +1,21 @@ -// apps/fetch/back-end/src/fon.rs +// apps/engine/src/fon.rs -use std::collections::BTreeMap; +use std::collections::BTreeSet; use std::path::{Component, Path, PathBuf}; -use fon_parser::{Ast, Member, StringPartKind, Value, parse}; +use fon_parser::{SchemaKind, Value, parse}; -use crate::model::IndexPage; - -type AppResult = Result>; +use crate::core::ast::{ + CoreResult, RuntimeValue, object_field_value, object_path_field, object_string_field, + parse_root_value, +}; +use crate::core::document::{SearchDocument, stable_mapping_id}; +use crate::core::pattern::{expand_pattern, placeholder_names}; /// Describes one pattern-to-sitemap mapping declared by the site. #[derive(Debug, Clone)] pub struct MappingSpec { + pub id: String, pub pattern: String, pub datas: PathBuf, } @@ -20,11 +24,11 @@ pub struct MappingSpec { #[derive(Debug, Clone)] pub struct LoadedCatalog { pub mappings: Vec, - pub pages: Vec, + pub pages: Vec, } /// Load the three-file mock protocol from a local site root. -pub fn load_catalog(root: &Path) -> AppResult { +pub fn load_catalog(root: &Path) -> CoreResult { let mapping_path = resolve_site_path(root, Path::new("./well-known/search-patterns.fon"))?; let mapping_source = std::fs::read_to_string(&mapping_path)?; let mappings = parse_mappings(&mapping_source, &mapping_path)?; @@ -46,7 +50,7 @@ pub fn load_catalog(root: &Path) -> AppResult { } /// Parse and semantically inspect the mapping index while preserving schema syntax. -fn parse_mappings(source: &str, path: &Path) -> AppResult> { +fn parse_mappings(source: &str, path: &Path) -> CoreResult> { let result = parse(source); if result.has_errors() { return Err(format!( @@ -62,6 +66,7 @@ fn parse_mappings(source: &str, path: &Path) -> AppResult> { .root_array_items() .ok_or_else(|| format!("{} must contain a root array", path.display()))?; let mut mappings = Vec::with_capacity(item_ids.len()); + let mut mapping_ids = BTreeSet::new(); for item_id in item_ids { let value = result @@ -78,10 +83,42 @@ fn parse_mappings(source: &str, path: &Path) -> AppResult> { .ok_or_else(|| "mapping.datas is required".to_string())?; let params = object_field_value(&result.document.ast, &object.members, "params") .ok_or_else(|| "mapping.params is required".to_string())?; - if !matches!(params, Value::Schema(_)) { - return Err("mapping.params must be a struct or enum schema".into()); + let placeholders = placeholder_names(&pattern)?; + let Value::Schema(schema_value) = params else { + return Err("mapping.params must be a struct schema".into()); + }; + if schema_value.kind != SchemaKind::Struct { + return Err("mapping.params must be a struct schema".into()); + } + let schema = result + .document + .ast + .schema(schema_value.schema) + .ok_or_else(|| "mapping.params references an invalid schema".to_string())?; + let declared = schema + .fields + .iter() + .map(|field| field.key.raw.as_str()) + .collect::>(); + if declared.len() != schema.fields.len() { + return Err("mapping.params contains duplicate fields".into()); + } + if declared + .iter() + .any(|name| !placeholders.iter().any(|placeholder| placeholder == *name)) + || placeholders + .iter() + .any(|name| !declared.contains(name.as_str())) + { + return Err("mapping.params fields must exactly match pattern placeholders".into()); + } + let id = object_string_field(&result.document.ast, &object.members, "id")? + .unwrap_or_else(|| stable_mapping_id(&pattern, &datas)); + if id.trim().is_empty() || !mapping_ids.insert(id.clone()) { + return Err("mapping ids must be non-empty and unique".into()); } mappings.push(MappingSpec { + id, pattern, datas: PathBuf::from(datas), }); @@ -95,7 +132,7 @@ fn materialize_row( root: &Path, mapping: &MappingSpec, row: &RuntimeValue, -) -> AppResult> { +) -> CoreResult> { let RuntimeValue::Object(object) = row else { return Err("each sitemap row must be an object".into()); }; @@ -120,13 +157,17 @@ fn materialize_row( Ok(urls .into_iter() - .map(|url| IndexPage { - url, - title: choose_title(content.title.as_deref(), &content.body), - body: content.body.clone(), - updated_at: updated_at.clone(), - images: content.images.clone(), - graph: content.graph.clone(), + .map(|url| { + SearchDocument::new( + mapping.id.clone(), + content_reference.clone(), + url, + choose_title(content.title.as_deref(), &content.body), + content.body.clone(), + updated_at.clone(), + content.images.clone(), + content.graph.clone(), + ) }) .collect()) } @@ -141,7 +182,7 @@ struct ContentPage { } /// Parse content FON without interpreting HTML; content remains plain text/Markdown. -fn parse_content(path: &Path) -> AppResult { +fn parse_content(path: &Path) -> CoreResult { let source = std::fs::read_to_string(path)?; let value = parse_root_value(&source, path)?; let RuntimeValue::Object(object) = value else { @@ -196,92 +237,7 @@ pub fn choose_title(explicit: Option<&str>, content: &str) -> String { .join(" ") } -/// Expand scalar, optional and array bindings without enumerating an unbounded space. -fn expand_pattern( - pattern: &str, - params: &BTreeMap, -) -> AppResult> { - let mut variants = vec![String::new()]; - let mut cursor = 0; - while let Some(open_offset) = pattern[cursor..].find('{') { - let open = cursor + open_offset; - let close = pattern[open..] - .find('}') - .map(|offset| open + offset) - .ok_or_else(|| "pattern contains an unterminated parameter".to_string())?; - let name = &pattern[open + 1..close]; - let prefix = &pattern[cursor..open]; - let value = params.get(name); - let optional_terminal = value.is_none() && is_terminal_segment(pattern, open, close); - let replacements = match value { - Some(RuntimeValue::Array(values)) => values - .iter() - .map(path_value) - .collect::>>()?, - Some(value) => vec![path_value(value)?], - None if optional_terminal => vec![String::new()], - None => return Err(format!("missing non-terminal parameter: {name}").into()), - }; - let prefix = if optional_terminal { - prefix.trim_end_matches('/') - } else { - prefix - }; - variants = variants - .into_iter() - .flat_map(|base| { - replacements - .iter() - .map(move |replacement| format!("{base}{prefix}{replacement}")) - }) - .collect(); - cursor = close + 1; - } - let suffix = &pattern[cursor..]; - Ok(variants - .into_iter() - .map(|value| canonical_path(&format!("{value}{suffix}"))) - .collect()) -} - -/// Detect a missing placeholder at the end of a path segment. -fn is_terminal_segment(pattern: &str, open: usize, close: usize) -> bool { - pattern[..open].ends_with('/') && close + 1 == pattern.len() -} - -/// Percent-encode one path value while keeping unreserved URI characters intact. -fn percent_encode_segment(value: &str) -> String { - let mut output = String::with_capacity(value.len()); - for byte in value.as_bytes() { - if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') { - output.push(*byte as char); - } else { - output.push_str(&format!("%{byte:02X}")); - } - } - output -} - -fn path_value(value: &RuntimeValue) -> AppResult { - match value { - RuntimeValue::String(value) | RuntimeValue::Unknown(value) | RuntimeValue::Enum(value) => { - Ok(percent_encode_segment(value)) - } - RuntimeValue::Number(value) => Ok(percent_encode_segment(value)), - RuntimeValue::Boolean(value) => Ok(value.to_string()), - _ => Err("path parameters must be scalar strings, numbers, booleans, or arrays".into()), - } -} - -fn canonical_path(value: &str) -> String { - if value.starts_with('/') { - value.to_string() - } else { - format!("/{value}") - } -} - -fn resolve_site_path(root: &Path, path: &Path) -> AppResult { +fn resolve_site_path(root: &Path, path: &Path) -> CoreResult { let relative = path.strip_prefix("./").unwrap_or(path); if relative.is_absolute() || relative @@ -293,198 +249,11 @@ fn resolve_site_path(root: &Path, path: &Path) -> AppResult { Ok(root.join(relative)) } -fn object_string_field( - ast: &Ast, - members: &[fon_parser::MemberId], - name: &str, -) -> AppResult> { - let Some(value) = object_field_value(ast, members, name) else { - return Ok(None); - }; - match value { - Value::String(value) => Ok(Some(unquote_string(&value.raw))), - _ => Err(format!("{name} must be a FON string").into()), - } -} - -fn object_path_field( - ast: &Ast, - members: &[fon_parser::MemberId], - name: &str, -) -> AppResult> { - let Some(value) = object_field_value(ast, members, name) else { - return Ok(None); - }; - match value { - Value::Unknown(value) => Ok(Some(value.raw.clone())), - Value::String(value) => Ok(Some(unquote_string(&value.raw))), - _ => Err(format!("{name} must be a FON path or string").into()), - } -} - -fn object_field_value<'a>( - ast: &'a Ast, - members: &[fon_parser::MemberId], - name: &str, -) -> Option<&'a Value> { - members.iter().find_map(|id| { - let binding = ast.member(*id).and_then(Member::binding)?; - (binding.key.raw == name) - .then(|| ast.value(binding.value)) - .flatten() - }) -} - -fn unquote_string(raw: &str) -> String { - let value = raw - .strip_prefix('`') - .and_then(|value| value.strip_suffix('`')) - .unwrap_or(raw); - let mut output = String::with_capacity(value.len()); - let mut chars = value.chars(); - while let Some(character) = chars.next() { - if character != '\\' { - output.push(character); - continue; - } - match chars.next() { - Some('n') => output.push('\n'), - Some('r') => output.push('\r'), - Some('t') => output.push('\t'), - Some('`') => output.push('`'), - Some('\\') => output.push('\\'), - Some(other) => { - output.push('\\'); - output.push(other); - } - None => output.push('\\'), - } - } - output -} - -/// Convert parsed AST values into a small, explicit runtime representation. -#[derive(Debug, Clone)] -enum RuntimeValue { - Boolean(bool), - Number(String), - String(String), - Enum(String), - Array(Vec), - Object(BTreeMap), - Unknown(String), -} - -impl RuntimeValue { - fn as_string(&self) -> String { - match self { - Self::Boolean(value) => value.to_string(), - Self::Number(value) - | Self::String(value) - | Self::Enum(value) - | Self::Unknown(value) => value.clone(), - Self::Array(_) | Self::Object(_) => String::new(), - } - } - - fn as_array(&self) -> Option<&[RuntimeValue]> { - match self { - Self::Array(values) => Some(values), - _ => None, - } - } -} - -fn parse_root_value(source: &str, path: &Path) -> AppResult { - let result = parse(source); - if result.has_errors() { - return Err(format!("invalid FON {}: {:?}", path.display(), result.diagnostics).into()); - } - if let Some(items) = result.document.ast.root_array_items() { - let values = items - .iter() - .map(|id| value_to_runtime(&result.document.ast, *id)) - .collect::>>()?; - return Ok(RuntimeValue::Array(values)); - } - let members = result - .document - .ast - .object_members() - .ok_or_else(|| format!("{} has no supported root", path.display()))?; - let mut object = BTreeMap::new(); - for member_id in members { - let member = result - .document - .ast - .member(*member_id) - .ok_or_else(|| "invalid member reference".to_string())?; - let binding = member - .binding() - .ok_or_else(|| "data objects cannot contain type declarations".to_string())?; - let value = value_to_runtime(&result.document.ast, binding.value)?; - if object.insert(binding.key.raw.clone(), value).is_some() { - return Err(format!("duplicate FON field: {}", binding.key.raw).into()); - } - } - Ok(RuntimeValue::Object(object)) -} - -fn value_to_runtime(ast: &Ast, value_id: fon_parser::ValueId) -> AppResult { - let value = ast - .value(value_id) - .ok_or_else(|| "invalid value reference".to_string())?; - match value { - Value::Boolean { value, .. } => Ok(RuntimeValue::Boolean(*value)), - Value::Number { raw, .. } => Ok(RuntimeValue::Number(raw.clone())), - Value::String(value) => { - if value - .parts - .iter() - .any(|part| part.kind == StringPartKind::Interpolation) - { - return Err("FON interpolation is not allowed in data files".into()); - } - Ok(RuntimeValue::String(unquote_string(&value.raw))) - } - Value::EnumPath(value) => Ok(RuntimeValue::Enum( - value.path.trim_start_matches('.').to_string(), - )), - Value::Array(value) => Ok(RuntimeValue::Array( - value - .items - .iter() - .map(|id| value_to_runtime(ast, *id)) - .collect::>>()?, - )), - Value::Object(value) => { - let mut object = BTreeMap::new(); - for member_id in &value.members { - let member = ast - .member(*member_id) - .ok_or_else(|| "invalid nested member reference".to_string())?; - let binding = member.binding().ok_or_else(|| { - "nested data objects cannot contain type declarations".to_string() - })?; - let nested = value_to_runtime(ast, binding.value)?; - if object.insert(binding.key.raw.clone(), nested).is_some() { - return Err(format!("duplicate FON field: {}", binding.key.raw).into()); - } - } - Ok(RuntimeValue::Object(object)) - } - Value::Unknown(value) => Ok(RuntimeValue::Unknown(value.raw.clone())), - Value::Regex(_) | Value::Schema(_) | Value::Expression(_) | Value::Error(_) => { - Err("unsupported executable or schema value in data file".into()) - } - } -} - #[cfg(test)] mod tests { use std::path::PathBuf; - use super::{choose_title, load_catalog}; + use super::{choose_title, load_catalog, parse_mappings}; #[test] fn loads_repository_mock_catalog() { @@ -492,6 +261,9 @@ mod tests { let catalog = load_catalog(&root).expect("repository mock catalog must load"); assert_eq!(catalog.mappings.len(), 3); assert_eq!(catalog.pages.len(), 5); + assert_eq!(catalog.mappings[0].id, "profile"); + assert_eq!(catalog.mappings[1].id, "user-tab"); + assert_eq!(catalog.mappings[2].id, "thought"); assert!( catalog .pages @@ -499,6 +271,35 @@ mod tests { .any(|page| page.url == "/zh-hans/thought/1001") ); assert!(catalog.pages.iter().all(|page| page.images.len() == 1)); + assert!( + catalog + .pages + .iter() + .all(|page| page.document_id.starts_with("doc_")) + ); + assert!( + catalog + .pages + .iter() + .all(|page| page.content_hash.starts_with("sha256:")) + ); + } + + #[test] + fn duplicate_mapping_ids_are_rejected() { + let source = r#"[ + { id = `same` pattern = `/{username}` params = struct { username: string } datas = ./a.fon } + { id = `same` pattern = `/{locale}` params = struct { locale: string } datas = ./b.fon } + ]"#; + assert!(parse_mappings(source, std::path::Path::new("mappings.fon")).is_err()); + } + + #[test] + fn mapping_params_must_match_pattern_placeholders() { + let source = r#"[ + { id = `profile` pattern = `/{username}` params = struct { account: string } datas = ./a.fon } + ]"#; + assert!(parse_mappings(source, std::path::Path::new("mappings.fon")).is_err()); } #[test] diff --git a/apps/engine/src/index/mod.rs b/apps/engine/src/index/mod.rs new file mode 100644 index 0000000..f6e26ba --- /dev/null +++ b/apps/engine/src/index/mod.rs @@ -0,0 +1,9 @@ +// apps/engine/src/index/mod.rs + +mod reader; +mod schema; +mod writer; + +pub(crate) use reader::first_text; +pub(crate) use schema::{SearchFields, build_schema, fields_from_schema}; +pub(crate) use writer::{IndexManifest, ManifestDocument, load_manifest, persist_manifest}; diff --git a/apps/engine/src/index/reader.rs b/apps/engine/src/index/reader.rs new file mode 100644 index 0000000..f03ab19 --- /dev/null +++ b/apps/engine/src/index/reader.rs @@ -0,0 +1,12 @@ +// apps/engine/src/index/reader.rs + +use tantivy::schema::{Field, TantivyDocument, Value as TantivyValue}; + +/// Read the first stored string value for a field without exposing Tantivy values upstream. +pub(crate) fn first_text(document: &TantivyDocument, field: Field) -> String { + document + .get_first(field) + .and_then(|value| value.as_str()) + .unwrap_or_default() + .to_string() +} diff --git a/apps/engine/src/index/schema.rs b/apps/engine/src/index/schema.rs new file mode 100644 index 0000000..9895b17 --- /dev/null +++ b/apps/engine/src/index/schema.rs @@ -0,0 +1,80 @@ +// apps/engine/src/index/schema.rs + +use tantivy::schema::{ + Field, IndexRecordOption, STORED, STRING, Schema, TextFieldIndexing, TextOptions, +}; + +pub(crate) type IndexResult = Result>; + +/// Field handles kept together so query and document code cannot drift apart. +#[derive(Clone, Copy)] +pub(crate) struct SearchFields { + pub(crate) document_id: Field, + pub(crate) mapping_id: Field, + pub(crate) source: Field, + pub(crate) url: Field, + pub(crate) title: Field, + pub(crate) content: Field, + pub(crate) updated_at: Field, + pub(crate) image_url: Field, + pub(crate) graph: Field, + pub(crate) content_hash: Field, + pub(crate) generation: Field, +} + +/// Build stored fields and jieba-backed text fields for the stable document schema. +pub(crate) fn build_schema() -> (Schema, SearchFields) { + let mut builder = Schema::builder(); + let text_options = TextOptions::default() + .set_indexing_options( + TextFieldIndexing::default() + .set_tokenizer("jieba_search") + .set_index_option(IndexRecordOption::WithFreqsAndPositions), + ) + .set_stored(); + let document_id = builder.add_text_field("document_id", STRING | STORED); + let mapping_id = builder.add_text_field("mapping_id", STRING | STORED); + let source = builder.add_text_field("source", STRING | STORED); + let url = builder.add_text_field("url", STRING | STORED); + let title = builder.add_text_field("title", text_options.clone()); + let content = builder.add_text_field("content", text_options); + let updated_at = builder.add_text_field("updated_at", STRING | STORED); + let image_url = builder.add_text_field("image_url", STRING | STORED); + let graph = builder.add_text_field("graph", STRING | STORED); + let content_hash = builder.add_text_field("content_hash", STRING | STORED); + let generation = builder.add_text_field("generation", STRING | STORED); + let schema = builder.build(); + ( + schema, + SearchFields { + document_id, + mapping_id, + source, + url, + title, + content, + updated_at, + image_url, + graph, + content_hash, + generation, + }, + ) +} + +/// Resolve all field handles from an existing index and fail fast on incompatible schemas. +pub(crate) fn fields_from_schema(schema: &Schema) -> IndexResult { + Ok(SearchFields { + document_id: schema.get_field("document_id")?, + mapping_id: schema.get_field("mapping_id")?, + source: schema.get_field("source")?, + url: schema.get_field("url")?, + title: schema.get_field("title")?, + content: schema.get_field("content")?, + updated_at: schema.get_field("updated_at")?, + image_url: schema.get_field("image_url")?, + graph: schema.get_field("graph")?, + content_hash: schema.get_field("content_hash")?, + generation: schema.get_field("generation")?, + }) +} diff --git a/apps/engine/src/index/writer.rs b/apps/engine/src/index/writer.rs new file mode 100644 index 0000000..637d431 --- /dev/null +++ b/apps/engine/src/index/writer.rs @@ -0,0 +1,58 @@ +// apps/engine/src/index/writer.rs + +use std::collections::BTreeMap; +use std::fs::File; +use std::io::Write; +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +use super::schema::IndexResult; + +pub(crate) const MANIFEST_VERSION: u32 = 1; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct IndexManifest { + pub(crate) version: u32, + pub(crate) generation: u64, + pub(crate) documents: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct ManifestDocument { + pub(crate) content_hash: String, + pub(crate) generation: u64, +} + +impl Default for IndexManifest { + fn default() -> Self { + Self { + version: MANIFEST_VERSION, + generation: 0, + documents: BTreeMap::new(), + } + } +} + +pub(crate) fn load_manifest(path: &Path) -> IndexResult { + if !path.exists() { + return Ok(IndexManifest::default()); + } + let bytes = std::fs::read(path)?; + let manifest: IndexManifest = serde_json::from_slice(&bytes)?; + if manifest.version != MANIFEST_VERSION { + return Err(format!("unsupported index manifest version: {}", manifest.version).into()); + } + Ok(manifest) +} + +/// Persist the logical catalog after Tantivy commit using an atomic same-directory rename. +pub(crate) fn persist_manifest(path: &Path, manifest: &IndexManifest) -> IndexResult<()> { + let temporary_path = path.with_extension("json.tmp"); + let bytes = serde_json::to_vec_pretty(manifest)?; + let mut file = File::create(&temporary_path)?; + file.write_all(&bytes)?; + file.sync_all()?; + std::fs::rename(temporary_path, path)?; + Ok(()) +} diff --git a/apps/engine/src/main.rs b/apps/engine/src/main.rs index 792fadd..a5bbf2b 100644 --- a/apps/engine/src/main.rs +++ b/apps/engine/src/main.rs @@ -1,32 +1,30 @@ -// apps/fetch/back-end/src/main.rs +// apps/engine/src/main.rs +mod config; +mod core; mod fon; +mod index; mod model; mod search; mod web; -use std::net::SocketAddr; -use std::path::PathBuf; use std::sync::Arc; +use config::Config; use search::SearchEngine; use web::{AppState, router}; /// Start the local Fetch search API with configurable data and index directories. #[tokio::main] async fn main() -> Result<(), Box> { - let data_root = std::env::var_os("FETCH_DATA_ROOT") - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from(".")); - let index_root = std::env::var_os("FETCH_INDEX_ROOT") - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from(".fetch-index")); - let bind_address = std::env::var("FETCH_BIND").unwrap_or_else(|_| "127.0.0.1:3000".to_string()); - let engine = Arc::new(SearchEngine::open(&index_root)?); - let state = AppState::new(engine, data_root); - let address: SocketAddr = bind_address.parse()?; - let listener = tokio::net::TcpListener::bind(address).await?; - println!("Fetch search API listening on http://{address}"); + let config = Config::from_env()?; + let engine = Arc::new(SearchEngine::open(&config.index_root)?); + let state = AppState::new(engine, config.data_root); + let listener = tokio::net::TcpListener::bind(config.bind_address).await?; + println!( + "Fetch search API listening on http://{}", + config.bind_address + ); axum::serve(listener, router(state)).await?; Ok(()) } diff --git a/apps/engine/src/model.rs b/apps/engine/src/model.rs index 0743d07..9261285 100644 --- a/apps/engine/src/model.rs +++ b/apps/engine/src/model.rs @@ -1,26 +1,19 @@ -// apps/fetch/back-end/src/model.rs +// apps/engine/src/model.rs use serde::Serialize; -/// A page materialized from one finite sitemap binding. -#[derive(Debug, Clone)] -pub struct IndexPage { - pub url: String, - pub title: String, - pub body: String, - pub updated_at: String, - pub images: Vec, - pub graph: Option, -} - /// A search hit returned by the public API and rendered by the CSR client. #[derive(Debug, Clone, Serialize)] pub struct SearchHit { + pub document_id: String, + pub mapping_id: String, pub url: String, pub title: String, pub snippet: String, pub updated_at: String, pub images: Vec, + pub content_hash: String, + pub generation: u64, pub score: f32, } @@ -31,6 +24,7 @@ pub struct SearchResponse { pub offset: usize, pub limit: usize, pub total: usize, + pub generation: u64, pub results: Vec, } @@ -39,13 +33,19 @@ pub struct SearchResponse { pub struct IndexStatus { pub documents: usize, pub mappings: usize, + pub generation: u64, pub last_rebuilt_at: String, } -/// Result of one complete mock sitemap ingestion. +/// Result of one complete sitemap ingestion. #[derive(Debug, Clone, Serialize)] pub struct RebuildReport { pub mappings: usize, pub rows: usize, pub documents: usize, + pub generation: u64, + pub added: usize, + pub updated: usize, + pub deleted: usize, + pub unchanged: usize, } diff --git a/apps/engine/src/search.rs b/apps/engine/src/search.rs index 431ca91..189dcb8 100644 --- a/apps/engine/src/search.rs +++ b/apps/engine/src/search.rs @@ -1,20 +1,24 @@ -// apps/fetch/back-end/src/search.rs +// apps/engine/src/search.rs -use std::path::Path; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use jieba_rs::Jieba; use tantivy::collector::{Count, TopDocs}; use tantivy::directory::MmapDirectory; use tantivy::query::QueryParser; -use tantivy::schema::{ - Field, IndexRecordOption, STORED, STRING, Schema, TantivyDocument, TextFieldIndexing, - TextOptions, Value as TantivyValue, -}; +use tantivy::schema::{TantivyDocument, Value as TantivyValue}; use tantivy::tokenizer::{TextAnalyzer, Token, TokenStream, Tokenizer}; -use tantivy::{Index, IndexReader, IndexWriter, ReloadPolicy, doc}; +use tantivy::{Index, IndexReader, IndexWriter, ReloadPolicy, Term}; -use crate::model::{IndexPage, SearchHit, SearchResponse}; +use crate::core::document::SearchDocument; +use crate::index::{ + IndexManifest, ManifestDocument, SearchFields, build_schema, fields_from_schema, first_text, + load_manifest, persist_manifest, +}; +use crate::model::{SearchHit, SearchResponse}; type AppResult = Result>; @@ -86,15 +90,14 @@ impl TokenStream for JiebaTokenStream { } } -/// Field handles kept together so query and document code cannot drift apart. -#[derive(Clone, Copy)] -struct SearchFields { - url: Field, - title: Field, - content: Field, - updated_at: Field, - image_url: Field, - graph: Field, +/// Changes applied to the Tantivy index in one committed batch. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct IndexSyncReport { + pub generation: u64, + pub added: usize, + pub updated: usize, + pub deleted: usize, + pub unchanged: usize, } /// A single-writer Tantivy index with manual reader reloads after commits. @@ -103,17 +106,23 @@ pub struct SearchEngine { reader: IndexReader, writer: Mutex, fields: SearchFields, + manifest_path: PathBuf, + generation: AtomicU64, } impl SearchEngine { /// Open or create the on-disk index and register the Chinese analyzer. pub fn open(index_path: &Path) -> AppResult { std::fs::create_dir_all(index_path)?; - let (schema, fields) = build_schema(); - let index = if index_path.join("meta.json").exists() { - Index::open(MmapDirectory::open(index_path)?)? + let manifest_path = index_path.join("manifest.json"); + let initial_generation = load_manifest(&manifest_path)?.generation; + let (new_schema, new_fields) = build_schema(); + let (index, fields) = if index_path.join("meta.json").exists() { + let index = Index::open(MmapDirectory::open(index_path)?)?; + let fields = fields_from_schema(&index.schema())?; + (index, fields) } else { - Index::create_in_dir(index_path, schema.clone())? + (Index::create_in_dir(index_path, new_schema)?, new_fields) }; index.tokenizers().register( "jieba_search", @@ -129,22 +138,102 @@ impl SearchEngine { reader, writer: Mutex::new(writer), fields, + manifest_path, + generation: AtomicU64::new(initial_generation), }) } - /// Replace the current index atomically at the Tantivy commit boundary. - pub fn replace_all(&self, pages: &[IndexPage]) -> AppResult<()> { + /// Apply an authoritative finite catalog using stable IDs and content hashes. + pub fn sync_documents(&self, pages: &[SearchDocument]) -> AppResult { let mut writer = self .writer .lock() .map_err(|_| "index writer lock poisoned")?; - writer.delete_all_documents()?; + let mut manifest = load_manifest(&self.manifest_path)?; + let live_document_count = self.document_count()?; + let legacy_index = !self.manifest_path.exists() && live_document_count > 0; + let mut incoming = BTreeMap::new(); for page in pages { - writer.add_document(self.to_document(page))?; + if incoming.insert(page.document_id.clone(), page).is_some() { + return Err(format!("duplicate document_id: {}", page.document_id).into()); + } + } + + let mut report = IndexSyncReport { + generation: manifest.generation, + ..IndexSyncReport::default() + }; + if legacy_index { + writer.delete_all_documents()?; + report.deleted = live_document_count; + manifest = IndexManifest::default(); + } + + for (document_id, page) in &incoming { + match manifest.documents.get(document_id) { + None => report.added += 1, + Some(previous) if previous.content_hash == page.content_hash => { + report.unchanged += 1 + } + Some(_) => report.updated += 1, + } + } + report.deleted += manifest + .documents + .keys() + .filter(|document_id| !incoming.contains_key(*document_id)) + .count(); + + let changed = report.added + report.updated + report.deleted; + if changed == 0 { + return Ok(report); + } + let generation = manifest.generation.saturating_add(1); + report.generation = generation; + + for document_id in manifest.documents.keys().filter(|document_id| { + !incoming.contains_key(*document_id) + || incoming.get(*document_id).is_some_and(|page| { + manifest + .documents + .get(*document_id) + .is_some_and(|previous| previous.content_hash != page.content_hash) + }) + }) { + writer.delete_term(Term::from_field_text(self.fields.document_id, document_id)); + } + if legacy_index { + // The legacy delete_all above already removed unknown IDs; this loop is intentionally empty. + } + for (document_id, page) in &incoming { + let needs_write = manifest + .documents + .get(document_id) + .is_none_or(|previous| previous.content_hash != page.content_hash); + if needs_write { + let document = (*page).clone().with_generation(generation); + writer.add_document(self.to_document(&document))?; + } } writer.commit()?; self.reader.reload()?; - Ok(()) + + manifest.generation = generation; + manifest.documents = incoming + .into_iter() + .map(|(document_id, page)| { + ( + document_id, + ManifestDocument { + content_hash: page.content_hash.clone(), + generation, + }, + ) + }) + .collect(); + persist_manifest(&self.manifest_path, &manifest)?; + self.generation.store(generation, Ordering::Release); + Ok(report) } /// Search indexed title/content with bounded pagination and stored metadata. @@ -169,6 +258,8 @@ impl SearchEngine { for (score, address) in top_docs { let document = searcher.doc::(address)?; results.push(SearchHit { + document_id: first_text(&document, self.fields.document_id), + mapping_id: first_text(&document, self.fields.mapping_id), url: first_text(&document, self.fields.url), title: first_text(&document, self.fields.title), snippet: first_text(&document, self.fields.content) @@ -180,6 +271,10 @@ impl SearchEngine { .get_all(self.fields.image_url) .filter_map(|value| value.as_str().map(ToOwned::to_owned)) .collect(), + content_hash: first_text(&document, self.fields.content_hash), + generation: first_text(&document, self.fields.generation) + .parse() + .unwrap_or_default(), score, }); } @@ -188,6 +283,7 @@ impl SearchEngine { offset, limit, total, + generation: self.generation()?, results, }) } @@ -200,13 +296,22 @@ impl SearchEngine { .search(&tantivy::query::AllQuery, &Count)?) } - fn to_document(&self, page: &IndexPage) -> TantivyDocument { - let mut document = doc!( - self.fields.url => page.url.as_str(), - self.fields.title => page.title.as_str(), - self.fields.content => page.body.as_str(), - self.fields.updated_at => page.updated_at.as_str(), - ); + /// Return the last committed logical catalog generation. + pub fn generation(&self) -> AppResult { + Ok(self.generation.load(Ordering::Acquire)) + } + + fn to_document(&self, page: &SearchDocument) -> TantivyDocument { + let mut document = TantivyDocument::default(); + document.add_text(self.fields.document_id, &page.document_id); + document.add_text(self.fields.mapping_id, &page.mapping_id); + document.add_text(self.fields.source, &page.source); + document.add_text(self.fields.url, &page.url); + document.add_text(self.fields.title, &page.title); + document.add_text(self.fields.content, &page.body); + document.add_text(self.fields.updated_at, &page.updated_at); + document.add_text(self.fields.content_hash, &page.content_hash); + document.add_text(self.fields.generation, page.generation.to_string()); if let Some(graph) = &page.graph { document.add_text(self.fields.graph, graph); } @@ -217,47 +322,12 @@ impl SearchEngine { } } -/// Build stored fields and jieba-backed text fields for the MVP schema. -fn build_schema() -> (Schema, SearchFields) { - let mut builder = Schema::builder(); - let text_options = TextOptions::default() - .set_indexing_options( - TextFieldIndexing::default() - .set_tokenizer("jieba_search") - .set_index_option(IndexRecordOption::WithFreqsAndPositions), - ) - .set_stored(); - let url = builder.add_text_field("url", STRING | STORED); - let title = builder.add_text_field("title", text_options.clone()); - let content = builder.add_text_field("content", text_options); - let updated_at = builder.add_text_field("updated_at", STRING | STORED); - let image_url = builder.add_text_field("image_url", STRING | STORED); - let graph = builder.add_text_field("graph", STRING | STORED); - let schema = builder.build(); - ( - schema, - SearchFields { - url, - title, - content, - updated_at, - image_url, - graph, - }, - ) -} - -fn first_text(document: &TantivyDocument, field: Field) -> String { - document - .get_first(field) - .and_then(|value| value.as_str()) - .unwrap_or_default() - .to_string() -} - #[cfg(test)] mod tests { - use super::JiebaTokenizer; + use tempfile::tempdir; + + use super::{JiebaTokenizer, SearchEngine}; + use crate::core::document::SearchDocument; use tantivy::tokenizer::{TokenStream, Tokenizer}; #[test] @@ -270,4 +340,62 @@ mod tests { } assert!(terms.iter().any(|term| term == "动态")); } + + #[test] + fn unchanged_sync_keeps_generation_and_changed_sync_increments_it() { + let directory = tempdir().unwrap(); + let engine = SearchEngine::open(directory.path()).unwrap(); + let page = SearchDocument::new( + "mapping", + "content.fon", + "/stable", + "Stable", + "Initial body", + "2026-08-30", + Vec::new(), + None, + ); + let first = engine.sync_documents(std::slice::from_ref(&page)).unwrap(); + assert_eq!(first.generation, 1); + assert_eq!(first.added, 1); + let unchanged = engine.sync_documents(std::slice::from_ref(&page)).unwrap(); + assert_eq!(unchanged.generation, 1); + assert_eq!(unchanged.unchanged, 1); + let changed = SearchDocument::new( + "mapping", + "content.fon", + "/stable", + "Stable", + "Changed body", + "2026-08-30", + Vec::new(), + None, + ); + let second = engine + .sync_documents(std::slice::from_ref(&changed)) + .unwrap(); + assert_eq!(second.generation, 2); + assert_eq!(second.updated, 1); + assert_eq!(engine.document_count().unwrap(), 1); + } + + #[test] + fn missing_authoritative_document_is_deleted() { + let directory = tempdir().unwrap(); + let engine = SearchEngine::open(directory.path()).unwrap(); + let page = SearchDocument::new( + "mapping", + "content.fon", + "/to-delete", + "Delete me", + "Body", + "2026-08-30", + Vec::new(), + None, + ); + engine.sync_documents(std::slice::from_ref(&page)).unwrap(); + let report = engine.sync_documents(&[]).unwrap(); + assert_eq!(report.deleted, 1); + assert_eq!(engine.document_count().unwrap(), 0); + } } diff --git a/apps/engine/src/web.rs b/apps/engine/src/web.rs index 22ab55f..8647642 100644 --- a/apps/engine/src/web.rs +++ b/apps/engine/src/web.rs @@ -1,11 +1,11 @@ -// apps/fetch/back-end/src/web.rs +// apps/engine/src/web.rs use std::sync::{Arc, RwLock}; use axum::Router; use axum::extract::{Query, State}; use axum::http::StatusCode; -use axum::response::{Html, IntoResponse, Json, Response}; +use axum::response::{IntoResponse, Json, Response}; use axum::routing::{get, post}; use serde::Deserialize; @@ -37,10 +37,9 @@ impl AppState { } } -/// Build the public API and CSR shell routes. +/// Build the data-plane API; gateway version prefixes are intentionally outside this router. pub fn router(state: AppState) -> Router { Router::new() - .route("/", get(index_page)) .route("/search", get(search)) .route("/index/status", get(index_status)) .route("/index/rebuild", post(rebuild_index)) @@ -67,7 +66,7 @@ async fn search( Ok(Json(response)) } -/// Report the number of currently searchable documents. +/// Report the number of currently searchable documents and the published generation. async fn index_status(State(state): State) -> Result, ApiError> { let metadata = state .metadata @@ -76,14 +75,15 @@ async fn index_status(State(state): State) -> Result Ok(Json(IndexStatus { documents: state.engine.document_count()?, mappings: metadata.mappings, + generation: state.engine.generation()?, last_rebuilt_at: metadata.last_rebuilt_at.clone(), })) } -/// Rebuild the local mock index from the three FON resources. +/// Apply the local finite FON catalog without crossing site boundaries or crawling remote pages. async fn rebuild_index(State(state): State) -> Result, ApiError> { let catalog = load_catalog(&state.mock_root)?; - state.engine.replace_all(&catalog.pages)?; + let sync = state.engine.sync_documents(&catalog.pages)?; let mut metadata = state .metadata .write() @@ -94,6 +94,11 @@ async fn rebuild_index(State(state): State) -> Result u64 { .map_or(0, |duration| duration.as_secs()) } -/// Serve a dependency-free CSR mock console so the backend can be tested in a browser. -async fn index_page() -> Html<&'static str> { - Html(include_str!("../../../web/index.html")) -} - /// Convert internal failures into one JSON error shape without leaking implementation details. #[derive(Debug)] struct ApiError(Box); @@ -170,6 +170,27 @@ mod tests { let rebuild_json: serde_json::Value = serde_json::from_slice(&rebuild_body).expect("rebuild body must be JSON"); assert_eq!(rebuild_json["documents"], 5); + assert_eq!(rebuild_json["added"], 5); + assert_eq!(rebuild_json["generation"], 1); + + let unchanged = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/index/rebuild") + .body(Body::empty()) + .unwrap(), + ) + .await + .expect("second rebuild response must be returned"); + let unchanged_body = to_bytes(unchanged.into_body(), usize::MAX) + .await + .expect("second rebuild body must be readable"); + let unchanged_json: serde_json::Value = + serde_json::from_slice(&unchanged_body).expect("second rebuild body must be JSON"); + assert_eq!(unchanged_json["unchanged"], 5); + assert_eq!(unchanged_json["generation"], 1); let search = app .oneshot( @@ -187,6 +208,7 @@ mod tests { let search_json: serde_json::Value = serde_json::from_slice(&search_body).expect("search body must be JSON"); assert_eq!(search_json["total"], 2); + assert_eq!(search_json["generation"], 1); assert!( search_json["results"][0]["url"] .as_str() diff --git a/apps/front-end/src/types/search.ts b/apps/front-end/src/types/search.ts index 918c61b..de692c6 100644 --- a/apps/front-end/src/types/search.ts +++ b/apps/front-end/src/types/search.ts @@ -14,14 +14,19 @@ export interface SearchResults { offset: number; limit: number; total: number; + generation: number; results: SearchItem[]; } export interface SearchItem { + document_id: string; + mapping_id: string; url: string; title: string; snippet: string; updated_at: string; images: string[]; + content_hash: string; + generation: number; score: number; } diff --git a/web/index.html b/web/index.html deleted file mode 100644 index 5de9d20..0000000 --- a/web/index.html +++ /dev/null @@ -1,201 +0,0 @@ - - - - - - - Fetch · SPP Search - - - -
-
-
-
Fuyeor · Search Patterns Protocol
-

Fetch

-
-

一个以 FON sitemap 为入口、以 Markdown 为内容、由 Tantivy 和 jieba-rs 驱动的 CSR 搜索 mock。

-
- -
- - -
- -
- 等待索引 - -
- -
-
输入关键词开始搜索
-
- - - - diff --git a/well-known/search-patterns.fon b/well-known/search-patterns.fon index 240e153..da2de7d 100644 --- a/well-known/search-patterns.fon +++ b/well-known/search-patterns.fon @@ -1,6 +1,7 @@ // well-known/search-patterns.fon [ { + id = `profile` pattern = `/@{username}` params = struct { username: string @@ -8,6 +9,7 @@ datas = ./well-known/sitemaps/users.fon } { + id = `user-tab` pattern = `/@{username}/{tab}` params = struct { username: string @@ -16,6 +18,7 @@ datas = ./well-known/sitemaps/user-tabs.fon } { + id = `thought` pattern = `/{locale}/thought/{id}` params = struct { locale: enum { en, fr, es, zh-hans } From 87c5655c5cfc62b15f404ac84abad126987e5ec5 Mon Sep 17 00:00:00 2001 From: Manus AI Date: Thu, 27 Aug 2026 22:38:37 +0000 Subject: [PATCH 6/9] chore: Ignore root index runtime state --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 50d6927..89a6e53 100644 --- a/.gitignore +++ b/.gitignore @@ -65,4 +65,5 @@ Thumbs.db # Tantivy runtime state +/.fetch-index/ /apps/engine/.fetch-index/ From 5da539127631b1203f4acd0bc57b7040dc736794 Mon Sep 17 00:00:00 2001 From: Manus AI Date: Fri, 28 Aug 2026 01:16:12 +0000 Subject: [PATCH 7/9] feat: Add redb submitted sitemap ingestion --- .gitignore | 4 +- Cargo.lock | 1003 +++++++++++++++++++++++++++- apps/engine/Cargo.toml | 7 +- apps/engine/src/config.rs | 3 + apps/engine/src/core/document.rs | 35 +- apps/engine/src/core/pattern.rs | 36 +- apps/engine/src/crawler/fetcher.rs | 266 ++++++++ apps/engine/src/crawler/mod.rs | 341 ++++++++++ apps/engine/src/crawler/state.rs | 288 ++++++++ apps/engine/src/fon.rs | 120 +++- apps/engine/src/main.rs | 9 +- apps/engine/src/model.rs | 5 + apps/engine/src/web.rs | 136 +++- well-known/search-patterns.fon | 3 - 14 files changed, 2186 insertions(+), 70 deletions(-) create mode 100644 apps/engine/src/crawler/fetcher.rs create mode 100644 apps/engine/src/crawler/mod.rs create mode 100644 apps/engine/src/crawler/state.rs diff --git a/.gitignore b/.gitignore index 89a6e53..a7b1c31 100644 --- a/.gitignore +++ b/.gitignore @@ -64,6 +64,8 @@ .DS_Store Thumbs.db -# Tantivy runtime state +# Engine runtime state /.fetch-index/ /apps/engine/.fetch-index/ +/.fetch-state/ +/apps/engine/.fetch-state/ diff --git a/Cargo.lock b/Cargo.lock index 03d757d..a09518a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -55,6 +55,29 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-lc-rs" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + [[package]] name = "axum" version = "0.8.9" @@ -217,6 +240,58 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -226,6 +301,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.1" @@ -350,12 +434,29 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + [[package]] name = "downcast-rs" version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "either" version = "1.18.0" @@ -407,7 +508,10 @@ version = "0.1.0" dependencies = [ "axum", "fon-parser", + "futures-util", "jieba-rs", + "redb", + "reqwest", "serde", "serde_json", "sha2", @@ -459,6 +563,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures-channel" version = "0.3.34" @@ -474,6 +584,12 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + [[package]] name = "futures-macro" version = "0.3.34" @@ -485,6 +601,12 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + [[package]] name = "futures-task" version = "0.3.34" @@ -498,8 +620,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", + "futures-io", "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -514,6 +639,19 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -521,8 +659,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", + "rand_core", + "wasm-bindgen", +] + +[[package]] +name = "h2" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", ] [[package]] @@ -536,6 +696,12 @@ dependencies = [ "foldhash", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "htmlescape" version = "0.3.1" @@ -597,6 +763,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -605,6 +772,22 @@ dependencies = [ "pin-project-lite", "smallvec", "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", ] [[package]] @@ -613,13 +796,104 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ + "base64", "bytes", + "futures-channel", + "futures-util", "http", "http-body", "hyper", + "ipnet", + "libc", + "percent-encoding", "pin-project-lite", + "socket2", "tokio", "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", ] [[package]] @@ -628,6 +902,27 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "include-flate" version = "0.3.4" @@ -661,6 +956,16 @@ dependencies = [ "zstd", ] +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + [[package]] name = "inventory" version = "0.3.24" @@ -670,6 +975,12 @@ dependencies = [ "rustversion", ] +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + [[package]] name = "itertools" version = "0.14.0" @@ -709,13 +1020,62 @@ dependencies = [ "rustc-hash", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + [[package]] name = "jobserver" version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom", + "getrandom 0.4.3", "libc", ] @@ -761,7 +1121,7 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff7a10e427698aef6eef269482776debfef63384d30f13aad39a1a95e0e098fd" dependencies = [ - "hashbrown", + "hashbrown 0.16.1", "no_std_io2", "rle-decode-fast", ] @@ -772,6 +1132,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + [[package]] name = "log" version = "0.4.34" @@ -784,9 +1150,15 @@ version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" dependencies = [ - "hashbrown", + "hashbrown 0.16.1", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "lz4_flex" version = "0.13.1" @@ -898,6 +1270,12 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "269bca4c2591a28585d6bf10d9ed0332b7d76900a1b02bec41bdc3a2cdcda107" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "ordered-float" version = "5.5.0" @@ -973,6 +1351,15 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -1021,22 +1408,105 @@ dependencies = [ ] [[package]] -name = "quote" -version = "1.0.47" +name = "quinn" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" dependencies = [ - "proc-macro2", + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", ] [[package]] -name = "r-efi" -version = "6.0.0" +name = "quinn-proto" +version = "0.11.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] [[package]] -name = "rayon" +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rayon" version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" @@ -1055,6 +1525,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "redb" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de6c3b63e007e90ce536ec2ae4690826136a20ec8dbbbb400daef1bb999d2e36" +dependencies = [ + "libc", +] + [[package]] name = "regex" version = "1.13.1" @@ -1084,6 +1563,59 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rle-decode-fast" version = "1.0.3" @@ -1106,6 +1638,15 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.4" @@ -1119,6 +1660,81 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -1131,6 +1747,53 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.229" @@ -1204,7 +1867,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -1214,6 +1877,22 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "siphasher" version = "1.0.3" @@ -1263,6 +1942,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.119" @@ -1290,6 +1975,20 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] [[package]] name = "tantivy" @@ -1446,7 +2145,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", @@ -1502,12 +2201,38 @@ dependencies = [ "time-core", ] +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ + "bytes", "libc", "mio", "pin-project-lite", @@ -1527,6 +2252,30 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + [[package]] name = "tower" version = "0.5.3" @@ -1543,6 +2292,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -1575,6 +2342,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typeid" version = "1.0.3" @@ -1617,19 +2390,43 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + [[package]] name = "utf8-ranges" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7fcfc827f90e53a02eaef5e535ee14266c1d569214c6aa70133a624d8a3164ba" +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "uuid" version = "1.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" dependencies = [ - "getrandom", + "getrandom 0.4.3", "js-sys", "serde_core", "wasm-bindgen", @@ -1641,6 +2438,25 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1660,6 +2476,16 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.127" @@ -1692,6 +2518,48 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi" version = "0.3.9" @@ -1708,6 +2576,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -1720,6 +2597,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -1802,6 +2688,95 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + [[package]] name = "zmij" version = "1.0.23" diff --git a/apps/engine/Cargo.toml b/apps/engine/Cargo.toml index e093948..933990f 100644 --- a/apps/engine/Cargo.toml +++ b/apps/engine/Cargo.toml @@ -9,13 +9,16 @@ license = "MIT" [dependencies] axum = "0.8.9" fon-parser = { git = "https://github.com/Fuyeor/fon-parser.git", branch = "main", package = "fon-parser" } +futures-util = "0.3.31" jieba-rs = "0.10.3" +tempfile = "3.27.0" serde = { version = "1.0", features = ["derive"] } +reqwest = { version = "0.13.4", default-features = false, features = ["http2", "rustls", "stream"] } +redb = "4.2.0" serde_json = "1.0" sha2 = "0.10" tantivy = "0.26.1" -tokio = { version = "1.48", features = ["macros", "net", "rt-multi-thread"] } +tokio = { version = "1.48", features = ["macros", "net", "rt-multi-thread", "time"] } [dev-dependencies] tower = { version = "0.5", features = ["util"] } -tempfile = "3.27.0" diff --git a/apps/engine/src/config.rs b/apps/engine/src/config.rs index 58e3d9e..623ede1 100644 --- a/apps/engine/src/config.rs +++ b/apps/engine/src/config.rs @@ -10,6 +10,7 @@ pub type ConfigResult = Result>; pub struct Config { pub data_root: PathBuf, pub index_root: PathBuf, + pub crawler_state_root: PathBuf, pub bind_address: SocketAddr, } @@ -18,12 +19,14 @@ impl Config { pub fn from_env() -> ConfigResult { let data_root = env_path("FETCH_DATA_ROOT", ".")?; let index_root = env_path("FETCH_INDEX_ROOT", ".fetch-index")?; + let crawler_state_root = env_path("FETCH_CRAWLER_STATE_ROOT", ".fetch-state")?; let bind_address = std::env::var("FETCH_BIND") .unwrap_or_else(|_| "127.0.0.1:3000".to_string()) .parse()?; Ok(Self { data_root, index_root, + crawler_state_root, bind_address, }) } diff --git a/apps/engine/src/core/document.rs b/apps/engine/src/core/document.rs index d018ad8..d892ae1 100644 --- a/apps/engine/src/core/document.rs +++ b/apps/engine/src/core/document.rs @@ -6,6 +6,7 @@ use sha2::{Digest, Sha256}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct SearchDocument { pub document_id: String, + /// Internal mapping key derived exclusively from the canonical FRL pattern. pub mapping_id: String, pub source: String, pub url: String, @@ -19,7 +20,7 @@ pub struct SearchDocument { } impl SearchDocument { - /// Build a document and derive identity from its mapping and canonical URL. + /// Build a document and derive identity from its pattern-derived mapping key and URL. #[allow(clippy::too_many_arguments)] pub fn new( mapping_id: impl Into, @@ -61,17 +62,18 @@ impl SearchDocument { } } -/// Derive a stable ID from immutable mapping identity. -pub fn stable_mapping_id(pattern: &str, datas: &str) -> String { +/// Derive a stable internal mapping key from the canonical FRL pattern only. +pub fn stable_mapping_id(canonical_pattern: &str) -> String { let mut hasher = Sha256::new(); - update_length_prefixed(&mut hasher, pattern.as_bytes()); - update_length_prefixed(&mut hasher, datas.as_bytes()); + hasher.update(b"fetch/mapping/v1\0"); + update_length_prefixed(&mut hasher, canonical_pattern.as_bytes()); format!("map_{}", hex_digest(hasher.finalize())) } /// Derive a stable ID from protocol identity, not from mutable page content. pub fn stable_document_id(mapping_id: &str, canonical_url: &str) -> String { let mut hasher = Sha256::new(); + hasher.update(b"fetch/document/v1\0"); update_length_prefixed(&mut hasher, mapping_id.as_bytes()); update_length_prefixed(&mut hasher, canonical_url.as_bytes()); format!("doc_{}", hex_digest(hasher.finalize())) @@ -86,6 +88,7 @@ pub fn content_hash( graph: Option<&str>, ) -> String { let mut hasher = Sha256::new(); + hasher.update(b"fetch/content/v1\0"); for value in [title, body, updated_at] { update_length_prefixed(&mut hasher, value.as_bytes()); } @@ -118,17 +121,23 @@ fn hex_digest>(digest: D) -> String { #[cfg(test)] mod tests { - use super::{SearchDocument, content_hash, stable_document_id}; + use super::{SearchDocument, content_hash, stable_document_id, stable_mapping_id}; + + #[test] + fn mapping_id_depends_on_canonical_pattern_only() { + let first = stable_mapping_id("/@{username}/thoughts"); + let second = stable_mapping_id("/@{username}/thoughts"); + assert_eq!(first, second); + assert_ne!(first, stable_mapping_id("/@{username}/articles")); + } #[test] fn document_id_depends_on_mapping_and_url_only() { - let first = stable_document_id("thoughts", "/zh-hans/thought/1001"); - let second = stable_document_id("thoughts", "/zh-hans/thought/1001"); + let mapping_id = stable_mapping_id("/@{username}/thoughts"); + let first = stable_document_id(&mapping_id, "/@Fuyeor/thoughts"); + let second = stable_document_id(&mapping_id, "/@Fuyeor/thoughts"); assert_eq!(first, second); - assert_ne!( - first, - stable_document_id("thoughts", "/zh-hans/thought/1002") - ); + assert_ne!(first, stable_document_id(&mapping_id, "/@Fuyeor/comments")); } #[test] @@ -141,7 +150,7 @@ mod tests { #[test] fn new_document_starts_before_publication_generation() { let document = SearchDocument::new( - "profile", + stable_mapping_id("/@{username}"), "profile.fon", "/@Fuyeor", "Fuyeor", diff --git a/apps/engine/src/core/pattern.rs b/apps/engine/src/core/pattern.rs index d389891..4cc4095 100644 --- a/apps/engine/src/core/pattern.rs +++ b/apps/engine/src/core/pattern.rs @@ -2,6 +2,28 @@ use super::ast::{CoreResult, RuntimeValue}; +/// Normalize and validate one FRL pattern before it becomes a mapping identity. +pub fn canonical_pattern(pattern: &str) -> CoreResult { + let pattern = pattern.trim(); + if pattern.is_empty() { + return Err("pattern must not be empty".into()); + } + if !pattern.starts_with('/') { + return Err("pattern must start with '/'".into()); + } + if pattern.contains("//") { + return Err("pattern must not contain repeated slashes".into()); + } + if pattern.contains('?') || pattern.contains('#') { + return Err("pattern must not contain a query or fragment".into()); + } + if pattern.chars().any(char::is_control) { + return Err("pattern must not contain control characters".into()); + } + placeholder_names(pattern)?; + Ok(pattern.to_string()) +} + /// Extract and validate unique placeholder names from one FRL pattern. pub fn placeholder_names(pattern: &str) -> CoreResult> { if pattern.is_empty() { @@ -140,9 +162,21 @@ pub fn canonical_path(value: &str) -> String { mod tests { use std::collections::BTreeMap; - use super::expand_pattern; + use super::{canonical_pattern, expand_pattern}; use crate::core::ast::RuntimeValue; + #[test] + fn canonical_pattern_rejects_ambiguous_paths() { + assert!(canonical_pattern("/@{username}//posts").is_err()); + assert!(canonical_pattern("/@{username}?tab=posts").is_err()); + assert!(canonical_pattern("@{username}").is_err()); + } + + #[test] + fn canonical_pattern_preserves_meaningful_trailing_slash() { + assert_eq!(canonical_pattern("/posts/").unwrap(), "/posts/"); + } + #[test] fn expands_array_parameters_to_multiple_paths() { let mut params = BTreeMap::new(); diff --git a/apps/engine/src/crawler/fetcher.rs b/apps/engine/src/crawler/fetcher.rs new file mode 100644 index 0000000..10bf120 --- /dev/null +++ b/apps/engine/src/crawler/fetcher.rs @@ -0,0 +1,266 @@ +// apps/engine/src/crawler/fetcher.rs + +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; +use std::time::Duration; + +use reqwest::header::{ETAG, IF_MODIFIED_SINCE, IF_NONE_MATCH, LAST_MODIFIED, LOCATION}; +use reqwest::{Client, StatusCode, Url}; + +use super::CrawlerResult; +use super::state::ResourceRecord; + +/// Bounds applied to every remote request made from the submitted locator set. +#[derive(Debug, Clone)] +pub struct FetchPolicy { + pub max_response_bytes: usize, + pub connect_timeout: Duration, + pub request_timeout: Duration, + pub max_redirects: usize, + pub allow_private_networks: bool, +} + +impl Default for FetchPolicy { + fn default() -> Self { + Self { + max_response_bytes: 4 * 1024 * 1024, + connect_timeout: Duration::from_secs(5), + request_timeout: Duration::from_secs(30), + max_redirects: 3, + allow_private_networks: false, + } + } +} + +impl FetchPolicy { + /// Enable loopback access only for deterministic local integration tests. + #[cfg(test)] + pub fn for_tests() -> Self { + Self { + allow_private_networks: true, + ..Self::default() + } + } +} + +/// A response body and its cache validators after origin checks and size limits. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FetchedResource { + pub url: Url, + pub status: u16, + pub etag: Option, + pub last_modified: Option, + pub body: Vec, +} + +/// A bounded HTTP client for webmaster-submitted FON locators. +#[derive(Clone)] +pub struct RemoteFetcher { + client: Client, + policy: FetchPolicy, +} + +impl RemoteFetcher { + /// Build a client with manual redirect handling so every hop is revalidated. + pub fn new(policy: FetchPolicy) -> CrawlerResult { + let client = Client::builder() + .connect_timeout(policy.connect_timeout) + .timeout(policy.request_timeout) + .redirect(reqwest::redirect::Policy::none()) + .build()?; + Ok(Self { client, policy }) + } + + /// Fetch one explicitly submitted locator with optional conditional validators. + pub async fn fetch( + &self, + raw_url: &str, + previous: Option<&ResourceRecord>, + ) -> CrawlerResult { + let initial = Url::parse(raw_url)?; + let mut current = initial.clone(); + for redirect_count in 0..=self.policy.max_redirects { + self.validate_target(¤t).await?; + let mut request = self.client.get(current.clone()); + if redirect_count == 0 { + if let Some(previous) = previous { + if let Some(etag) = previous.etag.as_deref() { + request = request.header(IF_NONE_MATCH, etag); + } + if let Some(last_modified) = previous.last_modified.as_deref() { + request = request.header(IF_MODIFIED_SINCE, last_modified); + } + } + } + let mut response = request.send().await?; + if response.status().is_redirection() { + if redirect_count == self.policy.max_redirects { + return Err("remote locator redirect limit exceeded".into()); + } + let location = response + .headers() + .get(LOCATION) + .ok_or_else(|| "remote locator redirect has no location".to_string())? + .to_str()?; + let next = current.join(location)?; + if !same_origin(&initial, &next) { + return Err("cross-origin redirect rejected".into()); + } + current = next; + continue; + } + if response.status() == StatusCode::NOT_MODIFIED { + return Ok(FetchedResource { + url: current, + status: response.status().as_u16(), + etag: header_string(&response, ETAG), + last_modified: header_string(&response, LAST_MODIFIED), + body: Vec::new(), + }); + } + if !response.status().is_success() { + return Err(format!("remote locator returned status {}", response.status()).into()); + } + if response + .content_length() + .is_some_and(|length| length > self.policy.max_response_bytes as u64) + { + return Err("remote locator response exceeds byte limit".into()); + } + let status = response.status().as_u16(); + let etag = header_string(&response, ETAG); + let last_modified = header_string(&response, LAST_MODIFIED); + let mut body = Vec::new(); + while let Some(chunk) = response.chunk().await? { + if body.len().saturating_add(chunk.len()) > self.policy.max_response_bytes { + return Err("remote locator response exceeds byte limit".into()); + } + body.extend_from_slice(&chunk); + } + return Ok(FetchedResource { + url: current, + status, + etag, + last_modified, + body, + }); + } + Err("remote locator redirect loop exhausted".into()) + } + + async fn validate_target(&self, url: &Url) -> CrawlerResult<()> { + if !matches!(url.scheme(), "http" | "https") { + return Err("remote locator must use http or https".into()); + } + if !url.username().is_empty() || url.password().is_some() { + return Err("remote locator must not contain credentials".into()); + } + let host = url + .host_str() + .ok_or_else(|| "remote locator must contain a host".to_string())?; + if self.policy.allow_private_networks { + return Ok(()); + } + let port = url + .port_or_known_default() + .ok_or_else(|| "remote locator must use a known HTTP port".to_string())?; + let addresses = tokio::net::lookup_host((host, port)).await?; + let mut found = false; + for address in addresses { + found = true; + if !is_public_ip(address.ip()) { + return Err("remote locator resolves to a private or reserved address".into()); + } + } + if !found { + return Err("remote locator host did not resolve".into()); + } + Ok(()) + } +} + +fn header_string( + response: &reqwest::Response, + name: reqwest::header::HeaderName, +) -> Option { + response + .headers() + .get(name) + .and_then(|value| value.to_str().ok()) + .map(ToOwned::to_owned) +} + +fn same_origin(left: &Url, right: &Url) -> bool { + left.scheme() == right.scheme() + && left.host() == right.host() + && left.port_or_known_default() == right.port_or_known_default() +} + +fn is_public_ip(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(ip) => is_public_ipv4(ip), + IpAddr::V6(ip) => is_public_ipv6(ip), + } +} + +fn is_public_ipv4(ip: Ipv4Addr) -> bool { + let octets = ip.octets(); + !ip.is_private() + && !ip.is_loopback() + && !ip.is_link_local() + && !ip.is_unspecified() + && !ip.is_broadcast() + && !matches!(octets[0], 0 | 10 | 127 | 224..=255) + && !(octets[0] == 100 && (64..=127).contains(&octets[1])) + && !(octets[0] == 169 && octets[1] == 254) + && !(octets[0] == 192 && octets[1] == 0 && octets[2] == 0) + && !(octets[0] == 192 && octets[1] == 0 && octets[2] == 2) + && !(octets[0] == 192 && octets[1] == 88 && octets[2] == 99) + && !(octets[0] == 198 && (18..=19).contains(&octets[1])) + && !(octets[0] == 198 && octets[1] == 51 && octets[2] == 100) + && !(octets[0] == 203 && octets[1] == 0 && octets[2] == 113) +} + +fn is_public_ipv6(ip: Ipv6Addr) -> bool { + let segments = ip.segments(); + !ip.is_loopback() + && !ip.is_unspecified() + && !ip.is_unique_local() + && !ip.is_unicast_link_local() + && !(segments[0] & 0xff00 == 0xff00) + && !(segments[0] & 0xffc0 == 0xfe80) + && !(segments[0] == 0x2001 && segments[1] == 0x0db8) +} + +#[cfg(test)] +mod tests { + use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + + use reqwest::Url; + + use super::{is_public_ip, same_origin}; + + #[test] + fn rejects_private_and_reserved_addresses() { + assert!(!is_public_ip(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)))); + assert!(!is_public_ip(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)))); + assert!(!is_public_ip(IpAddr::V6(Ipv6Addr::LOCALHOST))); + assert!(is_public_ip(IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)))); + } + + #[test] + fn redirect_origin_comparison_includes_scheme_host_and_port() { + let origin = Url::parse("https://example.com:443/a").unwrap(); + assert!(same_origin( + &origin, + &Url::parse("https://example.com/b").unwrap() + )); + assert!(!same_origin( + &origin, + &Url::parse("http://example.com/a").unwrap() + )); + assert!(!same_origin( + &origin, + &Url::parse("https://other.example/a").unwrap() + )); + } +} diff --git a/apps/engine/src/crawler/mod.rs b/apps/engine/src/crawler/mod.rs new file mode 100644 index 0000000..86d2247 --- /dev/null +++ b/apps/engine/src/crawler/mod.rs @@ -0,0 +1,341 @@ +// apps/engine/src/crawler/mod.rs + +use std::collections::BTreeMap; +use std::path::{Component, Path, PathBuf}; +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +use reqwest::Url; +use tokio::time::{Duration, sleep}; + +use crate::fon::{LoadedCatalog, materialize_row_from_source, parse_mappings, parse_sitemap_rows}; + +mod fetcher; +mod state; + +pub use fetcher::{FetchPolicy, FetchedResource, RemoteFetcher}; +pub use state::CrawlerState; +use state::SuccessUpdate; + +pub type CrawlerResult = Result>; + +/// Limits and retry policy for one explicitly submitted site ingestion. +#[derive(Debug, Clone)] +pub struct CrawlerConfig { + pub refresh_after: u64, + pub lease_seconds: u64, + pub max_backoff_seconds: u64, + pub max_attempts: u32, + pub max_mappings: usize, + pub max_rows: usize, + pub max_documents: usize, +} + +impl Default for CrawlerConfig { + fn default() -> Self { + Self { + refresh_after: 3600, + lease_seconds: 120, + max_backoff_seconds: 3600, + max_attempts: 3, + max_mappings: 256, + max_rows: 100_000, + max_documents: 1_000_000, + } + } +} + +/// Counters for remote resources consumed while building one finite catalog. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct CrawlStats { + pub fetched: usize, + pub not_modified: usize, + pub retries: usize, + pub deferred: usize, +} + +/// A crawler that never discovers links beyond the submitted mapping origin. +#[derive(Clone)] +pub struct Crawler { + fetcher: RemoteFetcher, + state: Arc, + config: CrawlerConfig, +} + +impl Crawler { + /// Create a crawler with durable state and explicit network/resource limits. + pub fn open( + state_path: &Path, + fetch_policy: FetchPolicy, + config: CrawlerConfig, + ) -> CrawlerResult { + if config.max_attempts == 0 { + return Err("crawler max_attempts must be greater than zero".into()); + } + Ok(Self { + fetcher: RemoteFetcher::new(fetch_policy)?, + state: Arc::new(CrawlerState::open(state_path)?), + config, + }) + } + + /// Ingest only the mapping locator explicitly submitted by the webmaster. + pub async fn ingest_mapping(&self, mapping_locator: &str) -> CrawlerResult { + let mapping_url = Url::parse(mapping_locator)?; + let mut stats = CrawlStats::default(); + let mapping_source = self.fetch_text(&mapping_url, &mut stats).await?; + let mapping_path = PathBuf::from(mapping_url.as_str()); + let mappings = parse_mappings(&mapping_source, &mapping_path)?; + if mappings.len() > self.config.max_mappings { + return Err("mapping catalog exceeds mapping limit".into()); + } + + let mut pages = Vec::new(); + let mut total_rows = 0_usize; + for mapping in &mappings { + let data_url = same_origin_url(&mapping_url, &mapping.datas)?; + let rows_source = self.fetch_text(&data_url, &mut stats).await?; + let rows = parse_sitemap_rows(&rows_source, Path::new(data_url.as_str()))?; + total_rows = total_rows.saturating_add(rows.len()); + if total_rows > self.config.max_rows { + return Err("submitted sitemap rows exceed row limit".into()); + } + let mut content_cache: BTreeMap = BTreeMap::new(); + for row in rows { + let content_reference = row_content_reference(&row)?; + let content_url = same_origin_url(&data_url, &content_reference)?; + let content_source = if let Some(source) = content_cache.get(content_url.as_str()) { + source.clone() + } else { + let source = self.fetch_text(&content_url, &mut stats).await?; + content_cache.insert(content_url.to_string(), source.clone()); + source + }; + pages.extend(materialize_row_from_source( + mapping, + &row, + content_url.as_str(), + &content_source, + Path::new(content_url.as_str()), + )?); + if pages.len() > self.config.max_documents { + return Err("expanded documents exceed document limit".into()); + } + } + } + Ok(RemoteCatalog { + catalog: LoadedCatalog { mappings, pages }, + rows: total_rows, + stats, + }) + } + + async fn fetch_text(&self, url: &Url, stats: &mut CrawlStats) -> CrawlerResult { + let mut last_error: Option> = None; + for attempt in 0..self.config.max_attempts { + let previous = self.state.get(url.as_str())?; + let Some(_) = self.state.try_acquire( + url.as_str(), + unix_timestamp(), + self.config.lease_seconds, + )? + else { + stats.deferred = stats.deferred.saturating_add(1); + return Err("remote resource is currently leased".into()); + }; + match self.fetcher.fetch(url.as_str(), previous.as_ref()).await { + Ok(FetchedResource { + url: fetched_url, + status: 304, + etag, + last_modified, + body, + }) => { + let _ = self.state.apply_not_modified( + fetched_url.as_str(), + unix_timestamp(), + self.config.refresh_after, + )?; + let cached = self + .state + .body(fetched_url.as_str())? + .ok_or_else(|| "304 response has no cached body".to_string())?; + stats.not_modified = stats.not_modified.saturating_add(1); + let _ = (etag, last_modified, body); + return String::from_utf8(cached).map_err(Into::into); + } + Ok(FetchedResource { + url: fetched_url, + status, + etag, + last_modified, + body, + }) => { + let text = String::from_utf8(body.clone())?; + self.state.apply_success( + fetched_url.as_str(), + SuccessUpdate { + status, + etag, + last_modified, + body: &body, + fetched_at: unix_timestamp(), + refresh_after: self.config.refresh_after, + }, + )?; + stats.fetched = stats.fetched.saturating_add(1); + stats.retries = stats.retries.saturating_add(attempt as usize); + return Ok(text); + } + Err(error) => { + self.state.apply_failure( + url.as_str(), + unix_timestamp(), + self.config.max_backoff_seconds, + )?; + last_error = Some(error); + if attempt + 1 < self.config.max_attempts { + sleep(Duration::from_millis( + 50_u64.saturating_mul(attempt as u64 + 1), + )) + .await; + } + } + } + } + Err(last_error.unwrap_or_else(|| "remote fetch failed".into())) + } +} + +/// A finite remote catalog ready for the existing authoritative index sync. +#[derive(Debug, Clone)] +pub struct RemoteCatalog { + pub catalog: LoadedCatalog, + pub rows: usize, + pub stats: CrawlStats, +} + +fn row_content_reference(row: &crate::core::ast::RuntimeValue) -> CrawlerResult { + let crate::core::ast::RuntimeValue::Object(object) = row else { + return Err("each sitemap row must be an object".into()); + }; + object + .get("content") + .map(crate::core::ast::RuntimeValue::as_string) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .ok_or_else(|| "sitemap row.content is required".into()) +} + +fn same_origin_url(base: &Url, locator: &Path) -> CrawlerResult { + let raw = locator + .to_str() + .ok_or_else(|| "remote locator must be valid UTF-8".to_string())?; + if locator + .components() + .any(|component| matches!(component, Component::ParentDir)) + { + return Err("submitted locator must not contain parent traversal".into()); + } + let target = if let Ok(target) = Url::parse(raw) { + target + } else if let Some(root_relative) = raw.strip_prefix("./") { + let mut target = base.clone(); + target.set_path(&format!("/{root_relative}")); + target.set_query(None); + target.set_fragment(None); + target + } else if raw.starts_with('/') { + let mut target = base.clone(); + target.set_path(raw); + target.set_query(None); + target.set_fragment(None); + target + } else { + return Err("submitted locator must be absolute or root-relative".into()); + }; + if !same_origin(base, &target) { + return Err("submitted locator crosses site origin".into()); + } + Ok(target) +} + +fn same_origin(left: &Url, right: &Url) -> bool { + left.scheme() == right.scheme() + && left.host() == right.host() + && left.port_or_known_default() == right.port_or_known_default() +} + +fn unix_timestamp() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |duration| duration.as_secs()) +} + +#[cfg(test)] +mod tests { + use std::net::SocketAddr; + + use axum::Router; + use axum::routing::get; + use tempfile::tempdir; + + use super::{Crawler, CrawlerConfig, FetchPolicy}; + + #[tokio::test] + async fn ingests_only_same_origin_submitted_fon_resources() { + let app = Router::new().route( + "/search-patterns.fon", + get(|| async { + "[{ pattern = `/profile/{username}` params = struct { username: string } datas = `/sitemap.fon` }]" + }), + ).route( + "/sitemap.fon", + get(|| async { + "[{ params = { username = `Alice` } content = `/content.fon` }]" + }), + ).route( + "/content.fon", + get(|| async { "{ title = `Alice` content = `# Hello` }" }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address: SocketAddr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + + let root = tempdir().unwrap(); + let crawler = Crawler::open( + &root.path().join("state"), + FetchPolicy::for_tests(), + CrawlerConfig { + max_attempts: 1, + ..CrawlerConfig::default() + }, + ) + .unwrap(); + let result = crawler + .ingest_mapping(&format!("http://{address}/search-patterns.fon")) + .await + .unwrap(); + assert_eq!(result.catalog.mappings.len(), 1); + assert_eq!(result.catalog.pages.len(), 1); + assert_eq!(result.stats.fetched, 3); + } + + #[tokio::test] + async fn rejects_cross_origin_sitemap_locator() { + let root = tempdir().unwrap(); + let crawler = Crawler::open( + &root.path().join("state"), + FetchPolicy::for_tests(), + CrawlerConfig { + max_attempts: 1, + ..CrawlerConfig::default() + }, + ) + .unwrap(); + let result = crawler + .ingest_mapping("http://127.0.0.1:9/search-patterns.fon") + .await; + assert!(result.is_err()); + } +} diff --git a/apps/engine/src/crawler/state.rs b/apps/engine/src/crawler/state.rs new file mode 100644 index 0000000..72145c2 --- /dev/null +++ b/apps/engine/src/crawler/state.rs @@ -0,0 +1,288 @@ +// apps/engine/src/crawler/state.rs + +use std::path::Path; +use std::sync::Arc; + +use redb::{Database, ReadableDatabase, ReadableTable, TableDefinition}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use super::CrawlerResult; + +const RECORDS: TableDefinition<&str, &[u8]> = TableDefinition::new("resource"); +const BODIES: TableDefinition<&str, &[u8]> = TableDefinition::new("body"); + +/// Persisted metadata for one submitted remote resource. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResourceRecord { + pub url: String, + #[serde(default)] + pub etag: Option, + #[serde(default)] + pub last_modified: Option, + pub status: u16, + #[serde(default)] + pub content_hash: String, + pub fetched_at: u64, + pub next_fetch_at: u64, + pub retry_count: u32, + pub lease_until: u64, +} + +/// Input payload for an atomic successful resource update. +pub(crate) struct SuccessUpdate<'a> { + pub status: u16, + pub etag: Option, + pub last_modified: Option, + pub body: &'a [u8], + pub fetched_at: u64, + pub refresh_after: u64, +} + +/// Durable crawler state backed by one redb database file. +#[derive(Clone)] +pub struct CrawlerState { + db: Arc, +} + +impl CrawlerState { + /// Open or create the crawler state database with explicit persistence semantics. + pub fn open(path: &Path) -> CrawlerResult { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let db_path = if path.extension().is_some() { + path.to_path_buf() + } else { + path.join("crawler.redb") + }; + if let Some(parent) = db_path.parent() { + std::fs::create_dir_all(parent)?; + } + let db = Database::create(db_path)?; + Ok(Self { db: Arc::new(db) }) + } + + /// Read the last response metadata for a submitted URL. + pub fn get(&self, url: &str) -> CrawlerResult> { + let read_txn = self.db.begin_read()?; + let table = match read_txn.open_table(RECORDS) { + Ok(table) => table, + Err(redb::TableError::TableDoesNotExist(_)) => return Ok(None), + Err(error) => return Err(error.into()), + }; + table + .get(record_key(url).as_str())? + .map(|value| serde_json::from_slice(value.value())) + .transpose() + .map_err(Into::into) + } + + /// Read the cached response body used when a conditional request returns 304. + pub fn body(&self, url: &str) -> CrawlerResult>> { + let read_txn = self.db.begin_read()?; + let table = match read_txn.open_table(BODIES) { + Ok(table) => table, + Err(redb::TableError::TableDoesNotExist(_)) => return Ok(None), + Err(error) => return Err(error.into()), + }; + Ok(table + .get(body_key(url).as_str())? + .map(|value| value.value().to_vec())) + } + + /// Acquire a process-scoped lease so one resource is fetched by one worker at a time. + pub fn try_acquire( + &self, + url: &str, + now: u64, + lease_seconds: u64, + ) -> CrawlerResult> { + let key = record_key(url); + let write_txn = self.db.begin_write()?; + let mut table = write_txn.open_table(RECORDS)?; + let mut record = table + .get(key.as_str())? + .map(|value| serde_json::from_slice(value.value())) + .transpose()? + .unwrap_or_else(|| ResourceRecord { + url: url.to_string(), + etag: None, + last_modified: None, + status: 0, + content_hash: String::new(), + fetched_at: 0, + next_fetch_at: 0, + retry_count: 0, + lease_until: 0, + }); + if record.lease_until > now { + return Ok(None); + } + record.lease_until = now.saturating_add(lease_seconds); + let serialized = serde_json::to_vec(&record)?; + table.insert(key.as_str(), serialized.as_slice())?; + drop(table); + write_txn.commit()?; + Ok(Some(record)) + } + + /// Atomically persist a successful body and its cache validators. + pub fn apply_success( + &self, + url: &str, + update: SuccessUpdate<'_>, + ) -> CrawlerResult { + let record = ResourceRecord { + url: url.to_string(), + etag: update.etag, + last_modified: update.last_modified, + status: update.status, + content_hash: sha256(update.body), + fetched_at: update.fetched_at, + next_fetch_at: update.fetched_at.saturating_add(update.refresh_after), + retry_count: 0, + lease_until: 0, + }; + let serialized = serde_json::to_vec(&record)?; + let record_key = record_key(url); + let body_key = body_key(url); + let write_txn = self.db.begin_write()?; + { + let mut records = write_txn.open_table(RECORDS)?; + records.insert(record_key.as_str(), serialized.as_slice())?; + let mut bodies = write_txn.open_table(BODIES)?; + bodies.insert(body_key.as_str(), update.body)?; + } + write_txn.commit()?; + Ok(record) + } + + /// Persist a 304 result while retaining the previously cached response body. + pub fn apply_not_modified( + &self, + url: &str, + fetched_at: u64, + refresh_after: u64, + ) -> CrawlerResult> { + let Some(mut record) = self.get(url)? else { + return Ok(None); + }; + record.status = 304; + record.fetched_at = fetched_at; + record.next_fetch_at = fetched_at.saturating_add(refresh_after); + record.retry_count = 0; + record.lease_until = 0; + let serialized = serde_json::to_vec(&record)?; + let write_txn = self.db.begin_write()?; + { + let mut table = write_txn.open_table(RECORDS)?; + table.insert(record_key(url).as_str(), serialized.as_slice())?; + } + write_txn.commit()?; + Ok(Some(record)) + } + + /// Record a failed attempt with bounded exponential backoff and release its lease. + pub fn apply_failure(&self, url: &str, now: u64, max_backoff: u64) -> CrawlerResult<()> { + let current = self.get(url)?; + let mut record = current.unwrap_or_else(|| ResourceRecord { + url: url.to_string(), + etag: None, + last_modified: None, + status: 0, + content_hash: String::new(), + fetched_at: 0, + next_fetch_at: now, + retry_count: 0, + lease_until: 0, + }); + record.retry_count = record.retry_count.saturating_add(1); + let exponent = record.retry_count.min(10); + let delay = 5_u64.saturating_mul(1_u64 << exponent).min(max_backoff); + record.next_fetch_at = now.saturating_add(delay); + record.lease_until = 0; + let serialized = serde_json::to_vec(&record)?; + let write_txn = self.db.begin_write()?; + { + let mut table = write_txn.open_table(RECORDS)?; + table.insert(record_key(url).as_str(), serialized.as_slice())?; + } + write_txn.commit()?; + Ok(()) + } +} + +fn record_key(url: &str) -> String { + format!("resource/{}", hex_digest(url_hash(url))) +} + +fn body_key(url: &str) -> String { + format!("body/{}", hex_digest(url_hash(url))) +} + +fn url_hash(url: &str) -> [u8; 32] { + Sha256::digest(url.as_bytes()).into() +} + +fn sha256(body: &[u8]) -> String { + format!("sha256:{}", hex_digest(Sha256::digest(body))) +} + +fn hex_digest>(digest: D) -> String { + digest + .as_ref() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +#[cfg(test)] +mod tests { + use tempfile::tempdir; + + use super::CrawlerState; + + #[test] + fn lease_and_conditional_body_state_are_persistent() { + let directory = tempdir().unwrap(); + let state = CrawlerState::open(&directory.path().join("state")).unwrap(); + let url = "https://example.com/catalog.fon"; + assert!(state.try_acquire(url, 100, 60).unwrap().is_some()); + assert!(state.try_acquire(url, 101, 60).unwrap().is_none()); + state + .apply_success( + url, + super::SuccessUpdate { + status: 200, + etag: Some("etag-1".to_string()), + last_modified: None, + body: b"catalog", + fetched_at: 100, + refresh_after: 3600, + }, + ) + .unwrap(); + assert_eq!(state.body(url).unwrap().as_deref(), Some(&b"catalog"[..])); + assert_eq!( + state.get(url).unwrap().unwrap().etag.as_deref(), + Some("etag-1") + ); + let unchanged = state.apply_not_modified(url, 200, 3600).unwrap().unwrap(); + assert_eq!(unchanged.status, 304); + assert_eq!(state.body(url).unwrap().as_deref(), Some(&b"catalog"[..])); + } + + #[test] + fn failure_backoff_is_bounded_and_releases_lease() { + let directory = tempdir().unwrap(); + let state = CrawlerState::open(&directory.path().join("state")).unwrap(); + let url = "https://example.com/fail.fon"; + state.try_acquire(url, 100, 60).unwrap(); + state.apply_failure(url, 100, 120).unwrap(); + let record = state.get(url).unwrap().unwrap(); + assert_eq!(record.retry_count, 1); + assert_eq!(record.lease_until, 0); + assert_eq!(record.next_fetch_at, 110); + } +} diff --git a/apps/engine/src/fon.rs b/apps/engine/src/fon.rs index b2c6f7b..6947b7c 100644 --- a/apps/engine/src/fon.rs +++ b/apps/engine/src/fon.rs @@ -10,12 +10,13 @@ use crate::core::ast::{ parse_root_value, }; use crate::core::document::{SearchDocument, stable_mapping_id}; -use crate::core::pattern::{expand_pattern, placeholder_names}; +use crate::core::pattern::{canonical_pattern, expand_pattern, placeholder_names}; /// Describes one pattern-to-sitemap mapping declared by the site. #[derive(Debug, Clone)] pub struct MappingSpec { - pub id: String, + /// Internal key derived from the canonical pattern; never read from FON. + pub mapping_id: String, pub pattern: String, pub datas: PathBuf, } @@ -37,10 +38,7 @@ pub fn load_catalog(root: &Path) -> CoreResult { for mapping in &mappings { let data_path = resolve_site_path(root, &mapping.datas)?; let rows_source = std::fs::read_to_string(&data_path)?; - let rows = parse_root_value(&rows_source, &data_path)?; - let RuntimeValue::Array(rows) = rows else { - return Err(format!("{} must contain a root array", data_path.display()).into()); - }; + let rows = parse_sitemap_rows(&rows_source, &data_path)?; for row in rows { pages.extend(materialize_row(root, mapping, &row)?); } @@ -50,7 +48,7 @@ pub fn load_catalog(root: &Path) -> CoreResult { } /// Parse and semantically inspect the mapping index while preserving schema syntax. -fn parse_mappings(source: &str, path: &Path) -> CoreResult> { +pub(crate) fn parse_mappings(source: &str, path: &Path) -> CoreResult> { let result = parse(source); if result.has_errors() { return Err(format!( @@ -66,7 +64,7 @@ fn parse_mappings(source: &str, path: &Path) -> CoreResult> { .root_array_items() .ok_or_else(|| format!("{} must contain a root array", path.display()))?; let mut mappings = Vec::with_capacity(item_ids.len()); - let mut mapping_ids = BTreeSet::new(); + let mut mapping_patterns = BTreeSet::new(); for item_id in item_ids { let value = result @@ -77,8 +75,19 @@ fn parse_mappings(source: &str, path: &Path) -> CoreResult> { let Value::Object(object) = value else { return Err("each mapping must be an object".into()); }; + for member in &object.members { + let key = result + .document + .ast + .member_key_text(*member) + .ok_or_else(|| "mapping contains an invalid member".to_string())?; + if !matches!(key, "pattern" | "params" | "datas") { + return Err(format!("unsupported mapping field: {key}").into()); + } + } let pattern = object_string_field(&result.document.ast, &object.members, "pattern")? .ok_or_else(|| "mapping.pattern is required".to_string())?; + let pattern = canonical_pattern(&pattern)?; let datas = object_path_field(&result.document.ast, &object.members, "datas")? .ok_or_else(|| "mapping.datas is required".to_string())?; let params = object_field_value(&result.document.ast, &object.members, "params") @@ -112,13 +121,11 @@ fn parse_mappings(source: &str, path: &Path) -> CoreResult> { { return Err("mapping.params fields must exactly match pattern placeholders".into()); } - let id = object_string_field(&result.document.ast, &object.members, "id")? - .unwrap_or_else(|| stable_mapping_id(&pattern, &datas)); - if id.trim().is_empty() || !mapping_ids.insert(id.clone()) { - return Err("mapping ids must be non-empty and unique".into()); + if !mapping_patterns.insert(pattern.clone()) { + return Err("mapping patterns must be unique after canonicalization".into()); } mappings.push(MappingSpec { - id, + mapping_id: stable_mapping_id(&pattern), pattern, datas: PathBuf::from(datas), }); @@ -127,11 +134,47 @@ fn parse_mappings(source: &str, path: &Path) -> CoreResult> { Ok(mappings) } +/// Parse a finite sitemap binding file without fetching or executing its content. +pub(crate) fn parse_sitemap_rows(source: &str, path: &Path) -> CoreResult> { + let value = parse_root_value(source, path)?; + let RuntimeValue::Array(rows) = value else { + return Err(format!("{} must contain a root array", path.display()).into()); + }; + Ok(rows) +} + /// Materialize one sitemap row into one or more concrete pages. fn materialize_row( root: &Path, mapping: &MappingSpec, row: &RuntimeValue, +) -> CoreResult> { + let RuntimeValue::Object(object) = row else { + return Err("each sitemap row must be an object".into()); + }; + let content_reference = object + .get("content") + .map(RuntimeValue::as_string) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "sitemap row.content is required".to_string())?; + let content_path = resolve_site_path(root, Path::new(&content_reference))?; + let content_source = std::fs::read_to_string(&content_path)?; + materialize_row_from_source( + mapping, + row, + &content_reference, + &content_source, + Path::new(&content_reference), + ) +} + +/// Materialize one already-fetched content source for a sitemap row. +pub(crate) fn materialize_row_from_source( + mapping: &MappingSpec, + row: &RuntimeValue, + source: &str, + content_source: &str, + display_path: &Path, ) -> CoreResult> { let RuntimeValue::Object(object) = row else { return Err("each sitemap row must be an object".into()); @@ -146,21 +189,15 @@ fn materialize_row( .get("updated-at") .map(RuntimeValue::as_string) .unwrap_or_default(); - let content_reference = object - .get("content") - .map(RuntimeValue::as_string) - .filter(|value| !value.is_empty()) - .ok_or_else(|| "sitemap row.content is required".to_string())?; - let content_path = resolve_site_path(root, Path::new(&content_reference))?; - let content = parse_content(&content_path)?; + let content = parse_content_source(content_source, display_path)?; let urls = expand_pattern(&mapping.pattern, params)?; Ok(urls .into_iter() .map(|url| { SearchDocument::new( - mapping.id.clone(), - content_reference.clone(), + mapping.mapping_id.clone(), + source.to_string(), url, choose_title(content.title.as_deref(), &content.body), content.body.clone(), @@ -181,10 +218,8 @@ struct ContentPage { graph: Option, } -/// Parse content FON without interpreting HTML; content remains plain text/Markdown. -fn parse_content(path: &Path) -> CoreResult { - let source = std::fs::read_to_string(path)?; - let value = parse_root_value(&source, path)?; +fn parse_content_source(source: &str, path: &Path) -> CoreResult { + let value = parse_root_value(source, path)?; let RuntimeValue::Object(object) = value else { return Err(format!("{} must contain an object", path.display()).into()); }; @@ -253,7 +288,7 @@ fn resolve_site_path(root: &Path, path: &Path) -> CoreResult { mod tests { use std::path::PathBuf; - use super::{choose_title, load_catalog, parse_mappings}; + use super::{choose_title, load_catalog, parse_mappings, stable_mapping_id}; #[test] fn loads_repository_mock_catalog() { @@ -261,9 +296,18 @@ mod tests { let catalog = load_catalog(&root).expect("repository mock catalog must load"); assert_eq!(catalog.mappings.len(), 3); assert_eq!(catalog.pages.len(), 5); - assert_eq!(catalog.mappings[0].id, "profile"); - assert_eq!(catalog.mappings[1].id, "user-tab"); - assert_eq!(catalog.mappings[2].id, "thought"); + assert_eq!( + catalog.mappings[0].mapping_id, + stable_mapping_id("/@{username}") + ); + assert_eq!( + catalog.mappings[1].mapping_id, + stable_mapping_id("/@{username}/{tab}") + ); + assert_eq!( + catalog.mappings[2].mapping_id, + stable_mapping_id("/{locale}/thought/{id}") + ); assert!( catalog .pages @@ -286,10 +330,18 @@ mod tests { } #[test] - fn duplicate_mapping_ids_are_rejected() { + fn duplicate_mapping_patterns_are_rejected() { + let source = r#"[ + { pattern = `/{username}` params = struct { username: string } datas = ./a.fon } + { pattern = `/{username}` params = struct { username: string } datas = ./b.fon } + ]"#; + assert!(parse_mappings(source, std::path::Path::new("mappings.fon")).is_err()); + } + + #[test] + fn explicit_mapping_id_is_rejected() { let source = r#"[ - { id = `same` pattern = `/{username}` params = struct { username: string } datas = ./a.fon } - { id = `same` pattern = `/{locale}` params = struct { locale: string } datas = ./b.fon } + { id = `profile` pattern = `/{username}` params = struct { username: string } datas = ./a.fon } ]"#; assert!(parse_mappings(source, std::path::Path::new("mappings.fon")).is_err()); } @@ -297,7 +349,7 @@ mod tests { #[test] fn mapping_params_must_match_pattern_placeholders() { let source = r#"[ - { id = `profile` pattern = `/{username}` params = struct { account: string } datas = ./a.fon } + { pattern = `/{username}` params = struct { account: string } datas = ./a.fon } ]"#; assert!(parse_mappings(source, std::path::Path::new("mappings.fon")).is_err()); } diff --git a/apps/engine/src/main.rs b/apps/engine/src/main.rs index a5bbf2b..f43b978 100644 --- a/apps/engine/src/main.rs +++ b/apps/engine/src/main.rs @@ -2,6 +2,7 @@ mod config; mod core; +mod crawler; mod fon; mod index; mod model; @@ -11,6 +12,7 @@ mod web; use std::sync::Arc; use config::Config; +use crawler::{Crawler, CrawlerConfig, FetchPolicy}; use search::SearchEngine; use web::{AppState, router}; @@ -19,7 +21,12 @@ use web::{AppState, router}; async fn main() -> Result<(), Box> { let config = Config::from_env()?; let engine = Arc::new(SearchEngine::open(&config.index_root)?); - let state = AppState::new(engine, config.data_root); + let crawler = Arc::new(Crawler::open( + &config.crawler_state_root, + FetchPolicy::default(), + CrawlerConfig::default(), + )?); + let state = AppState::new_with_crawler(engine, config.data_root, crawler); let listener = tokio::net::TcpListener::bind(config.bind_address).await?; println!( "Fetch search API listening on http://{}", diff --git a/apps/engine/src/model.rs b/apps/engine/src/model.rs index 9261285..d873e01 100644 --- a/apps/engine/src/model.rs +++ b/apps/engine/src/model.rs @@ -35,6 +35,7 @@ pub struct IndexStatus { pub mappings: usize, pub generation: u64, pub last_rebuilt_at: String, + pub last_ingested_at: String, } /// Result of one complete sitemap ingestion. @@ -48,4 +49,8 @@ pub struct RebuildReport { pub updated: usize, pub deleted: usize, pub unchanged: usize, + pub fetched: usize, + pub not_modified: usize, + pub retries: usize, + pub deferred: usize, } diff --git a/apps/engine/src/web.rs b/apps/engine/src/web.rs index 8647642..39ef45e 100644 --- a/apps/engine/src/web.rs +++ b/apps/engine/src/web.rs @@ -3,12 +3,13 @@ use std::sync::{Arc, RwLock}; use axum::Router; -use axum::extract::{Query, State}; +use axum::extract::{Json as JsonRequest, Query, State}; use axum::http::StatusCode; use axum::response::{IntoResponse, Json, Response}; use axum::routing::{get, post}; use serde::Deserialize; +use crate::crawler::Crawler; use crate::fon::load_catalog; use crate::model::{IndexStatus, RebuildReport, SearchResponse}; use crate::search::SearchEngine; @@ -18,6 +19,7 @@ use crate::search::SearchEngine; pub struct AppState { pub engine: Arc, pub mock_root: std::path::PathBuf, + pub crawler: Option>, metadata: Arc>, } @@ -25,13 +27,31 @@ pub struct AppState { struct IndexMetadata { mappings: usize, last_rebuilt_at: String, + last_ingested_at: String, } impl AppState { + /// Build a local-only state used by fixture tests and development rebuilds. + #[cfg(test)] pub fn new(engine: Arc, mock_root: std::path::PathBuf) -> Self { Self { engine, mock_root, + crawler: None, + metadata: Arc::new(RwLock::new(IndexMetadata::default())), + } + } + + /// Build the production-shaped state with a durable submitted-locator crawler. + pub fn new_with_crawler( + engine: Arc, + mock_root: std::path::PathBuf, + crawler: Arc, + ) -> Self { + Self { + engine, + mock_root, + crawler: Some(crawler), metadata: Arc::new(RwLock::new(IndexMetadata::default())), } } @@ -43,6 +63,7 @@ pub fn router(state: AppState) -> Router { .route("/search", get(search)) .route("/index/status", get(index_status)) .route("/index/rebuild", post(rebuild_index)) + .route("/index/ingest", post(ingest_index)) .with_state(state) } @@ -53,6 +74,11 @@ pub struct SearchQuery { pub limit: Option, } +#[derive(Debug, Deserialize)] +pub struct IngestRequest { + pub mapping_url: String, +} + /// Execute a bounded full-text query and return a stable JSON envelope. async fn search( State(state): State, @@ -77,6 +103,7 @@ async fn index_status(State(state): State) -> Result mappings: metadata.mappings, generation: state.engine.generation()?, last_rebuilt_at: metadata.last_rebuilt_at.clone(), + last_ingested_at: metadata.last_ingested_at.clone(), })) } @@ -99,6 +126,43 @@ async fn rebuild_index(State(state): State) -> Result, + JsonRequest(request): JsonRequest, +) -> Result, ApiError> { + let crawler = state + .crawler + .as_ref() + .ok_or_else(|| "remote ingestion is not enabled".to_string())?; + let remote = crawler.ingest_mapping(&request.mapping_url).await?; + let sync = state.engine.sync_documents(&remote.catalog.pages)?; + let mut metadata = state + .metadata + .write() + .map_err(|_| "index metadata lock poisoned")?; + metadata.mappings = remote.catalog.mappings.len(); + metadata.last_ingested_at = unix_timestamp().to_string(); + Ok(Json(RebuildReport { + mappings: remote.catalog.mappings.len(), + rows: remote.rows, + documents: remote.catalog.pages.len(), + generation: sync.generation, + added: sync.added, + updated: sync.updated, + deleted: sync.deleted, + unchanged: sync.unchanged, + fetched: remote.stats.fetched, + not_modified: remote.stats.not_modified, + retries: remote.stats.retries, + deferred: remote.stats.deferred, })) } @@ -119,6 +183,12 @@ impl From> for ApiError { } } +impl From for ApiError { + fn from(error: String) -> Self { + Self(std::io::Error::other(error).into()) + } +} + impl From<&'static str> for ApiError { fn from(error: &'static str) -> Self { Self(std::io::Error::other(error).into()) @@ -215,4 +285,68 @@ mod tests { .is_some_and(|url| url.contains("thought")) ); } + + #[tokio::test] + async fn ingest_endpoint_publishes_remote_submitted_catalog() { + let remote_app = axum::Router::new() + .route( + "/search-patterns.fon", + axum::routing::get(|| async { + "[{ pattern = `/profile/{username}` params = struct { username: string } datas = `/sitemap.fon` }]" + }), + ) + .route( + "/sitemap.fon", + axum::routing::get(|| async { + "[{ params = { username = `Alice` } content = `/content.fon` }]" + }), + ) + .route( + "/content.fon", + axum::routing::get(|| async { "{ title = `Alice` content = `# Hello` }" }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, remote_app).await.unwrap() }); + + let index_dir = tempfile::tempdir().expect("temporary index directory must be created"); + let state_dir = tempfile::tempdir().expect("temporary crawler state must be created"); + let engine = + Arc::new(SearchEngine::open(index_dir.path()).expect("temporary index must open")); + let crawler = Arc::new( + crate::crawler::Crawler::open( + &state_dir.path().join("state"), + crate::crawler::FetchPolicy::for_tests(), + crate::crawler::CrawlerConfig { + max_attempts: 1, + ..crate::crawler::CrawlerConfig::default() + }, + ) + .expect("crawler must open"), + ); + let app = router(AppState::new_with_crawler( + engine, + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."), + crawler, + )); + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/index/ingest") + .header("content-type", "application/json") + .body(Body::from(format!( + "{{\"mapping_url\":\"http://{address}/search-patterns.fon\"}}" + ))) + .unwrap(), + ) + .await + .expect("ingest response must be returned"); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["documents"], 1); + assert_eq!(json["fetched"], 3); + assert_eq!(json["generation"], 1); + } } diff --git a/well-known/search-patterns.fon b/well-known/search-patterns.fon index da2de7d..240e153 100644 --- a/well-known/search-patterns.fon +++ b/well-known/search-patterns.fon @@ -1,7 +1,6 @@ // well-known/search-patterns.fon [ { - id = `profile` pattern = `/@{username}` params = struct { username: string @@ -9,7 +8,6 @@ datas = ./well-known/sitemaps/users.fon } { - id = `user-tab` pattern = `/@{username}/{tab}` params = struct { username: string @@ -18,7 +16,6 @@ datas = ./well-known/sitemaps/user-tabs.fon } { - id = `thought` pattern = `/{locale}/thought/{id}` params = struct { locale: enum { en, fr, es, zh-hans } From 438e2df575137bc65669d7cde08149df6d86f7b3 Mon Sep 17 00:00:00 2001 From: Manus AI Date: Fri, 28 Aug 2026 01:33:20 +0000 Subject: [PATCH 8/9] fix: Quarantine incompatible Tantivy indexes --- apps/engine/src/search.rs | 81 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 76 insertions(+), 5 deletions(-) diff --git a/apps/engine/src/search.rs b/apps/engine/src/search.rs index 189dcb8..712c127 100644 --- a/apps/engine/src/search.rs +++ b/apps/engine/src/search.rs @@ -115,14 +115,25 @@ impl SearchEngine { pub fn open(index_path: &Path) -> AppResult { std::fs::create_dir_all(index_path)?; let manifest_path = index_path.join("manifest.json"); - let initial_generation = load_manifest(&manifest_path)?.generation; let (new_schema, new_fields) = build_schema(); - let (index, fields) = if index_path.join("meta.json").exists() { + let (initial_generation, index, fields) = if index_path.join("meta.json").exists() { let index = Index::open(MmapDirectory::open(index_path)?)?; - let fields = fields_from_schema(&index.schema())?; - (index, fields) + match fields_from_schema(&index.schema()) { + Ok(fields) => (load_manifest(&manifest_path)?.generation, index, fields), + Err(error) => { + drop(index); + quarantine_incompatible_index(index_path, error.as_ref())?; + let index = Index::create_in_dir(index_path, new_schema.clone())?; + (0, index, new_fields) + } + } } else { - (Index::create_in_dir(index_path, new_schema)?, new_fields) + let initial_generation = load_manifest(&manifest_path)?.generation; + ( + initial_generation, + Index::create_in_dir(index_path, new_schema)?, + new_fields, + ) }; index.tokenizers().register( "jieba_search", @@ -322,12 +333,49 @@ impl SearchEngine { } } +/// Move an incompatible Tantivy directory aside before creating the current schema. +fn quarantine_incompatible_index( + index_path: &Path, + error: &dyn std::error::Error, +) -> AppResult<()> { + let parent = index_path + .parent() + .ok_or_else(|| "index path has no parent directory".to_string())?; + let name = index_path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| "index path has no valid directory name".to_string())?; + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |duration| duration.as_secs()); + let mut backup = parent.join(format!("{name}.incompatible-{timestamp}")); + let mut suffix = 0_u32; + while backup.exists() { + suffix = suffix.saturating_add(1); + backup = parent.join(format!("{name}.incompatible-{timestamp}-{suffix}")); + } + std::fs::rename(index_path, &backup).map_err(|rename_error| { + format!( + "incompatible Tantivy schema ({error}); failed to quarantine old index at {}: {rename_error}", + backup.display() + ) + })?; + std::fs::create_dir_all(index_path)?; + eprintln!( + "Quarantined incompatible Tantivy index ({error}) at {}", + backup.display() + ); + Ok(()) +} + #[cfg(test)] mod tests { use tempfile::tempdir; use super::{JiebaTokenizer, SearchEngine}; use crate::core::document::SearchDocument; + use tantivy::Index; + use tantivy::schema::{STORED, Schema}; use tantivy::tokenizer::{TokenStream, Tokenizer}; #[test] @@ -379,6 +427,29 @@ mod tests { assert_eq!(engine.document_count().unwrap(), 1); } + #[test] + fn incompatible_schema_is_quarantined_and_recreated() { + let parent = tempdir().unwrap(); + let index_path = parent.path().join("index"); + std::fs::create_dir_all(&index_path).unwrap(); + let mut schema_builder = Schema::builder(); + schema_builder.add_text_field("title", STORED); + let old_index = Index::create_in_dir(&index_path, schema_builder.build()).unwrap(); + drop(old_index); + + let engine = SearchEngine::open(&index_path).unwrap(); + assert_eq!(engine.document_count().unwrap(), 0); + assert!( + std::fs::read_dir(parent.path()) + .unwrap() + .flatten() + .any(|entry| entry + .file_name() + .to_string_lossy() + .starts_with("index.incompatible-")) + ); + } + #[test] fn missing_authoritative_document_is_deleted() { let directory = tempdir().unwrap(); From 23c105e69aa8f4409752ae5f526d34b15f04157f Mon Sep 17 00:00:00 2001 From: Manus AI Date: Fri, 28 Aug 2026 03:17:28 +0000 Subject: [PATCH 9/9] feat: Index Markdown plain text projection --- Cargo.lock | 6 ++++++ apps/engine/Cargo.toml | 1 + apps/engine/src/core/document.rs | 23 +++++++++++++++++++++++ apps/engine/src/core/markdown.rs | 31 +++++++++++++++++++++++++++++++ apps/engine/src/core/mod.rs | 1 + apps/engine/src/index/schema.rs | 12 ++++++++---- apps/engine/src/search.rs | 32 ++++++++++++++++++++++++++++---- 7 files changed, 98 insertions(+), 8 deletions(-) create mode 100644 apps/engine/src/core/markdown.rs diff --git a/Cargo.lock b/Cargo.lock index a09518a..9d9892a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -507,6 +507,7 @@ name = "fetch-engine" version = "0.1.0" dependencies = [ "axum", + "ffm-parser", "fon-parser", "futures-util", "jieba-rs", @@ -521,6 +522,11 @@ dependencies = [ "tower", ] +[[package]] +name = "ffm-parser" +version = "0.1.0" +source = "git+https://github.com/Fuyeor/markdown-parser.git?branch=main#2b1ccb841aa58a453d889dfff7fd2ad70a1594f9" + [[package]] name = "find-msvc-tools" version = "0.1.11" diff --git a/apps/engine/Cargo.toml b/apps/engine/Cargo.toml index 933990f..3d05d1f 100644 --- a/apps/engine/Cargo.toml +++ b/apps/engine/Cargo.toml @@ -8,6 +8,7 @@ license = "MIT" [dependencies] axum = "0.8.9" +ffm-parser = { git = "https://github.com/Fuyeor/markdown-parser.git", branch = "main", package = "ffm-parser" } fon-parser = { git = "https://github.com/Fuyeor/fon-parser.git", branch = "main", package = "fon-parser" } futures-util = "0.3.31" jieba-rs = "0.10.3" diff --git a/apps/engine/src/core/document.rs b/apps/engine/src/core/document.rs index d892ae1..87d07d7 100644 --- a/apps/engine/src/core/document.rs +++ b/apps/engine/src/core/document.rs @@ -2,6 +2,8 @@ use sha2::{Digest, Sha256}; +use crate::core::markdown::to_search_text; + /// A canonical searchable document independent of the index implementation. #[derive(Debug, Clone, PartialEq, Eq)] pub struct SearchDocument { @@ -11,7 +13,10 @@ pub struct SearchDocument { pub source: String, pub url: String, pub title: String, + /// Original Markdown/FFM content retained for snippets and presentation. pub body: String, + /// Parser-derived visible text used exclusively for full-text indexing. + pub search_text: String, pub updated_at: String, pub images: Vec, pub graph: Option, @@ -38,6 +43,7 @@ impl SearchDocument { let title = title.into(); let body = body.into(); let updated_at = updated_at.into(); + let search_text = to_search_text(&body); let document_id = stable_document_id(&mapping_id, &url); let content_hash = content_hash(&title, &body, &updated_at, &images, graph.as_deref()); Self { @@ -47,6 +53,7 @@ impl SearchDocument { url, title, body, + search_text, updated_at, images, graph, @@ -147,6 +154,22 @@ mod tests { assert_ne!(base, changed); } + #[test] + fn search_text_is_derived_from_markdown_without_changing_hash_input() { + let document = SearchDocument::new( + stable_mapping_id("/search"), + "content.fon", + "/search", + "Search", + "# Search\n\nUse **SPP**.", + "2026-08-30", + Vec::new(), + None, + ); + assert_eq!(document.search_text, "Search\nUse SPP."); + assert!(document.content_hash.starts_with("sha256:")); + } + #[test] fn new_document_starts_before_publication_generation() { let document = SearchDocument::new( diff --git a/apps/engine/src/core/markdown.rs b/apps/engine/src/core/markdown.rs new file mode 100644 index 0000000..b541608 --- /dev/null +++ b/apps/engine/src/core/markdown.rs @@ -0,0 +1,31 @@ +// apps/engine/src/core/markdown.rs + +use ffm_parser::{ParserOptions, create_fuyeor_markdown_parser, to_plain_text}; + +/// Convert Markdown/FFM into visible text for full-text indexing. +pub fn to_search_text(markdown: &str) -> String { + let parser = create_fuyeor_markdown_parser(ParserOptions::default()); + let ast = parser.parse(markdown); + to_plain_text(&ast) +} + +#[cfg(test)] +mod tests { + use super::to_search_text; + + #[test] + fn removes_markdown_presentation_noise_but_keeps_visible_text() { + assert_eq!( + to_search_text("# Search\n\nUse **SPP** and [FON](https://example.com)."), + "Search\nUse SPP and FON." + ); + } + + #[test] + fn treats_html_as_text_and_keeps_code_content() { + assert_eq!( + to_search_text("HTML\n\n```rust\nlet query = \"search\";\n```"), + "HTML\nlet query = \"search\";" + ); + } +} diff --git a/apps/engine/src/core/mod.rs b/apps/engine/src/core/mod.rs index 051ce43..bbfa322 100644 --- a/apps/engine/src/core/mod.rs +++ b/apps/engine/src/core/mod.rs @@ -2,4 +2,5 @@ pub mod ast; pub mod document; +pub mod markdown; pub mod pattern; diff --git a/apps/engine/src/index/schema.rs b/apps/engine/src/index/schema.rs index 9895b17..c3ff58b 100644 --- a/apps/engine/src/index/schema.rs +++ b/apps/engine/src/index/schema.rs @@ -14,7 +14,8 @@ pub(crate) struct SearchFields { pub(crate) source: Field, pub(crate) url: Field, pub(crate) title: Field, - pub(crate) content: Field, + pub(crate) content_raw: Field, + pub(crate) content_search: Field, pub(crate) updated_at: Field, pub(crate) image_url: Field, pub(crate) graph: Field, @@ -37,7 +38,8 @@ pub(crate) fn build_schema() -> (Schema, SearchFields) { let source = builder.add_text_field("source", STRING | STORED); let url = builder.add_text_field("url", STRING | STORED); let title = builder.add_text_field("title", text_options.clone()); - let content = builder.add_text_field("content", text_options); + let content_raw = builder.add_text_field("content_raw", STORED); + let content_search = builder.add_text_field("content_search", text_options); let updated_at = builder.add_text_field("updated_at", STRING | STORED); let image_url = builder.add_text_field("image_url", STRING | STORED); let graph = builder.add_text_field("graph", STRING | STORED); @@ -52,7 +54,8 @@ pub(crate) fn build_schema() -> (Schema, SearchFields) { source, url, title, - content, + content_raw, + content_search, updated_at, image_url, graph, @@ -70,7 +73,8 @@ pub(crate) fn fields_from_schema(schema: &Schema) -> IndexResult { source: schema.get_field("source")?, url: schema.get_field("url")?, title: schema.get_field("title")?, - content: schema.get_field("content")?, + content_raw: schema.get_field("content_raw")?, + content_search: schema.get_field("content_search")?, updated_at: schema.get_field("updated_at")?, image_url: schema.get_field("image_url")?, graph: schema.get_field("graph")?, diff --git a/apps/engine/src/search.rs b/apps/engine/src/search.rs index 712c127..6ce6ce3 100644 --- a/apps/engine/src/search.rs +++ b/apps/engine/src/search.rs @@ -255,8 +255,10 @@ impl SearchEngine { } let limit = limit.clamp(1, MAX_PAGE_SIZE); let searcher = self.reader.searcher(); - let parser = - QueryParser::for_index(&self.index, vec![self.fields.title, self.fields.content]); + let parser = QueryParser::for_index( + &self.index, + vec![self.fields.title, self.fields.content_search], + ); let parsed_query = parser.parse_query(query)?; let total = searcher.search(&parsed_query, &Count)?; let top_docs = searcher.search( @@ -273,7 +275,7 @@ impl SearchEngine { mapping_id: first_text(&document, self.fields.mapping_id), url: first_text(&document, self.fields.url), title: first_text(&document, self.fields.title), - snippet: first_text(&document, self.fields.content) + snippet: first_text(&document, self.fields.content_raw) .chars() .take(180) .collect(), @@ -319,7 +321,8 @@ impl SearchEngine { document.add_text(self.fields.source, &page.source); document.add_text(self.fields.url, &page.url); document.add_text(self.fields.title, &page.title); - document.add_text(self.fields.content, &page.body); + document.add_text(self.fields.content_raw, &page.body); + document.add_text(self.fields.content_search, &page.search_text); document.add_text(self.fields.updated_at, &page.updated_at); document.add_text(self.fields.content_hash, &page.content_hash); document.add_text(self.fields.generation, page.generation.to_string()); @@ -427,6 +430,27 @@ mod tests { assert_eq!(engine.document_count().unwrap(), 1); } + #[test] + fn search_uses_plain_text_and_preserves_raw_markdown_snippet() { + let directory = tempdir().unwrap(); + let engine = SearchEngine::open(directory.path()).unwrap(); + let page = SearchDocument::new( + "mapping", + "content.fon", + "/markdown", + "Markdown page", + "# Search heading\n\nUse **SPP** for [FON](https://example.com).", + "2026-08-30", + Vec::new(), + None, + ); + engine.sync_documents(std::slice::from_ref(&page)).unwrap(); + + let response = engine.search("Search heading", 0, 10).unwrap(); + assert_eq!(response.total, 1); + assert_eq!(response.results[0].snippet, page.body); + } + #[test] fn incompatible_schema_is_quarantined_and_recreated() { let parent = tempdir().unwrap();