diff --git a/Cargo.lock b/Cargo.lock
@@ -53,7 +53,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
- "windows-sys",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -64,7 +64,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
- "windows-sys",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -73,6 +73,67 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
+[[package]]
+name = "axum"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
+dependencies = [
+ "axum-core",
+ "base64",
+ "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",
+ "sha1",
+ "sync_wrapper",
+ "tokio",
+ "tokio-tungstenite",
+ "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"
@@ -109,6 +170,21 @@ version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
+[[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 = "bytes"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
+
[[package]]
name = "caseless"
version = "0.2.2"
@@ -155,7 +231,7 @@ dependencies = [
"heck",
"proc-macro2",
"quote",
- "syn",
+ "syn 3.0.3",
]
[[package]]
@@ -195,7 +271,16 @@ checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c"
dependencies = [
"encode_unicode",
"libc",
- "windows-sys",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
]
[[package]]
@@ -207,12 +292,38 @@ dependencies = [
"cfg-if",
]
+[[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 = "data-encoding"
+version = "2.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
+
[[package]]
name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
+[[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 = "encode_unicode"
version = "1.0.0"
@@ -238,7 +349,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
- "windows-sys",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -258,6 +369,15 @@ version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
+[[package]]
+name = "file-id"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1fc6a637b6dc58414714eddd9170ff187ecb0933d4c7024d1abbd23a3cc26e9"
+dependencies = [
+ "windows-sys 0.60.2",
+]
+
[[package]]
name = "finl_unicode"
version = "1.4.0"
@@ -280,6 +400,86 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+[[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 = "fsevent-sys"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
+
+[[package]]
+name = "futures-sink"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307"
+
+[[package]]
+name = "futures-task"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
+
+[[package]]
+name = "futures-util"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+ "futures-task",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi 5.3.0",
+ "wasip2",
+]
+
[[package]]
name = "getrandom"
version = "0.4.3"
@@ -288,7 +488,7 @@ checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
- "r-efi",
+ "r-efi 6.0.0",
]
[[package]]
@@ -303,6 +503,92 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+[[package]]
+name = "http"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
+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.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "http-range-header"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
+
+[[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 = "indexmap"
version = "2.14.0"
@@ -313,6 +599,26 @@ dependencies = [
"hashbrown",
]
+[[package]]
+name = "inotify"
+version = "0.11.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "153be1941a183ec9ccd095ddbe17a8b8d435ef6c76e9e02451b933c3999af2c8"
+dependencies = [
+ "bitflags",
+ "inotify-sys",
+ "libc",
+]
+
+[[package]]
+name = "inotify-sys"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d"
+dependencies = [
+ "libc",
+]
+
[[package]]
name = "insta"
version = "1.48.0"
@@ -343,6 +649,26 @@ version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47f142fe24a9c9944451e8349de0a56af5f3e7226dc46f3ed4d4ecc0b85af75e"
+[[package]]
+name = "kqueue"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5"
+dependencies = [
+ "kqueue-sys",
+ "libc",
+]
+
+[[package]]
+name = "kqueue-sys"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087"
+dependencies = [
+ "bitflags",
+ "libc",
+]
+
[[package]]
name = "libc"
version = "0.2.189"
@@ -361,6 +687,18 @@ version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+[[package]]
+name = "log"
+version = "0.4.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
+
+[[package]]
+name = "matchit"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
+
[[package]]
name = "memchr"
version = "2.8.3"
@@ -373,6 +711,22 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b"
+[[package]]
+name = "mime"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+
+[[package]]
+name = "mime_guess"
+version = "2.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
+dependencies = [
+ "mime",
+ "unicase",
+]
+
[[package]]
name = "minijinja"
version = "2.21.0"
@@ -393,6 +747,58 @@ dependencies = [
"simd-adler32",
]
+[[package]]
+name = "mio"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
+dependencies = [
+ "libc",
+ "log",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "notify"
+version = "8.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3"
+dependencies = [
+ "bitflags",
+ "fsevent-sys",
+ "inotify",
+ "kqueue",
+ "libc",
+ "log",
+ "mio",
+ "notify-types",
+ "walkdir",
+ "windows-sys 0.60.2",
+]
+
+[[package]]
+name = "notify-debouncer-full"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c02b49179cfebc9932238d04d6079912d26de0379328872846118a0fa0dbb302"
+dependencies = [
+ "file-id",
+ "log",
+ "notify",
+ "notify-types",
+ "walkdir",
+]
+
+[[package]]
+name = "notify-types"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a"
+dependencies = [
+ "bitflags",
+]
+
[[package]]
name = "num-conv"
version = "0.2.2"
@@ -411,6 +817,12 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+[[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"
@@ -450,6 +862,12 @@ 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 = "plist"
version = "1.10.0"
@@ -469,6 +887,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
[[package]]
name = "proc-macro2"
version = "1.0.107"
@@ -496,12 +923,47 @@ dependencies = [
"proc-macro2",
]
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
[[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.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
+dependencies = [
+ "rand_chacha",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
+dependencies = [
+ "ppv-lite86",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
+dependencies = [
+ "getrandom 0.3.4",
+]
+
[[package]]
name = "regex-automata"
version = "0.4.16"
@@ -535,9 +997,15 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
- "windows-sys",
+ "windows-sys 0.61.2",
]
+[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
[[package]]
name = "same-file"
version = "1.0.6"
@@ -574,7 +1042,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 3.0.3",
]
[[package]]
@@ -590,6 +1058,17 @@ dependencies = [
"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_spanned"
version = "1.1.1"
@@ -599,6 +1078,39 @@ dependencies = [
"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 = "sha1"
+version = "0.10.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
[[package]]
name = "simd-adler32"
version = "0.3.10"
@@ -617,24 +1129,45 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
+[[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 = "sqzass"
version = "0.1.0"
dependencies = [
"anyhow",
+ "axum",
"clap",
"comrak",
"insta",
"minijinja",
+ "notify",
+ "notify-debouncer-full",
"serde",
"syntect",
+ "tokio",
"toml",
+ "tower-http",
"two-face",
"walkdir",
]
@@ -645,6 +1178,17 @@ 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.3"
@@ -656,6 +1200,12 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "sync_wrapper"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
+
[[package]]
name = "syntect"
version = "5.3.0"
@@ -684,10 +1234,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
- "getrandom",
+ "getrandom 0.4.3",
"once_cell",
"rustix",
- "windows-sys",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -707,7 +1257,7 @@ checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 3.0.3",
]
[[package]]
@@ -755,6 +1305,58 @@ 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",
+ "signal-hook-registry",
+ "socket2",
+ "tokio-macros",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "tokio-tungstenite"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
+dependencies = [
+ "futures-util",
+ "log",
+ "tokio",
+ "tungstenite",
+]
+
+[[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",
+ "pin-project-lite",
+ "tokio",
+]
+
[[package]]
name = "toml"
version = "1.1.3+spec-1.1.0"
@@ -794,6 +1396,95 @@ version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
+[[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-http"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b11f75e912b0c2be01b63d8cf8057b8c3f97cf34abb3d431a3a4c8675498e233"
+dependencies = [
+ "bitflags",
+ "bytes",
+ "futures-core",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "http-range-header",
+ "httpdate",
+ "mime",
+ "mime_guess",
+ "percent-encoding",
+ "pin-project-lite",
+ "tokio",
+ "tokio-util",
+ "tower-layer",
+ "tower-service",
+]
+
+[[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 = "tungstenite"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
+dependencies = [
+ "bytes",
+ "data-encoding",
+ "http",
+ "httparse",
+ "log",
+ "rand",
+ "sha1",
+ "thiserror",
+]
+
[[package]]
name = "two-face"
version = "0.5.1"
@@ -811,6 +1502,18 @@ version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a"
+[[package]]
+name = "typenum"
+version = "1.20.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
+
+[[package]]
+name = "unicase"
+version = "2.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
+
[[package]]
name = "unicode-ident"
version = "1.0.24"
@@ -832,6 +1535,12 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
[[package]]
name = "walkdir"
version = "2.5.0"
@@ -842,13 +1551,28 @@ dependencies = [
"winapi-util",
]
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasip2"
+version = "1.0.3+wasi-0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
+dependencies = [
+ "wit-bindgen",
+]
+
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
- "windows-sys",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -857,6 +1581,15 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+[[package]]
+name = "windows-sys"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
+dependencies = [
+ "windows-targets",
+]
+
[[package]]
name = "windows-sys"
version = "0.61.2"
@@ -866,12 +1599,83 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "windows-targets"
+version = "0.53.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
+dependencies = [
+ "windows-link",
+ "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.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
+
[[package]]
name = "winnow"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
+[[package]]
+name = "wit-bindgen"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+
[[package]]
name = "yaml-rust"
version = "0.4.5"
@@ -881,6 +1685,26 @@ dependencies = [
"linked-hash-map",
]
+[[package]]
+name = "zerocopy"
+version = "0.8.48"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.48"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
[[package]]
name = "zmij"
version = "1.0.23"
diff --git a/Cargo.toml b/Cargo.toml
@@ -22,6 +22,7 @@ path = "src/main.rs"
[dependencies]
# --- M0 ---
anyhow = "1"
+axum = { version = "0.8", features = ["ws"] }
clap = { version = "4.6", features = ["derive"] }
# ⚠️ comrak의 default = ["cli", "syntect-onig", "bon"] 이다. 그대로 쓰면
# (1) comrak 자체 CLI 의존성(clap/shell-words/xdg/fmt2io)이 딸려오고
@@ -31,9 +32,13 @@ clap = { version = "4.6", features = ["derive"] }
# 구현하므로(업스트림 #330이 재작업 대상으로 지적) 필요 없다.
comrak = { version = "0.54", default-features = false }
minijinja = "2.21"
+notify = "8.2"
+notify-debouncer-full = "0.7"
serde = { version = "1", features = ["derive"] }
syntect = { version = "5.3", default-features = false, features = ["default-fancy"] }
+tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "signal"] }
toml = "1.1"
+tower-http = { version = "0.7", features = ["fs"] }
two-face = { version = "0.5", default-features = false, features = ["syntect-fancy"] }
walkdir = "2"
diff --git a/src/lib.rs b/src/lib.rs
@@ -5,10 +5,12 @@ pub mod content;
pub mod highlight;
pub mod markdown;
pub mod render;
+pub mod serve;
pub mod site;
use anyhow::{Context, Result};
use minijinja::context;
+use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use config::Config;
@@ -37,7 +39,24 @@ pub struct BuildStats {
pub output_dir: PathBuf,
}
-pub fn build(opts: &BuildOptions) -> Result<BuildStats> {
+/// 빌드 산출물을 메모리에 담는다.
+///
+/// 개발 서버가 디스크를 거치지 않고 이걸 그대로 서빙한다. 그래서 브라우저가 반쯤
+/// 쓰인 파일을 읽는 일이 없고, 저장할 때마다 디스크에 쓰기가 증폭되지도 않는다.
+#[derive(Debug, Default)]
+pub struct BuildOutput {
+ /// 출력 디렉터리 기준 상대 경로 → 내용
+ pub files: BTreeMap<String, Vec<u8>>,
+}
+
+impl BuildOutput {
+ pub fn pages(&self) -> usize {
+ self.files.keys().filter(|k| k.ends_with(".html")).count()
+ }
+}
+
+/// 사이트를 빌드해 메모리에 담는다. 디스크는 건드리지 않는다.
+pub fn build_to_memory(opts: &BuildOptions) -> Result<BuildOutput> {
let root = &opts.input;
let mut cfg = Config::load(root)?;
if let Some(base) = &opts.base_url {
@@ -45,7 +64,6 @@ pub fn build(opts: &BuildOptions) -> Result<BuildStats> {
}
let drafts = opts.drafts || cfg.build.drafts;
- let out_dir = resolve_output_dir(opts, &cfg);
let pages = content::discover(root, &cfg, drafts)?;
let site = Site::build(&pages, &cfg);
@@ -59,26 +77,18 @@ pub fn build(opts: &BuildOptions) -> Result<BuildStats> {
None
};
- // 출력 디렉터리를 매번 새로 만든다. 지운 페이지가 유령으로 남는 걸 막는다.
- if out_dir.exists() {
- std::fs::remove_dir_all(&out_dir)
- .with_context(|| format!("{}을(를) 비울 수 없습니다", out_dir.display()))?;
- }
+ let mut out = BuildOutput::default();
// 하이라이트 스타일시트는 테마에서 생성한다. HTML은 클래스만 담고 있으므로
// 이 파일 하나를 바꾸면 사이트 전체의 코드 색이 바뀐다.
if cfg.highlight.enabled {
let css = highlight::stylesheet(&cfg.highlight)?;
- let dest = out_dir.join(HIGHLIGHT_CSS_URL.trim_start_matches('/'));
- if let Some(parent) = dest.parent() {
- std::fs::create_dir_all(parent)
- .with_context(|| format!("{}을(를) 만들 수 없습니다", parent.display()))?;
- }
- std::fs::write(&dest, css)
- .with_context(|| format!("{}을(를) 쓸 수 없습니다", dest.display()))?;
+ out.files.insert(
+ HIGHLIGHT_CSS_URL.trim_start_matches('/').to_string(),
+ css.into_bytes(),
+ );
}
- let mut written = 0usize;
for page in &pages {
let html = render_page(
page,
@@ -88,23 +98,43 @@ pub fn build(opts: &BuildOptions) -> Result<BuildStats> {
&md,
highlight_css_url.as_deref(),
)?;
- let dest = out_dir.join(&page.out_path);
+ out.files.insert(
+ page.out_path.to_string_lossy().replace('\\', "/"),
+ html.into_bytes(),
+ );
+ }
+
+ // GitHub Pages는 이 파일이 없으면 출력을 Jekyll로 한 번 더 굴려서
+ // `_`로 시작하는 디렉터리를 통째로 삼킨다.
+ out.files.insert(".nojekyll".into(), Vec::new());
+
+ Ok(out)
+}
+
+/// 사이트를 빌드해 디스크에 쓴다.
+pub fn build(opts: &BuildOptions) -> Result<BuildStats> {
+ let cfg = Config::load(&opts.input)?;
+ let out_dir = resolve_output_dir(opts, &cfg);
+ let output = build_to_memory(opts)?;
+
+ // 출력 디렉터리를 매번 새로 만든다. 지운 페이지가 유령으로 남는 걸 막는다.
+ if out_dir.exists() {
+ std::fs::remove_dir_all(&out_dir)
+ .with_context(|| format!("{}을(를) 비울 수 없습니다", out_dir.display()))?;
+ }
+
+ for (rel, bytes) in &output.files {
+ let dest = out_dir.join(rel);
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("{}을(를) 만들 수 없습니다", parent.display()))?;
}
- std::fs::write(&dest, html)
+ std::fs::write(&dest, bytes)
.with_context(|| format!("{}을(를) 쓸 수 없습니다", dest.display()))?;
- written += 1;
}
- // GitHub Pages는 이 파일이 없으면 출력을 Jekyll로 한 번 더 굴려서
- // `_`로 시작하는 디렉터리를 통째로 삼킨다.
- std::fs::write(out_dir.join(".nojekyll"), "")
- .with_context(|| format!("{}에 .nojekyll을 쓸 수 없습니다", out_dir.display()))?;
-
Ok(BuildStats {
- pages_written: written,
+ pages_written: output.pages(),
output_dir: out_dir,
})
}
diff --git a/src/main.rs b/src/main.rs
@@ -2,6 +2,7 @@ use anyhow::Result;
use clap::{Parser, Subcommand};
use std::path::PathBuf;
+use sqzass::serve::{ServeOptions, serve};
use sqzass::{BuildOptions, build, display_path};
#[derive(Parser)]
@@ -20,6 +21,31 @@ struct Cli {
enum Command {
/// 사이트를 빌드한다
Build(BuildArgs),
+ /// 라이브 리로드가 붙은 개발 서버를 띄운다
+ Serve(ServeArgs),
+}
+
+#[derive(clap::Args)]
+struct ServeArgs {
+ /// 사이트 루트 (sqzass.toml이 있는 디렉터리)
+ #[arg(short, long, default_value = ".", value_name = "DIR")]
+ input: PathBuf,
+
+ /// 바인드 주소
+ #[arg(short, long, default_value = "127.0.0.1", value_name = "ADDR")]
+ bind: std::net::IpAddr,
+
+ /// 포트
+ #[arg(short, long, default_value_t = 3000)]
+ port: u16,
+
+ /// 드래프트 페이지도 포함한다
+ #[arg(long)]
+ drafts: bool,
+
+ /// 설정의 base_url을 덮어쓴다
+ #[arg(long, value_name = "URL")]
+ base_url: Option<String>,
}
#[derive(clap::Args)]
@@ -66,5 +92,16 @@ fn run() -> Result<()> {
);
Ok(())
}
+ Command::Serve(args) => {
+ // `build`는 동기 경로라 런타임을 서버에서만 만든다.
+ let rt = tokio::runtime::Runtime::new()?;
+ rt.block_on(serve(ServeOptions {
+ input: args.input,
+ bind: args.bind,
+ port: args.port,
+ drafts: args.drafts,
+ base_url: args.base_url,
+ }))
+ }
}
}
diff --git a/src/serve.rs b/src/serve.rs
@@ -0,0 +1,541 @@
+//! 개발 서버. 인메모리 서빙 + 라이브 리로드.
+
+use crate::{BuildOptions, BuildOutput, build_to_memory};
+use anyhow::{Context, Result};
+use axum::Router;
+use axum::body::Body;
+use axum::extract::{State, WebSocketUpgrade, ws};
+use axum::http::{HeaderMap, StatusCode, Uri, header};
+use axum::response::{IntoResponse, Response};
+use axum::routing::get;
+use notify::RecursiveMode;
+use notify_debouncer_full::new_debouncer;
+use std::net::{IpAddr, SocketAddr};
+use std::path::{Path, PathBuf};
+use std::sync::{Arc, RwLock};
+use std::time::Duration;
+use tokio::sync::broadcast;
+
+/// 라이브 리로드 엔드포인트. 사이트 경로와 겹치지 않게 접두사를 붙인다.
+const LIVE_PATH: &str = "/__sqzass/live";
+/// 편집기의 원자적 저장(임시 파일 + rename)은 한 번의 "저장"에 3~5개 이벤트를 낸다.
+const DEBOUNCE: Duration = Duration::from_millis(150);
+
+#[derive(Debug, Clone)]
+pub struct ServeOptions {
+ pub input: PathBuf,
+ pub bind: IpAddr,
+ pub port: u16,
+ pub drafts: bool,
+ pub base_url: Option<String>,
+}
+
+/// 브라우저로 보내는 신호.
+#[derive(Debug, Clone, PartialEq, Eq)]
+enum Signal {
+ /// 전체 새로고침
+ Reload,
+ /// CSS만 바뀜 — `<link>`의 href만 갈아끼운다. 스크롤과 열린 상태가 유지된다.
+ Css,
+ /// 빌드 실패 — 오버레이를 띄운다. 마지막으로 성공한 사이트는 계속 서빙된다.
+ Error(String),
+ /// 에러가 해소됨
+ Clear,
+}
+
+impl Signal {
+ fn encode(&self) -> String {
+ match self {
+ Signal::Reload => "reload".into(),
+ Signal::Css => "css".into(),
+ Signal::Clear => "clear".into(),
+ // 개행이 프레임을 깨지 않도록 한 줄로 만든다.
+ Signal::Error(msg) => format!("error:{}", msg.replace('\n', "\\n")),
+ }
+ }
+}
+
+struct Shared {
+ output: RwLock<BuildOutput>,
+ /// 현재 미해결 빌드 에러. 뒤늦게 접속한 탭도 이걸 즉시 받는다.
+ error: RwLock<Option<String>>,
+ tx: broadcast::Sender<Signal>,
+}
+
+type SharedState = Arc<Shared>;
+
+pub async fn serve(opts: ServeOptions) -> Result<()> {
+ let build_opts = BuildOptions {
+ input: opts.input.clone(),
+ output: None,
+ drafts: opts.drafts,
+ base_url: opts.base_url.clone(),
+ };
+
+ let initial = build_to_memory(&build_opts)
+ .with_context(|| format!("{}의 첫 빌드 실패", opts.input.display()))?;
+ println!("{} pages", initial.pages());
+
+ let (tx, _) = broadcast::channel(64);
+ let shared: SharedState = Arc::new(Shared {
+ output: RwLock::new(initial),
+ error: RwLock::new(None),
+ tx,
+ });
+
+ let _watcher = spawn_watcher(&opts.input, build_opts, Arc::clone(&shared))?;
+
+ let app = Router::new()
+ .route(LIVE_PATH, get(live_socket))
+ .fallback(get(serve_file))
+ .with_state(shared);
+
+ let addr = SocketAddr::new(opts.bind, opts.port);
+ let listener = tokio::net::TcpListener::bind(addr)
+ .await
+ .with_context(|| format!("{addr}에 바인드할 수 없습니다"))?;
+
+ println!("http://{addr}/ (Ctrl+C to stop)");
+ axum::serve(listener, app)
+ .with_graceful_shutdown(async {
+ let _ = tokio::signal::ctrl_c().await;
+ })
+ .await
+ .context("서버 오류")?;
+ Ok(())
+}
+
+/// 감시 대상 디렉터리. 출력 디렉터리는 넣지 않는다 — 자기가 쓴 걸 보고 다시 빌드하는
+/// 무한 루프가 된다.
+const WATCH_DIRS: &[&str] = &["content", "templates", "static", "i18n", "data"];
+
+fn spawn_watcher(
+ root: &Path,
+ build_opts: BuildOptions,
+ shared: SharedState,
+) -> Result<
+ notify_debouncer_full::Debouncer<
+ notify::RecommendedWatcher,
+ notify_debouncer_full::RecommendedCache,
+ >,
+> {
+ let (tx, rx) = std::sync::mpsc::channel();
+ let mut debouncer =
+ new_debouncer(DEBOUNCE, None, tx).context("파일 감시자를 만들 수 없습니다")?;
+
+ for dir in WATCH_DIRS {
+ let path = root.join(dir);
+ if path.is_dir() {
+ debouncer
+ .watch(&path, RecursiveMode::Recursive)
+ .with_context(|| format!("{}를 감시할 수 없습니다", path.display()))?;
+ }
+ }
+ let cfg = root.join(crate::config::CONFIG_FILE);
+ if cfg.is_file() {
+ debouncer.watch(&cfg, RecursiveMode::NonRecursive).ok();
+ }
+
+ std::thread::spawn(move || {
+ for res in rx {
+ let Ok(events) = res else { continue };
+ let paths: Vec<PathBuf> = events
+ .iter()
+ .flat_map(|e| e.paths.clone())
+ .filter(|p| !is_noise(p))
+ .collect();
+ if paths.is_empty() {
+ continue;
+ }
+ rebuild(&build_opts, &shared, &paths);
+ }
+ });
+
+ Ok(debouncer)
+}
+
+/// 편집기 임시 파일과 VCS 내부 파일은 무시한다. 이걸 안 걸러내면 저장 한 번에
+/// 리빌드가 여러 번 돈다.
+fn is_noise(path: &Path) -> bool {
+ let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
+ if path.components().any(|c| c.as_os_str() == ".git") {
+ return true;
+ }
+ name.starts_with('.')
+ || name.ends_with('~')
+ || name.ends_with(".swp")
+ || name.ends_with(".swx")
+ || name.ends_with(".tmp")
+ || (name.starts_with('#') && name.ends_with('#'))
+}
+
+fn rebuild(build_opts: &BuildOptions, shared: &SharedState, paths: &[PathBuf]) {
+ match build_to_memory(build_opts) {
+ Ok(output) => {
+ let had_error = shared.error.read().map(|e| e.is_some()).unwrap_or(false);
+ if let Ok(mut slot) = shared.output.write() {
+ *slot = output;
+ }
+ if had_error {
+ if let Ok(mut e) = shared.error.write() {
+ *e = None;
+ }
+ let _ = shared.tx.send(Signal::Clear);
+ }
+
+ // CSS만 바뀌었으면 페이지를 새로 그리지 않는다 — 스크롤 위치와 열어둔
+ // details/사이드바 상태가 유지되어야 디자인 작업이 견딜 만해진다.
+ let css_only = paths
+ .iter()
+ .all(|p| p.extension().and_then(|e| e.to_str()) == Some("css"));
+ let signal = if css_only {
+ Signal::Css
+ } else {
+ Signal::Reload
+ };
+ println!(" rebuilt ({})", if css_only { "css" } else { "full" });
+ let _ = shared.tx.send(signal);
+ }
+ Err(err) => {
+ let msg = format!("{err:#}");
+ eprintln!("error: {msg}");
+ if let Ok(mut e) = shared.error.write() {
+ *e = Some(msg.clone());
+ }
+ // 마지막으로 성공한 출력은 그대로 둔다. 빌드가 깨졌다고 사이트가
+ // 사라지면 무엇이 깨졌는지 볼 수가 없다.
+ let _ = shared.tx.send(Signal::Error(msg));
+ }
+ }
+}
+
+// --- HTTP ---
+
+async fn serve_file(State(shared): State<SharedState>, uri: Uri) -> Response {
+ let path = uri.path();
+ let key = resolve_key(path);
+
+ let (body, is_html) = {
+ let files = shared.output.read().expect("출력 락");
+ match files.files.get(&key) {
+ Some(bytes) => (bytes.clone(), key.ends_with(".html")),
+ None => {
+ return (
+ StatusCode::NOT_FOUND,
+ [(header::CONTENT_TYPE, "text/html; charset=utf-8")],
+ format!("<!doctype html><meta charset=utf-8><title>404</title><h1>404</h1><p><code>{}</code> not found.</p>{}", html_escape(path), reload_script()),
+ )
+ .into_response();
+ }
+ }
+ };
+
+ if !is_html {
+ return ([(header::CONTENT_TYPE, content_type(&key))], body).into_response();
+ }
+
+ // 리로드 스크립트는 **서브 시점에** 주입한다. 빌드 산출물 자체는 프로덕션과
+ // 바이트 단위로 동일하게 유지되어야 한다.
+ let mut html = String::from_utf8_lossy(&body).into_owned();
+ let error = shared.error.read().ok().and_then(|e| e.clone());
+ let injected = format!("{}{}", overlay(error.as_deref()), reload_script());
+ match html.rfind("</body>") {
+ Some(i) => html.insert_str(i, &injected),
+ None => html.push_str(&injected),
+ }
+
+ Response::builder()
+ .header(header::CONTENT_TYPE, "text/html; charset=utf-8")
+ // 두 번의 저장이 같은 초에 들어가면 검증자가 같아져 엉뚱한 304가 나간다.
+ .header(header::CACHE_CONTROL, "no-store")
+ .body(Body::from(html))
+ .expect("응답 생성")
+}
+
+/// 요청 경로를 출력 파일 키로 바꾼다. `/a/b/` → `a/b/index.html`
+fn resolve_key(path: &str) -> String {
+ let p = path.trim_start_matches('/');
+ if p.is_empty() {
+ return "index.html".into();
+ }
+ if p.ends_with('/') {
+ return format!("{p}index.html");
+ }
+ let last = Path::new(p)
+ .file_name()
+ .and_then(|n| n.to_str())
+ .unwrap_or("");
+ // `Path::extension()`은 `.nojekyll` 같은 점 파일에 None을 준다(파일명 전체가
+ // stem으로 취급된다). 그것만 보고 판단하면 점 파일을 디렉터리로 오인한다.
+ if Path::new(p).extension().is_some() || last.starts_with('.') {
+ return p.to_string();
+ }
+ format!("{p}/index.html")
+}
+
+fn content_type(key: &str) -> &'static str {
+ match Path::new(key).extension().and_then(|e| e.to_str()) {
+ Some("html") => "text/html; charset=utf-8",
+ Some("css") => "text/css; charset=utf-8",
+ Some("js") => "text/javascript; charset=utf-8",
+ Some("json") => "application/json",
+ Some("xml") => "application/xml",
+ Some("txt") => "text/plain; charset=utf-8",
+ Some("svg") => "image/svg+xml",
+ Some("png") => "image/png",
+ Some("jpg" | "jpeg") => "image/jpeg",
+ Some("webp") => "image/webp",
+ Some("avif") => "image/avif",
+ Some("ico") => "image/x-icon",
+ Some("woff2") => "font/woff2",
+ _ => "application/octet-stream",
+ }
+}
+
+fn html_escape(s: &str) -> String {
+ s.replace('&', "&")
+ .replace('<', "<")
+ .replace('>', ">")
+}
+
+// --- WebSocket ---
+
+async fn live_socket(
+ State(shared): State<SharedState>,
+ headers: HeaderMap,
+ upgrade: WebSocketUpgrade,
+) -> Response {
+ let origin = headers.get(header::ORIGIN).and_then(|v| v.to_str().ok());
+ let host = headers.get(header::HOST).and_then(|v| v.to_str().ok());
+
+ if !origin_allowed(origin, host) {
+ // Cross-Site WebSocket Hijacking 방어. 판단할 수 없으면 거절한다.
+ return (StatusCode::FORBIDDEN, "origin not allowed").into_response();
+ }
+
+ upgrade.on_upgrade(move |socket| handle_socket(socket, shared))
+}
+
+async fn handle_socket(mut socket: ws::WebSocket, shared: SharedState) {
+ let mut rx = shared.tx.subscribe();
+
+ // 지금 빌드가 깨져 있다면 새로 붙은 탭에도 즉시 알려준다. 이게 없으면 리로드
+ // 도중에 접속한 탭은 왜 화면이 옛날인지 알 수 없다.
+ if let Some(err) = shared.error.read().ok().and_then(|e| e.clone())
+ && socket
+ .send(ws::Message::Text(Signal::Error(err).encode().into()))
+ .await
+ .is_err()
+ {
+ return;
+ }
+
+ while let Ok(signal) = rx.recv().await {
+ if socket
+ .send(ws::Message::Text(signal.encode().into()))
+ .await
+ .is_err()
+ {
+ break;
+ }
+ }
+}
+
+/// WebSocket 핸드셰이크의 Origin 검증.
+///
+/// **판단할 수 없으면 거절한다(fail closed).** Origin이 없거나 파싱이 안 되면 통과시키지
+/// 않는다 — 개발 서버는 로컬에만 떠 있지만, 사용자가 아무 사이트나 열어둔 상태에서
+/// 그 페이지가 이 소켓에 붙어 리빌드 신호를 엿보거나 트리거할 수 있으면 안 된다.
+fn origin_allowed(origin: Option<&str>, host: Option<&str>) -> bool {
+ let Some(origin) = origin else { return false };
+ let Some(origin_host) = host_of_origin(origin) else {
+ return false;
+ };
+ if is_loopback_host(&origin_host) {
+ return true;
+ }
+ match host.and_then(strip_port) {
+ Some(h) => h.eq_ignore_ascii_case(&origin_host),
+ None => false,
+ }
+}
+
+/// `scheme://host[:port]` 에서 host를 뽑는다. 대괄호 IPv6(`[::1]:3000`)를 처리한다.
+fn host_of_origin(origin: &str) -> Option<String> {
+ let rest = origin.split_once("://")?.1;
+ let authority = rest.split(['/', '?', '#']).next()?;
+ strip_port(authority).map(|h| h.to_ascii_lowercase())
+}
+
+fn strip_port(authority: &str) -> Option<&str> {
+ if let Some(end) = authority.find(']') {
+ // 대괄호 IPv6: `[::1]:3000` → `[::1]`
+ return authority.get(..=end);
+ }
+ Some(match authority.rsplit_once(':') {
+ Some((h, port)) if port.chars().all(|c| c.is_ascii_digit()) => h,
+ _ => authority,
+ })
+}
+
+fn is_loopback_host(host: &str) -> bool {
+ matches!(host, "localhost" | "127.0.0.1" | "[::1]" | "::1")
+}
+
+// --- 브라우저로 주입되는 코드 ---
+
+fn overlay(error: Option<&str>) -> String {
+ let Some(err) = error else {
+ return String::new();
+ };
+ format!(
+ r#"<div id="__sqzass_error" style="position:fixed;inset:0;z-index:2147483647;background:#1a1114;color:#ffd7d7;font:13px/1.6 ui-monospace,monospace;padding:2rem;overflow:auto;white-space:pre-wrap"><strong style="color:#ff8080;font-size:15px">build failed</strong>
+
+{}</div>"#,
+ html_escape(err)
+ )
+}
+
+fn reload_script() -> String {
+ format!(
+ r#"<script>(function(){{
+var path={LIVE_PATH:?};
+var back=500, ws;
+function overlay(msg){{
+ var el=document.getElementById("__sqzass_error");
+ if(!msg){{ if(el) el.remove(); return; }}
+ if(!el){{ el=document.createElement("div"); el.id="__sqzass_error";
+ el.setAttribute("style","position:fixed;inset:0;z-index:2147483647;background:#1a1114;color:#ffd7d7;font:13px/1.6 ui-monospace,monospace;padding:2rem;overflow:auto;white-space:pre-wrap");
+ document.body.appendChild(el); }}
+ el.textContent="build failed\n\n"+msg;
+}}
+function swapCss(){{
+ document.querySelectorAll('link[rel="stylesheet"]').forEach(function(l){{
+ var u=new URL(l.href,location.href); u.searchParams.set("__r",Date.now()); l.href=u.href;
+ }});
+}}
+function connect(){{
+ ws=new WebSocket((location.protocol==="https:"?"wss://":"ws://")+location.host+path);
+ ws.onopen=function(){{ back=500; }};
+ ws.onmessage=function(e){{
+ var m=e.data;
+ if(m==="reload"){{ sessionStorage.setItem("__sqzass_y",String(scrollY)); location.reload(); }}
+ else if(m==="css"){{ swapCss(); overlay(null); }}
+ else if(m==="clear"){{ overlay(null); location.reload(); }}
+ else if(m.indexOf("error:")===0){{ overlay(m.slice(6).replace(/\\n/g,"\n")); }}
+ }};
+ // 지수 백오프. 서버를 재시작해도 탭이 알아서 다시 붙는다.
+ ws.onclose=function(){{ setTimeout(connect,back); back=Math.min(back*2,10000); }};
+}}
+var y=sessionStorage.getItem("__sqzass_y");
+if(y!==null){{ sessionStorage.removeItem("__sqzass_y");
+ addEventListener("load",function(){{ scrollTo(0,parseInt(y,10)||0); }}); }}
+connect();
+}})();</script>"#
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn resolves_directory_urls_to_index() {
+ assert_eq!(resolve_key("/"), "index.html");
+ assert_eq!(resolve_key("/ko/"), "ko/index.html");
+ assert_eq!(
+ resolve_key("/start/installation/"),
+ "start/installation/index.html"
+ );
+ }
+
+ #[test]
+ fn resolves_extensionless_urls_to_index() {
+ // 트레일링 슬래시가 없어도 같은 페이지를 준다. 프로덕션 호스트가
+ // 리다이렉트해 주는 경우가 많아서, 개발 중에만 404가 나면 혼란스럽다.
+ assert_eq!(resolve_key("/start"), "start/index.html");
+ }
+
+ #[test]
+ fn passes_through_files_with_extensions() {
+ assert_eq!(resolve_key("/assets/highlight.css"), "assets/highlight.css");
+ assert_eq!(resolve_key("/.nojekyll"), ".nojekyll");
+ }
+
+ #[test]
+ fn origin_must_be_present_and_parseable() {
+ // fail closed: 판단할 수 없으면 거절
+ assert!(!origin_allowed(None, Some("127.0.0.1:3000")));
+ assert!(!origin_allowed(Some("garbage"), Some("127.0.0.1:3000")));
+ assert!(!origin_allowed(Some(""), Some("127.0.0.1:3000")));
+ }
+
+ #[test]
+ fn loopback_origins_are_allowed() {
+ assert!(origin_allowed(
+ Some("http://localhost:3000"),
+ Some("localhost:3000")
+ ));
+ assert!(origin_allowed(
+ Some("http://127.0.0.1:3000"),
+ Some("127.0.0.1:3000")
+ ));
+ assert!(origin_allowed(
+ Some("http://[::1]:3000"),
+ Some("[::1]:3000")
+ ));
+ }
+
+ #[test]
+ fn foreign_origins_are_rejected() {
+ assert!(!origin_allowed(
+ Some("https://evil.example"),
+ Some("127.0.0.1:3000")
+ ));
+ // 접두사만 같은 도메인도 막아야 한다
+ assert!(!origin_allowed(
+ Some("http://localhost.evil.example"),
+ Some("localhost:3000")
+ ));
+ }
+
+ #[test]
+ fn matching_non_loopback_host_is_allowed() {
+ // LAN의 다른 기기에서 --bind 0.0.0.0 으로 접근하는 경우
+ assert!(origin_allowed(
+ Some("http://192.168.1.50:3000"),
+ Some("192.168.1.50:3000")
+ ));
+ assert!(!origin_allowed(
+ Some("http://192.168.1.99:3000"),
+ Some("192.168.1.50:3000")
+ ));
+ }
+
+ #[test]
+ fn strips_ports_but_keeps_ipv6_brackets() {
+ assert_eq!(strip_port("localhost:3000"), Some("localhost"));
+ assert_eq!(strip_port("example.com"), Some("example.com"));
+ assert_eq!(strip_port("[::1]:3000"), Some("[::1]"));
+ assert_eq!(strip_port("[::1]"), Some("[::1]"));
+ }
+
+ #[test]
+ fn editor_temp_files_are_ignored() {
+ assert!(is_noise(Path::new("content/.post.md.swp")));
+ assert!(is_noise(Path::new("content/post.md~")));
+ assert!(is_noise(Path::new("content/#post.md#")));
+ assert!(is_noise(Path::new("/repo/.git/index")));
+ assert!(!is_noise(Path::new("content/post.md")));
+ assert!(!is_noise(Path::new("templates/base.html")));
+ }
+
+ #[test]
+ fn signals_encode_on_one_line() {
+ assert_eq!(Signal::Reload.encode(), "reload");
+ assert_eq!(Signal::Css.encode(), "css");
+ let e = Signal::Error("line1\nline2".into()).encode();
+ assert!(!e.contains('\n'), "개행이 프레임을 깬다: {e}");
+ assert!(e.starts_with("error:"));
+ }
+}
|