max / makenotwork
21 files changed,
+2 insertions,
-4295 deletions
| @@ -88,7 +88,7 @@ | |||
| 88 | 88 | base64 = { workspace = true } | |
| 89 | 89 | rand = { workspace = true } | |
| 90 | 90 | pulldown-cmark = { workspace = true } | |
| 91 | - | docengine = { path = "../shared/docengine", features = ["mentions", "quotes"] } | |
| 91 | + | docengine = { path = "../../Libraries/docengine", features = ["mentions", "quotes"] } | |
| 92 | 92 | tagtree = { workspace = true } | |
| 93 | 93 | tower_governor = { workspace = true } | |
| 94 | 94 | governor = { workspace = true } |
| @@ -118,7 +118,7 @@ | |||
| 118 | 118 | metrics-exporter-prometheus = { version = "0.18.1", default-features = false } | |
| 119 | 119 | ||
| 120 | 120 | # Markdown rendering + documentation engine | |
| 121 | - | docengine = { path = "../shared/docengine", features = ["doc-loader", "directives", "frontmatter", "media-urls"] } | |
| 121 | + | docengine = { path = "../../Libraries/docengine", features = ["doc-loader", "directives", "frontmatter", "media-urls"] } | |
| 122 | 122 | mnw-assumptions = { path = "../shared/mnw-assumptions" } | |
| 123 | 123 | ||
| 124 | 124 | # Tag standard |
| @@ -1,2 +1,0 @@ | |||
| 1 | - | /target/ | |
| 2 | - | .DS_Store |
| @@ -1,1558 +1,0 @@ | |||
| 1 | - | # This file is automatically @generated by Cargo. | |
| 2 | - | # It is not intended for manual editing. | |
| 3 | - | version = 4 | |
| 4 | - | ||
| 5 | - | [[package]] | |
| 6 | - | name = "aho-corasick" | |
| 7 | - | version = "1.1.4" | |
| 8 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 9 | - | checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" | |
| 10 | - | dependencies = [ | |
| 11 | - | "memchr", | |
| 12 | - | ] | |
| 13 | - | ||
| 14 | - | [[package]] | |
| 15 | - | name = "alloca" | |
| 16 | - | version = "0.4.0" | |
| 17 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 18 | - | checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4" | |
| 19 | - | dependencies = [ | |
| 20 | - | "cc", | |
| 21 | - | ] | |
| 22 | - | ||
| 23 | - | [[package]] | |
| 24 | - | name = "ammonia" | |
| 25 | - | version = "4.1.4" | |
| 26 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 27 | - | checksum = "dc6d763210e2eb7670d1a5183a08bebefa3f97db2a738a684f2ce00bd49f681d" | |
| 28 | - | dependencies = [ | |
| 29 | - | "cssparser", | |
| 30 | - | "html5ever", | |
| 31 | - | "maplit", | |
| 32 | - | "url", | |
| 33 | - | ] | |
| 34 | - | ||
| 35 | - | [[package]] | |
| 36 | - | name = "anes" | |
| 37 | - | version = "0.1.6" | |
| 38 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 39 | - | checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" | |
| 40 | - | ||
| 41 | - | [[package]] | |
| 42 | - | name = "anstyle" | |
| 43 | - | version = "1.0.14" | |
| 44 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 45 | - | checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" | |
| 46 | - | ||
| 47 | - | [[package]] | |
| 48 | - | name = "anyhow" | |
| 49 | - | version = "1.0.104" | |
| 50 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 51 | - | checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" | |
| 52 | - | ||
| 53 | - | [[package]] | |
| 54 | - | name = "autocfg" | |
| 55 | - | version = "1.5.1" | |
| 56 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 57 | - | checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" | |
| 58 | - | ||
| 59 | - | [[package]] | |
| 60 | - | name = "bitflags" | |
| 61 | - | version = "2.11.0" | |
| 62 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 63 | - | checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" | |
| 64 | - | ||
| 65 | - | [[package]] | |
| 66 | - | name = "bumpalo" | |
| 67 | - | version = "3.20.2" | |
| 68 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 69 | - | checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" | |
| 70 | - | ||
| 71 | - | [[package]] | |
| 72 | - | name = "cast" | |
| 73 | - | version = "0.3.0" | |
| 74 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 75 | - | checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" | |
| 76 | - | ||
| 77 | - | [[package]] | |
| 78 | - | name = "cc" | |
| 79 | - | version = "1.3.0" | |
| 80 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 81 | - | checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" | |
| 82 | - | dependencies = [ | |
| 83 | - | "find-msvc-tools", | |
| 84 | - | "shlex", | |
| 85 | - | ] | |
| 86 | - | ||
| 87 | - | [[package]] | |
| 88 | - | name = "cfg-if" | |
| 89 | - | version = "1.0.4" | |
| 90 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 91 | - | checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" | |
| 92 | - | ||
| 93 | - | [[package]] | |
| 94 | - | name = "ciborium" | |
| 95 | - | version = "0.2.2" | |
| 96 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 97 | - | checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" | |
| 98 | - | dependencies = [ | |
| 99 | - | "ciborium-io", | |
| 100 | - | "ciborium-ll", | |
| 101 | - | "serde", | |
| 102 | - | ] | |
| 103 | - | ||
| 104 | - | [[package]] | |
| 105 | - | name = "ciborium-io" | |
| 106 | - | version = "0.2.2" | |
| 107 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 108 | - | checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" | |
| 109 | - | ||
| 110 | - | [[package]] | |
| 111 | - | name = "ciborium-ll" | |
| 112 | - | version = "0.2.2" | |
| 113 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 114 | - | checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" | |
| 115 | - | dependencies = [ | |
| 116 | - | "ciborium-io", | |
| 117 | - | "half", | |
| 118 | - | ] | |
| 119 | - | ||
| 120 | - | [[package]] | |
| 121 | - | name = "clap" | |
| 122 | - | version = "4.6.4" | |
| 123 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 124 | - | checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" | |
| 125 | - | dependencies = [ | |
| 126 | - | "clap_builder", | |
| 127 | - | ] | |
| 128 | - | ||
| 129 | - | [[package]] | |
| 130 | - | name = "clap_builder" | |
| 131 | - | version = "4.6.2" | |
| 132 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 133 | - | checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" | |
| 134 | - | dependencies = [ | |
| 135 | - | "anstyle", | |
| 136 | - | "clap_lex", | |
| 137 | - | ] | |
| 138 | - | ||
| 139 | - | [[package]] | |
| 140 | - | name = "clap_lex" | |
| 141 | - | version = "1.1.0" | |
| 142 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 143 | - | checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" | |
| 144 | - | ||
| 145 | - | [[package]] | |
| 146 | - | name = "criterion" | |
| 147 | - | version = "0.8.2" | |
| 148 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 149 | - | checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3" | |
| 150 | - | dependencies = [ | |
| 151 | - | "alloca", | |
| 152 | - | "anes", | |
| 153 | - | "cast", | |
| 154 | - | "ciborium", | |
| 155 | - | "clap", | |
| 156 | - | "criterion-plot", | |
| 157 | - | "itertools", | |
| 158 | - | "num-traits", | |
| 159 | - | "oorandom", | |
| 160 | - | "page_size", | |
| 161 | - | "plotters", | |
| 162 | - | "rayon", | |
| 163 | - | "regex", | |
| 164 | - | "serde", | |
| 165 | - | "serde_json", | |
| 166 | - | "tinytemplate", | |
| 167 | - | "walkdir", | |
| 168 | - | ] | |
| 169 | - | ||
| 170 | - | [[package]] | |
| 171 | - | name = "criterion-plot" | |
| 172 | - | version = "0.8.2" | |
| 173 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 174 | - | checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea" | |
| 175 | - | dependencies = [ | |
| 176 | - | "cast", | |
| 177 | - | "itertools", | |
| 178 | - | ] | |
| 179 | - | ||
| 180 | - | [[package]] | |
| 181 | - | name = "crossbeam-deque" | |
| 182 | - | version = "0.8.7" | |
| 183 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 184 | - | checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" | |
| 185 | - | dependencies = [ | |
| 186 | - | "crossbeam-epoch", | |
| 187 | - | "crossbeam-utils", | |
| 188 | - | ] | |
| 189 | - | ||
| 190 | - | [[package]] | |
| 191 | - | name = "crossbeam-epoch" | |
| 192 | - | version = "0.9.20" | |
| 193 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 194 | - | checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" | |
| 195 | - | dependencies = [ | |
| 196 | - | "crossbeam-utils", | |
| 197 | - | ] | |
| 198 | - | ||
| 199 | - | [[package]] | |
| 200 | - | name = "crossbeam-utils" | |
| 201 | - | version = "0.8.22" | |
| 202 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 203 | - | checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" | |
| 204 | - | ||
| 205 | - | [[package]] | |
| 206 | - | name = "crunchy" | |
| 207 | - | version = "0.2.4" | |
| 208 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 209 | - | checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" | |
| 210 | - | ||
| 211 | - | [[package]] | |
| 212 | - | name = "cssparser" | |
| 213 | - | version = "0.37.0" | |
| 214 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 215 | - | checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98" | |
| 216 | - | dependencies = [ | |
| 217 | - | "dtoa-short", | |
| 218 | - | "itoa", | |
| 219 | - | "smallvec", | |
| 220 | - | ] | |
| 221 | - | ||
| 222 | - | [[package]] | |
| 223 | - | name = "displaydoc" | |
| 224 | - | version = "0.2.5" | |
| 225 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 226 | - | checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" | |
| 227 | - | dependencies = [ | |
| 228 | - | "proc-macro2", | |
| 229 | - | "quote", | |
| 230 | - | "syn", | |
| 231 | - | ] | |
| 232 | - | ||
| 233 | - | [[package]] | |
| 234 | - | name = "docengine" | |
| 235 | - | version = "0.3.5" | |
| 236 | - | dependencies = [ | |
| 237 | - | "ammonia", | |
| 238 | - | "criterion", | |
| 239 | - | "pulldown-cmark", | |
| 240 | - | "regex-lite", | |
| 241 | - | "serde", | |
| 242 | - | "tempfile", | |
| 243 | - | "toml", | |
| 244 | - | "tracing", | |
| 245 | - | "uuid", | |
| 246 | - | ] | |
| 247 | - | ||
| 248 | - | [[package]] | |
| 249 | - | name = "dtoa" | |
| 250 | - | version = "1.0.11" | |
| 251 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 252 | - | checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" | |
| 253 | - | ||
| 254 | - | [[package]] | |
| 255 | - | name = "dtoa-short" | |
| 256 | - | version = "0.3.5" | |
| 257 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 258 | - | checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" | |
| 259 | - | dependencies = [ | |
| 260 | - | "dtoa", | |
| 261 | - | ] | |
| 262 | - | ||
| 263 | - | [[package]] | |
| 264 | - | name = "either" | |
| 265 | - | version = "1.16.0" | |
| 266 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 267 | - | checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" | |
| 268 | - | ||
| 269 | - | [[package]] | |
| 270 | - | name = "equivalent" | |
| 271 | - | version = "1.0.2" | |
| 272 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 273 | - | checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" | |
| 274 | - | ||
| 275 | - | [[package]] | |
| 276 | - | name = "errno" | |
| 277 | - | version = "0.3.14" | |
| 278 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 279 | - | checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" | |
| 280 | - | dependencies = [ | |
| 281 | - | "libc", | |
| 282 | - | "windows-sys", | |
| 283 | - | ] | |
| 284 | - | ||
| 285 | - | [[package]] | |
| 286 | - | name = "fastrand" | |
| 287 | - | version = "2.4.1" | |
| 288 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 289 | - | checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" | |
| 290 | - | ||
| 291 | - | [[package]] | |
| 292 | - | name = "find-msvc-tools" | |
| 293 | - | version = "0.1.9" | |
| 294 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 295 | - | checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" | |
| 296 | - | ||
| 297 | - | [[package]] | |
| 298 | - | name = "foldhash" | |
| 299 | - | version = "0.1.5" | |
| 300 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 301 | - | checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" | |
| 302 | - | ||
| 303 | - | [[package]] | |
| 304 | - | name = "form_urlencoded" | |
| 305 | - | version = "1.2.2" | |
| 306 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 307 | - | checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" | |
| 308 | - | dependencies = [ | |
| 309 | - | "percent-encoding", | |
| 310 | - | ] | |
| 311 | - | ||
| 312 | - | [[package]] | |
| 313 | - | name = "getopts" | |
| 314 | - | version = "0.2.24" | |
| 315 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 316 | - | checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" | |
| 317 | - | dependencies = [ | |
| 318 | - | "unicode-width", | |
| 319 | - | ] | |
| 320 | - | ||
| 321 | - | [[package]] | |
| 322 | - | name = "getrandom" | |
| 323 | - | version = "0.4.2" | |
| 324 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 325 | - | checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" | |
| 326 | - | dependencies = [ | |
| 327 | - | "cfg-if", | |
| 328 | - | "libc", | |
| 329 | - | "r-efi", | |
| 330 | - | "wasip2", | |
| 331 | - | "wasip3", | |
| 332 | - | ] | |
| 333 | - | ||
| 334 | - | [[package]] | |
| 335 | - | name = "half" | |
| 336 | - | version = "2.7.1" | |
| 337 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 338 | - | checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" | |
| 339 | - | dependencies = [ | |
| 340 | - | "cfg-if", | |
| 341 | - | "crunchy", | |
| 342 | - | "zerocopy", | |
| 343 | - | ] | |
| 344 | - | ||
| 345 | - | [[package]] | |
| 346 | - | name = "hashbrown" | |
| 347 | - | version = "0.15.5" | |
| 348 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 349 | - | checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" | |
| 350 | - | dependencies = [ | |
| 351 | - | "foldhash", | |
| 352 | - | ] | |
| 353 | - | ||
| 354 | - | [[package]] | |
| 355 | - | name = "hashbrown" | |
| 356 | - | version = "0.16.1" | |
| 357 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 358 | - | checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" | |
| 359 | - | ||
| 360 | - | [[package]] | |
| 361 | - | name = "heck" | |
| 362 | - | version = "0.5.0" | |
| 363 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 364 | - | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" | |
| 365 | - | ||
| 366 | - | [[package]] | |
| 367 | - | name = "html5ever" | |
| 368 | - | version = "0.39.0" | |
| 369 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 370 | - | checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8" | |
| 371 | - | dependencies = [ | |
| 372 | - | "log", | |
| 373 | - | "markup5ever", | |
| 374 | - | ] | |
| 375 | - | ||
| 376 | - | [[package]] | |
| 377 | - | name = "icu_collections" | |
| 378 | - | version = "2.1.1" | |
| 379 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 380 | - | checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" | |
| 381 | - | dependencies = [ | |
| 382 | - | "displaydoc", | |
| 383 | - | "potential_utf", | |
| 384 | - | "yoke", | |
| 385 | - | "zerofrom", | |
| 386 | - | "zerovec", | |
| 387 | - | ] | |
| 388 | - | ||
| 389 | - | [[package]] | |
| 390 | - | name = "icu_locale_core" | |
| 391 | - | version = "2.1.1" | |
| 392 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 393 | - | checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" | |
| 394 | - | dependencies = [ | |
| 395 | - | "displaydoc", | |
| 396 | - | "litemap", | |
| 397 | - | "tinystr", | |
| 398 | - | "writeable", | |
| 399 | - | "zerovec", | |
| 400 | - | ] | |
| 401 | - | ||
| 402 | - | [[package]] | |
| 403 | - | name = "icu_normalizer" | |
| 404 | - | version = "2.1.1" | |
| 405 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 406 | - | checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" | |
| 407 | - | dependencies = [ | |
| 408 | - | "icu_collections", | |
| 409 | - | "icu_normalizer_data", | |
| 410 | - | "icu_properties", | |
| 411 | - | "icu_provider", | |
| 412 | - | "smallvec", | |
| 413 | - | "zerovec", | |
| 414 | - | ] | |
| 415 | - | ||
| 416 | - | [[package]] | |
| 417 | - | name = "icu_normalizer_data" | |
| 418 | - | version = "2.1.1" | |
| 419 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 420 | - | checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" | |
| 421 | - | ||
| 422 | - | [[package]] | |
| 423 | - | name = "icu_properties" | |
| 424 | - | version = "2.1.2" | |
| 425 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 426 | - | checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" | |
| 427 | - | dependencies = [ | |
| 428 | - | "icu_collections", | |
| 429 | - | "icu_locale_core", | |
| 430 | - | "icu_properties_data", | |
| 431 | - | "icu_provider", | |
| 432 | - | "zerotrie", | |
| 433 | - | "zerovec", | |
| 434 | - | ] | |
| 435 | - | ||
| 436 | - | [[package]] | |
| 437 | - | name = "icu_properties_data" | |
| 438 | - | version = "2.1.2" | |
| 439 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 440 | - | checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" | |
| 441 | - | ||
| 442 | - | [[package]] | |
| 443 | - | name = "icu_provider" | |
| 444 | - | version = "2.1.1" | |
| 445 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 446 | - | checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" | |
| 447 | - | dependencies = [ | |
| 448 | - | "displaydoc", | |
| 449 | - | "icu_locale_core", | |
| 450 | - | "writeable", | |
| 451 | - | "yoke", | |
| 452 | - | "zerofrom", | |
| 453 | - | "zerotrie", | |
| 454 | - | "zerovec", | |
| 455 | - | ] | |
| 456 | - | ||
| 457 | - | [[package]] | |
| 458 | - | name = "id-arena" | |
| 459 | - | version = "2.3.0" | |
| 460 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 461 | - | checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" | |
| 462 | - | ||
| 463 | - | [[package]] | |
| 464 | - | name = "idna" | |
| 465 | - | version = "1.1.0" | |
| 466 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 467 | - | checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" | |
| 468 | - | dependencies = [ | |
| 469 | - | "idna_adapter", | |
| 470 | - | "smallvec", | |
| 471 | - | "utf8_iter", | |
| 472 | - | ] | |
| 473 | - | ||
| 474 | - | [[package]] | |
| 475 | - | name = "idna_adapter" | |
| 476 | - | version = "1.2.1" | |
| 477 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 478 | - | checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" | |
| 479 | - | dependencies = [ | |
| 480 | - | "icu_normalizer", | |
| 481 | - | "icu_properties", | |
| 482 | - | ] | |
| 483 | - | ||
| 484 | - | [[package]] | |
| 485 | - | name = "indexmap" | |
| 486 | - | version = "2.13.0" | |
| 487 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 488 | - | checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" | |
| 489 | - | dependencies = [ | |
| 490 | - | "equivalent", | |
| 491 | - | "hashbrown 0.16.1", | |
| 492 | - | "serde", | |
| 493 | - | "serde_core", | |
| 494 | - | ] | |
| 495 | - | ||
| 496 | - | [[package]] | |
| 497 | - | name = "itertools" | |
| 498 | - | version = "0.13.0" | |
| 499 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 500 | - | checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" |
Lines truncated
| @@ -1,69 +1,0 @@ | |||
| 1 | - | [package] | |
| 2 | - | name = "docengine" | |
| 3 | - | version = "0.3.5" | |
| 4 | - | edition = "2024" | |
| 5 | - | license = "MIT" | |
| 6 | - | ||
| 7 | - | [features] | |
| 8 | - | default = [] | |
| 9 | - | doc-loader = ["dep:regex-lite", "dep:tracing"] | |
| 10 | - | directives = ["dep:regex-lite"] | |
| 11 | - | mentions = ["dep:regex-lite"] | |
| 12 | - | quotes = ["dep:regex-lite", "dep:uuid"] | |
| 13 | - | frontmatter = ["dep:toml", "dep:tracing"] | |
| 14 | - | media-urls = ["dep:regex-lite"] | |
| 15 | - | full = ["doc-loader", "directives", "mentions", "quotes", "frontmatter", "media-urls"] | |
| 16 | - | ||
| 17 | - | [dependencies] | |
| 18 | - | pulldown-cmark = "0.13" | |
| 19 | - | ammonia = "4" | |
| 20 | - | serde = { version = "1", features = ["derive"] } | |
| 21 | - | ||
| 22 | - | regex-lite = { version = "0.1", optional = true } | |
| 23 | - | uuid = { version = "1", features = ["serde", "v4"], optional = true } | |
| 24 | - | toml = { version = "1.1", optional = true } | |
| 25 | - | tracing = { version = "0.1", optional = true } | |
| 26 | - | ||
| 27 | - | [dev-dependencies] | |
| 28 | - | tempfile = "3" | |
| 29 | - | criterion = { version = "0.8", features = ["html_reports"] } | |
| 30 | - | ||
| 31 | - | # Run with: cargo bench --features full | |
| 32 | - | # (the render-path benchmarks exercise the doc-loader, directives, and quotes | |
| 33 | - | # post-processors, all feature-gated.) | |
| 34 | - | [[bench]] | |
| 35 | - | name = "render" | |
| 36 | - | harness = false | |
| 37 | - | required-features = ["doc-loader", "directives", "quotes"] | |
| 38 | - | ||
| 39 | - | [lints.rust] | |
| 40 | - | unused = "warn" | |
| 41 | - | unreachable_pub = "warn" | |
| 42 | - | ||
| 43 | - | [lints.clippy] | |
| 44 | - | pedantic = { level = "warn", priority = -1 } | |
| 45 | - | # Allow-list tuned from a measured breakdown across server/multithreaded/pter | |
| 46 | - | # (2026-07-22). These are the high-churn / low-signal pedantic lints; everything | |
| 47 | - | # else in `pedantic` stays a warning. Keep this block identical across repos. | |
| 48 | - | module_name_repetitions = "allow" | |
| 49 | - | # Doc lints. No docs-completeness push is underway. | |
| 50 | - | missing_errors_doc = "allow" | |
| 51 | - | missing_panics_doc = "allow" | |
| 52 | - | doc_markdown = "allow" | |
| 53 | - | # Numeric casts. Endemic and mostly intentional in size and byte math. | |
| 54 | - | cast_possible_truncation = "allow" | |
| 55 | - | cast_sign_loss = "allow" | |
| 56 | - | cast_precision_loss = "allow" | |
| 57 | - | cast_possible_wrap = "allow" | |
| 58 | - | cast_lossless = "allow" | |
| 59 | - | # Subjective structure and style nags. High churn, low signal. | |
| 60 | - | must_use_candidate = "allow" | |
| 61 | - | too_many_lines = "allow" | |
| 62 | - | struct_excessive_bools = "allow" | |
| 63 | - | similar_names = "allow" | |
| 64 | - | items_after_statements = "allow" | |
| 65 | - | single_match_else = "allow" | |
| 66 | - | # Frequent false-positives in TUI and router-heavy code. | |
| 67 | - | match_same_arms = "allow" | |
| 68 | - | unnecessary_wraps = "allow" | |
| 69 | - | type_complexity = "allow" |
| @@ -1,128 +1,0 @@ | |||
| 1 | - | # DocEngine | |
| 2 | - | ||
| 3 | - | Configurable markdown-to-HTML rendering library with sanitization presets. Built on pulldown-cmark (GFM) and ammonia. | |
| 4 | - | ||
| 5 | - | Used by MNW (site docs, blog posts, user-generated content), Multithreaded (forum posts), and the desktop apps (descriptions, notes). | |
| 6 | - | ||
| 7 | - | ## Presets | |
| 8 | - | ||
| 9 | - | Four rendering presets, each with different security/feature tradeoffs: | |
| 10 | - | ||
| 11 | - | | Preset | Use case | Tables | Images | Raw HTML | Dangerous scheme filter | Sanitization | | |
| 12 | - | |--------|----------|:------:|:------:|:--------:|:-----------------------:|--------------| | |
| 13 | - | | **Permissive** | Docs, blog posts (trusted) | Y | Y | Y | N | Default ammonia | | |
| 14 | - | | **Standard** | App text fields (descriptions) | Y | N | Y | N | Default ammonia | | |
| 15 | - | | **Strict** | User-generated content (forums) | N | N | N | Y | nofollow on links | | |
| 16 | - | | **Sanitize-only** | External HTML (RSS feeds) | -- | -- | -- | -- | Default ammonia, no markdown parsing | | |
| 17 | - | ||
| 18 | - | ```rust | |
| 19 | - | use docengine::{render_permissive, render_standard, render_strict, sanitize_html}; | |
| 20 | - | ||
| 21 | - | // Convenience functions | |
| 22 | - | let html = render_permissive("# Hello\n\n**Bold** text"); | |
| 23 | - | let html = render_standard("A description with [link](https://example.com)"); | |
| 24 | - | let html = render_strict("User post with @mentions and `code`"); | |
| 25 | - | let html = sanitize_html("<p>Pre-rendered</p><script>stripped</script>"); | |
| 26 | - | ||
| 27 | - | // Builder pattern for custom configurations | |
| 28 | - | use docengine::{Renderer, SanitizePreset}; | |
| 29 | - | ||
| 30 | - | let html = Renderer::permissive() | |
| 31 | - | .with_strip_images(true) // override: strip images even in permissive | |
| 32 | - | .with_footnotes(false) | |
| 33 | - | .render("# Custom config"); | |
| 34 | - | ||
| 35 | - | // Render with metadata (word count, reading time) | |
| 36 | - | let result = Renderer::standard().render_with_meta("Some article text..."); | |
| 37 | - | println!("{} words, ~{} min read", result.word_count, result.reading_time_minutes); | |
| 38 | - | ``` | |
| 39 | - | ||
| 40 | - | ## Feature Flags | |
| 41 | - | ||
| 42 | - | All optional features are off by default. Enable what you need: | |
| 43 | - | ||
| 44 | - | | Flag | Dependencies | Provides | | |
| 45 | - | |------|-------------|----------| | |
| 46 | - | | `doc-loader` | regex | `DocLoader` -- load a directory of `.md` files into an in-memory page store | | |
| 47 | - | | `directives` | regex-lite | `post_process_directives` -- `[!NOTE]`/`[!TIP]`/`[!TABS]` blockquote alerts and code tabs | | |
| 48 | - | | `frontmatter` | toml | `parse_frontmatter` -- extract TOML frontmatter delimited by `+++` | | |
| 49 | - | | `mentions` | regex-lite | `extract_mentions`, `resolve_mentions` -- `@username` parsing and linking | | |
| 50 | - | | `quotes` | regex-lite, uuid | `post_process_quotes` -- replace `[quote:POST_ID:HASH]` markers with author attribution | | |
| 51 | - | | `media-urls` | regex-lite | `rewrite_media_paths`, `img_to_video` -- CDN path rewriting and video tag conversion | | |
| 52 | - | | `full` | all of the above | Enable everything | | |
| 53 | - | ||
| 54 | - | ```toml | |
| 55 | - | # In Cargo.toml | |
| 56 | - | docengine = { path = "../shared/docengine" } # Core only (from MNW/server/) | |
| 57 | - | docengine = { path = "../../MNW/shared/docengine" } # From Apps/ | |
| 58 | - | ``` | |
| 59 | - | ||
| 60 | - | ## Core API | |
| 61 | - | ||
| 62 | - | ### Types | |
| 63 | - | ||
| 64 | - | - **`Renderer`** -- configurable markdown renderer with builder pattern | |
| 65 | - | - **`RenderResult`** -- rendered HTML plus `word_count` and `reading_time_minutes` | |
| 66 | - | - **`SanitizePreset`** -- `Permissive`, `Standard`, `Strict`, `Minimal` | |
| 67 | - | - **`TocEntry`** -- heading level, text, and anchor for table of contents | |
| 68 | - | ||
| 69 | - | ### Functions | |
| 70 | - | ||
| 71 | - | | Function | Description | | |
| 72 | - | |----------|-------------| | |
| 73 | - | | `render_permissive(md)` | Render with full GFM features | | |
| 74 | - | | `render_standard(md)` | Render without images | | |
| 75 | - | | `render_strict(md)` | Render with all restrictions (UGC-safe) | | |
| 76 | - | | `sanitize_html(html)` | Clean pre-rendered HTML without markdown parsing | | |
| 77 | - | | `word_count(text)` | Count words in raw text | | |
| 78 | - | | `reading_time_minutes(wc)` | Estimate reading time (200 wpm) | | |
| 79 | - | | `extract_title(md)` | Pull the first `# Heading` from markdown | | |
| 80 | - | | `strip_first_heading(md)` | Remove the first `# Heading` (for template-rendered titles) | | |
| 81 | - | | `extract_toc(md)` | Build a `Vec<TocEntry>` from all headings | | |
| 82 | - | | `render_toc_html(entries)` | Render TOC entries as a `<nav class="toc">` HTML list | | |
| 83 | - | ||
| 84 | - | ### Feature-gated | |
| 85 | - | ||
| 86 | - | | Function / Type | Feature | Description | | |
| 87 | - | |-----------------|---------|-------------| | |
| 88 | - | | `DocLoader::load(path, config)` | `doc-loader` | Load `.md` files from disk, render to HTML, build searchable index | | |
| 89 | - | | `DocPage`, `DocIndexEntry` | `doc-loader` | Page and index entry types | | |
| 90 | - | | `post_process_directives(html)` | `directives` | Convert `[!NOTE]`/`[!TIP]`/etc. blockquotes to alert divs, `[!TABS]` to tabbed code blocks | | |
| 91 | - | | `parse_frontmatter(input)` | `frontmatter` | Parse `+++`-delimited TOML frontmatter | | |
| 92 | - | | `Frontmatter` | `frontmatter` | Struct with `title`, `date`, `tags`, `section`, `draft`, `extra` | | |
| 93 | - | | `extract_mentions(md)` | `mentions` | Find unique `@username` mentions (skips code blocks) | | |
| 94 | - | | `resolve_mentions(md, valid, template)` | `mentions` | Replace `@user` with `[@user](/path/to/user)` for known usernames | | |
| 95 | - | | `post_process_quotes(html, authors)` | `quotes` | Replace `[quote:UUID:HASH]` with clickable attribution | | |
| 96 | - | | `rewrite_media_paths(md, base, user)` | `media-urls` | Rewrite relative image paths to absolute CDN URLs | | |
| 97 | - | | `img_to_video(html)` | `media-urls` | Convert `<img>` tags pointing to video files into `<video>` elements | | |
| 98 | - | ||
| 99 | - | ## Value substitution | |
| 100 | - | ||
| 101 | - | `{{ dotted.path | filter(args) }}` substitution used to be a docengine feature. It moved | |
| 102 | - | out on 2026-07-25 into two crates: [`subst`](../subst) (the generic engine) and | |
| 103 | - | [`mnw-assumptions`](../mnw-assumptions) (the MNW business-model layer on top). Nothing | |
| 104 | - | about the render path changed -- the server builds an `Assumptions` at boot and hands its | |
| 105 | - | `substitute` to `DocLoaderConfig::pre_process`, which is the same hook any other | |
| 106 | - | pre-render text transform would use. | |
| 107 | - | ||
| 108 | - | ## Consumers | |
| 109 | - | ||
| 110 | - | | Project | Features used | Preset | | |
| 111 | - | |---------|--------------|--------| | |
| 112 | - | | MNW | `doc-loader`, `directives`, `frontmatter`, `media-urls` | Permissive (docs/blog), Standard (descriptions) | | |
| 113 | - | | Multithreaded | `mentions`, `quotes` | Strict (forum posts) | | |
| 114 | - | | GoingsOn | core only | Standard (notes, descriptions) | | |
| 115 | - | | Balanced Breakfast | core only | Sanitize-only (RSS feed content) | | |
| 116 | - | ||
| 117 | - | ## Security | |
| 118 | - | ||
| 119 | - | All presets sanitize output through ammonia. The strict preset additionally: | |
| 120 | - | - Strips all raw HTML and images at the parser level (before ammonia) | |
| 121 | - | - Replaces `javascript:`, `data:`, `vbscript:` URLs with `#` | |
| 122 | - | - Adds `rel="noopener noreferrer nofollow"` to all links | |
| 123 | - | ||
| 124 | - | Zero unsafe code. | |
| 125 | - | ||
| 126 | - | ## License | |
| 127 | - | ||
| 128 | - | PolyForm Noncommercial 1.0.0 |
| @@ -1,186 +1,0 @@ | |||
| 1 | - | //! Render-path benchmarks. | |
| 2 | - | //! | |
| 3 | - | //! docengine sits on the MNW per-request render path (creator descriptions, | |
| 4 | - | //! forum posts) and the startup doc-load path (site-docs). These establish a | |
| 5 | - | //! baseline to measure any perf refactor against — parser reuse, `Arc<str>` | |
| 6 | - | //! sharing, or a directive pass that avoids re-scanning the whole HTML. | |
| 7 | - | //! | |
| 8 | - | //! Run with: `cargo bench --features full`. | |
| 9 | - | ||
| 10 | - | use std::collections::HashMap; | |
| 11 | - | use std::hint::black_box; | |
| 12 | - | use std::path::PathBuf; | |
| 13 | - | ||
| 14 | - | use criterion::{Criterion, criterion_group, criterion_main}; | |
| 15 | - | ||
| 16 | - | use docengine::{ | |
| 17 | - | DocLoader, DocLoaderConfig, QuoteAuthor, Renderer, post_process_directives, post_process_quotes, | |
| 18 | - | }; | |
| 19 | - | ||
| 20 | - | /// A ~2 KB markdown description, the shape of a typical creator project blurb or | |
| 21 | - | /// a short forum post: headings, emphasis, a link, a list, a code span, a table. | |
| 22 | - | fn description_2kb() -> String { | |
| 23 | - | let block = "\ | |
| 24 | - | ## What this is | |
| 25 | - | ||
| 26 | - | A small, focused tool for **doing one thing well**. It stays out of your way | |
| 27 | - | and does not phone home. See the [guide](/docs/guide/overview) for the full | |
| 28 | - | tour, or jump straight to `quickstart` below. | |
| 29 | - | ||
| 30 | - | - Fast, native builds — no runtime to install | |
| 31 | - | - Full export, no lock-in | |
| 32 | - | - Works offline first, syncs when you want it to | |
| 33 | - | ||
| 34 | - | | Tier | Price | Files | | |
| 35 | - | |------|-------|-------| | |
| 36 | - | | Basic | $16 | text | | |
| 37 | - | | Big Files | $36 | large | | |
| 38 | - | ||
| 39 | - | > A short aside about why this exists and who it is for. | |
| 40 | - | "; | |
| 41 | - | // The block is ~520 bytes; four copies lands near 2 KB. | |
| 42 | - | let mut s = String::with_capacity(2200); | |
| 43 | - | for _ in 0..4 { | |
| 44 | - | s.push_str(block); | |
| 45 | - | } | |
| 46 | - | s | |
| 47 | - | } | |
| 48 | - | ||
| 49 | - | /// A ~20 KB long-form doc page: many sections, alert directives, and images — | |
| 50 | - | /// the heavy end of what a single site-docs page renders to. | |
| 51 | - | fn doc_page_20kb() -> String { | |
| 52 | - | let section = "\ | |
| 53 | - | ## Section heading | |
| 54 | - | ||
| 55 | - | Longer explanatory prose that runs a few sentences so the parser has real text | |
| 56 | - | to walk, not just markup. It references [another page](/docs/guide/other) and | |
| 57 | - | uses `inline code` plus **bold** and *italic* spans throughout the paragraph. | |
| 58 | - | ||
| 59 | - |  | |
| 60 | - | ||
| 61 | - | > [!NOTE] | |
| 62 | - | > A callout that the directives post-processor will rewrite into a styled alert | |
| 63 | - | > block. These are common in the developer docs. | |
| 64 | - | ||
| 65 | - | > [!WARNING] | |
| 66 | - | > A second callout of a different type, to exercise the type-matching arm. | |
| 67 | - | ||
| 68 | - | 1. First ordered step with some detail | |
| 69 | - | 2. Second step that continues the thought | |
| 70 | - | 3. Third step to close it out | |
| 71 | - | "; | |
| 72 | - | // The section is ~640 bytes; ~32 copies lands near 20 KB. | |
| 73 | - | let mut s = String::with_capacity(21_000); | |
| 74 | - | for _ in 0..32 { | |
| 75 | - | s.push_str(section); | |
| 76 | - | } | |
| 77 | - | s | |
| 78 | - | } | |
| 79 | - | ||
| 80 | - | /// Locate the live site-docs corpus relative to this crate. Returns `None` when | |
| 81 | - | /// the MNW server tree is not checked out alongside docengine (e.g. the crate | |
| 82 | - | /// was vendored standalone), so the load benchmark self-skips instead of failing. | |
| 83 | - | fn site_docs_path() -> Option<PathBuf> { | |
| 84 | - | let p = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../server/site-docs/public"); | |
| 85 | - | p.is_dir().then_some(p) | |
| 86 | - | } | |
| 87 | - | ||
| 88 | - | fn server_doc_config() -> DocLoaderConfig { | |
| 89 | - | // Mirrors the sections the MNW server registers in main.rs. pre_process is | |
| 90 | - | // left None: assumption substitution is a separate cost, benched elsewhere. | |
| 91 | - | DocLoaderConfig { | |
| 92 | - | sections: vec![ | |
| 93 | - | ("about".to_string(), "About".to_string()), | |
| 94 | - | ("guide".to_string(), "Guide".to_string()), | |
| 95 | - | ("developer".to_string(), "Developer".to_string()), | |
| 96 | - | ("legal".to_string(), "Legal".to_string()), | |
| 97 | - | ("support".to_string(), "Support".to_string()), | |
| 98 | - | ("tech".to_string(), "Tech".to_string()), | |
| 99 | - | ], | |
| 100 | - | link_prefix: "/docs".to_string(), | |
| 101 | - | unpublished_pattern: Some("unpublished/".to_string()), | |
| 102 | - | examples_path: None, | |
| 103 | - | pre_process: None, | |
| 104 | - | } | |
| 105 | - | } | |
| 106 | - | ||
| 107 | - | // (1) Renderer::render across the four presets on a ~2 KB description. | |
| 108 | - | fn bench_presets(c: &mut Criterion) { | |
| 109 | - | let input = description_2kb(); | |
| 110 | - | let mut group = c.benchmark_group("render_presets_2kb"); | |
| 111 | - | for (name, renderer) in [ | |
| 112 | - | ("permissive", Renderer::permissive()), | |
| 113 | - | ("standard", Renderer::standard()), | |
| 114 | - | ("strict", Renderer::strict()), | |
| 115 | - | ("sanitize_only", Renderer::sanitize_only()), | |
| 116 | - | ] { | |
| 117 | - | group.bench_function(name, |b| { | |
| 118 | - | b.iter(|| renderer.render(black_box(&input))); | |
| 119 | - | }); | |
| 120 | - | } | |
| 121 | - | group.finish(); | |
| 122 | - | } | |
| 123 | - | ||
| 124 | - | // (2) render on a large ~20 KB doc page with directives + images (permissive, | |
| 125 | - | // the preset site-docs pages render under). | |
| 126 | - | fn bench_large_page(c: &mut Criterion) { | |
| 127 | - | let input = doc_page_20kb(); | |
| 128 | - | c.bench_function("render_permissive_20kb_page", |b| { | |
| 129 | - | let renderer = Renderer::permissive(); | |
| 130 | - | b.iter(|| renderer.render(black_box(&input))); | |
| 131 | - | }); | |
| 132 | - | } | |
| 133 | - | ||
| 134 | - | // (3) DocLoader::load startup on the current server/site-docs corpus. | |
| 135 | - | fn bench_doc_loader(c: &mut Criterion) { | |
| 136 | - | let Some(path) = site_docs_path() else { | |
| 137 | - | eprintln!("skipping doc_loader_load: site-docs corpus not found alongside crate"); | |
| 138 | - | return; | |
| 139 | - | }; | |
| 140 | - | c.bench_function("doc_loader_load_site_docs", |b| { | |
| 141 | - | b.iter(|| DocLoader::load(black_box(&path), &server_doc_config())); | |
| 142 | - | }); | |
| 143 | - | } | |
| 144 | - | ||
| 145 | - | // (4) post_process_directives + post_process_quotes, separately, on | |
| 146 | - | // already-rendered HTML (both run after render() on the request path). | |
| 147 | - | fn bench_post_process(c: &mut Criterion) { | |
| 148 | - | // Directives operate on rendered HTML, so render the alert-heavy page first. | |
| 149 | - | let directive_html = Renderer::permissive().render(&doc_page_20kb()); | |
| 150 | - | c.bench_function("post_process_directives_20kb", |b| { | |
| 151 | - | b.iter(|| post_process_directives(black_box(&directive_html))); | |
| 152 | - | }); | |
| 153 | - | ||
| 154 | - | // Quotes replace [quote:UUID:HASH] markers with author attribution. Build a | |
| 155 | - | // page carrying several markers and a matching author map. | |
| 156 | - | let id = uuid::Uuid::from_u128(0x0123_4567_89ab_cdef_0123_4567_89ab_cdef); | |
| 157 | - | let marker = format!("[quote:{id}:0123abcd]"); | |
| 158 | - | let mut quote_html = String::with_capacity(4096); | |
| 159 | - | for _ in 0..16 { | |
| 160 | - | quote_html.push_str("<p>Some preceding paragraph text for context.</p>\n"); | |
| 161 | - | quote_html.push_str("<blockquote><p>"); | |
| 162 | - | quote_html.push_str(&marker); | |
| 163 | - | quote_html.push_str("</p></blockquote>\n"); | |
| 164 | - | } | |
| 165 | - | let mut authors = HashMap::new(); | |
| 166 | - | authors.insert( | |
| 167 | - | id, | |
| 168 | - | QuoteAuthor { | |
| 169 | - | username: "creator".to_string(), | |
| 170 | - | display_name: "A Creator".to_string(), | |
| 171 | - | is_removed: false, | |
| 172 | - | }, | |
| 173 | - | ); | |
| 174 | - | c.bench_function("post_process_quotes_16_markers", |b| { | |
| 175 | - | b.iter(|| post_process_quotes(black_box("e_html), black_box(&authors))); | |
| 176 | - | }); | |
| 177 | - | } | |
| 178 | - | ||
| 179 | - | criterion_group!( | |
| 180 | - | benches, | |
| 181 | - | bench_presets, | |
| 182 | - | bench_large_page, | |
| 183 | - | bench_doc_loader, | |
| 184 | - | bench_post_process | |
| 185 | - | ); | |
| 186 | - | criterion_main!(benches); |
| @@ -1,97 +1,0 @@ | |||
| 1 | - | # DocEngine Architecture | |
| 2 | - | ||
| 3 | - | ## Overview | |
| 4 | - | ||
| 5 | - | DocEngine is a markdown rendering library that wraps pulldown-cmark (parsing) and ammonia (sanitization) behind a preset system. Each preset configures which markdown features are enabled and how aggressively the output is sanitized. | |
| 6 | - | ||
| 7 | - | ## Module Map | |
| 8 | - | ||
| 9 | - | ``` | |
| 10 | - | src/ | |
| 11 | - | lib.rs Crate root, re-exports, convenience functions | |
| 12 | - | render.rs Renderer struct (builder pattern, 4 presets, render/render_with_meta) | |
| 13 | - | sanitize.rs SanitizePreset enum (Permissive, Standard, Strict, Minimal) | |
| 14 | - | text.rs Text utilities (word_count, reading_time, extract_title, strip_first_heading) | |
| 15 | - | toc.rs Table of contents extraction and HTML rendering | |
| 16 | - | escape.rs HTML entity escaping for safe string interpolation | |
| 17 | - | code_spans.rs Code span/block byte range detection (used by mentions to skip code) | |
| 18 | - | directives.rs [directives] Alert/tabs blockquote post-processing | |
| 19 | - | doc_loader.rs [doc-loader] Load .md files from disk into in-memory page store | |
| 20 | - | frontmatter.rs [frontmatter] Parse +++delimited TOML frontmatter | |
| 21 | - | media_urls.rs [media-urls] CDN path rewriting for images, img-to-video conversion | |
| 22 | - | mentions.rs [mentions] @username extraction and resolution | |
| 23 | - | quotes.rs [quotes] [quote:UUID:HASH] post-processing for forum attribution | |
| 24 | - | ``` | |
| 25 | - | ||
| 26 | - | ## Design Decisions | |
| 27 | - | ||
| 28 | - | ### Presets over configuration | |
| 29 | - | ||
| 30 | - | Rather than exposing every pulldown-cmark option, DocEngine provides named presets that bundle markdown features with sanitization levels. This prevents misconfiguration -- you can't accidentally enable raw HTML without appropriate sanitization. | |
| 31 | - | ||
| 32 | - | Custom configurations are still possible via the builder pattern (`Renderer::permissive().with_strip_images(true)`). | |
| 33 | - | ||
| 34 | - | ### Two-phase rendering | |
| 35 | - | ||
| 36 | - | Rendering happens in two phases: | |
| 37 | - | 1. **pulldown-cmark** parses markdown to HTML events, with optional filtering (strip images, strip raw HTML, neutralize dangerous URL schemes) | |
| 38 | - | 2. **ammonia** sanitizes the resulting HTML string | |
| 39 | - | ||
| 40 | - | This means even the permissive preset strips `<script>` tags -- ammonia always runs. | |
| 41 | - | ||
| 42 | - | Post-processing steps (directives, mentions, quotes, media URLs) are applied after sanitization by consumers, not built into the render pipeline. | |
| 43 | - | ||
| 44 | - | ### Feature-gated modules | |
| 45 | - | ||
| 46 | - | DocEngine has zero required dependencies beyond pulldown-cmark, ammonia, and serde. Consumers that only need rendering don't pull in regex, toml, or uuid. The `full` feature enables everything. | |
| 47 | - | ||
| 48 | - | The `regex` vs `regex-lite` split is intentional -- doc-loader's link rewriting needs the full regex engine while simpler patterns in directives, mentions, quotes, and media-urls use the lighter variant. | |
| 49 | - | ||
| 50 | - | ### DocLoader loads once at startup | |
| 51 | - | ||
| 52 | - | `DocLoader::load()` reads all `.md` files from disk, renders them to HTML, and stores them in a `HashMap<String, DocPage>`. This happens once at application boot (MNW calls it during startup). Pages are served from memory with no disk I/O on request. | |
| 53 | - | ||
| 54 | - | Link rewriting converts relative `.md` references to the configured URL prefix (e.g., `./faq.md` becomes `/docs/faq`). Links to unpublished docs are stripped to plain text. | |
| 55 | - | ||
| 56 | - | ### Value substitution is a consumer concern, not a docengine feature | |
| 57 | - | ||
| 58 | - | `{{ dotted.path | filter(args) }}` substitution shipped as a docengine feature until | |
| 59 | - | 2026-07-25. It was a business-model calculator with a templater attached, and the parts a | |
| 60 | - | doc engine actually needs from it are zero. It now lives in `../subst` (the generic | |
| 61 | - | engine) and `../mnw-assumptions` (the MNW-specific toml, derived math, and validation). | |
| 62 | - | ||
| 63 | - | Substitution still runs before parsing, for the reason it always did: a regex pre-pass | |
| 64 | - | sees the raw text, so markers may appear anywhere -- prose, code spans, table cells, link | |
| 65 | - | text. A markdown-aware pass would either miss code spans (often exactly where a number | |
| 66 | - | belongs) or mean re-implementing parts of the parser. | |
| 67 | - | ||
| 68 | - | The seam is `DocLoaderConfig::pre_process`, a plain `Fn(&str) -> Result<String, String>` | |
| 69 | - | applied to each file's text before rendering. MNW's server builds an `Assumptions` at boot | |
| 70 | - | and hands over its `substitute`. Any other pre-render text transform plugs into the same | |
| 71 | - | hook without docengine growing a feature flag for it. | |
| 72 | - | ||
| 73 | - | ### Mention resolution skips code | |
| 74 | - | ||
| 75 | - | `extract_mentions` and `resolve_mentions` detect inline code (backticks) and fenced code blocks, skipping any @mentions inside them. This prevents false positives from code examples. | |
| 76 | - | ||
| 77 | - | ### Directive post-processing | |
| 78 | - | ||
| 79 | - | Directives (`[!NOTE]`, `[!TIP]`, `[!TABS]`, etc.) are implemented as HTML post-processing rather than markdown parsing extensions. This keeps the core render pipeline simple and makes directives composable with any preset. | |
| 80 | - | ||
| 81 | - | ## Consumers | |
| 82 | - | ||
| 83 | - | | Consumer | Features | How it's used | | |
| 84 | - | |----------|----------|---------------| | |
| 85 | - | | MNW | doc-loader, directives, frontmatter, media-urls | Site docs loaded at boot, blog posts with frontmatter, user descriptions (standard), item markdown (standard), CDN image rewriting, and a `pre_process` hook carrying mnw-assumptions substitution | | |
| 86 | - | | Multithreaded | mentions, quotes | Forum posts (strict), @username linking, quote attribution | | |
| 87 | - | | GoingsOn | core | Task/event descriptions (standard) | | |
| 88 | - | | Balanced Breakfast | core | RSS feed content (sanitize_only) | | |
| 89 | - | | audiofiles | core | Sample descriptions (standard) | | |
| 90 | - | ||
| 91 | - | ## Key Paths | |
| 92 | - | ||
| 93 | - | - `src/render.rs` -- the core rendering logic | |
| 94 | - | - `src/sanitize.rs` -- ammonia preset configurations | |
| 95 | - | - `src/directives.rs` -- alert and code tab processing | |
| 96 | - | - `src/doc_loader.rs` -- document loading and link rewriting | |
| 97 | - | - `src/media_urls.rs` -- CDN path rewriting |
| @@ -1,188 +1,0 @@ | |||
| 1 | - | /// Strip inline code (backtick) and fenced code blocks, replacing with spaces. | |
| 2 | - | #[cfg_attr(not(any(feature = "mentions", test)), allow(dead_code))] | |
| 3 | - | pub(crate) fn strip_code_spans(input: &str) -> String { | |
| 4 | - | let mut out = String::with_capacity(input.len()); | |
| 5 | - | let mut chars = input.chars().peekable(); | |
| 6 | - | ||
| 7 | - | while let Some(ch) = chars.next() { | |
| 8 | - | if ch == '`' { | |
| 9 | - | let mut tick_count = 1; | |
| 10 | - | while chars.peek() == Some(&'`') { | |
| 11 | - | tick_count += 1; | |
| 12 | - | chars.next(); | |
| 13 | - | } | |
| 14 | - | let mut skipped = 0; | |
| 15 | - | while let Some(c) = chars.next() { | |
| 16 | - | skipped += 1; | |
| 17 | - | if c == '`' { | |
| 18 | - | let mut close_count = 1; | |
| 19 | - | while chars.peek() == Some(&'`') { | |
| 20 | - | close_count += 1; | |
| 21 | - | chars.next(); | |
| 22 | - | } | |
| 23 | - | if close_count == tick_count { | |
| 24 | - | break; | |
| 25 | - | } | |
| 26 | - | } | |
| 27 | - | } | |
| 28 | - | let total = tick_count * 2 + skipped; | |
| 29 | - | for _ in 0..total { | |
| 30 | - | out.push(' '); | |
| 31 | - | } | |
| 32 | - | } else { | |
| 33 | - | out.push(ch); | |
| 34 | - | } | |
| 35 | - | } | |
| 36 | - | out | |
| 37 | - | } | |
| 38 | - | ||
| 39 | - | /// Return byte ranges of inline code spans and fenced code blocks. | |
| 40 | - | pub(crate) fn code_span_ranges(input: &str) -> Vec<(usize, usize)> { | |
| 41 | - | let mut ranges = Vec::new(); | |
| 42 | - | let bytes = input.as_bytes(); | |
| 43 | - | let len = bytes.len(); | |
| 44 | - | let mut i = 0; | |
| 45 | - | ||
| 46 | - | while i < len { | |
| 47 | - | if bytes[i] == b'`' { | |
| 48 | - | let start = i; | |
| 49 | - | let mut tick_count = 0; | |
| 50 | - | while i < len && bytes[i] == b'`' { | |
| 51 | - | tick_count += 1; | |
| 52 | - | i += 1; | |
| 53 | - | } | |
| 54 | - | let mut found = false; | |
| 55 | - | while i < len { | |
| 56 | - | if bytes[i] == b'`' { | |
| 57 | - | let mut close_count = 0; | |
| 58 | - | while i < len && bytes[i] == b'`' { | |
| 59 | - | close_count += 1; | |
| 60 | - | i += 1; | |
| 61 | - | } | |
| 62 | - | if close_count == tick_count { | |
| 63 | - | ranges.push((start, i)); | |
| 64 | - | found = true; | |
| 65 | - | break; | |
| 66 | - | } | |
| 67 | - | } else { | |
| 68 | - | i += 1; | |
| 69 | - | } | |
| 70 | - | } | |
| 71 | - | if !found { | |
| 72 | - | ranges.push((start, len)); | |
| 73 | - | } | |
| 74 | - | } else { | |
| 75 | - | i += 1; | |
| 76 | - | } | |
| 77 | - | } | |
| 78 | - | ranges | |
| 79 | - | } | |
| 80 | - | ||
| 81 | - | #[cfg(test)] | |
| 82 | - | mod tests { | |
| 83 | - | use super::*; | |
| 84 | - | ||
| 85 | - | #[test] | |
| 86 | - | fn strip_inline_code() { | |
| 87 | - | let result = strip_code_spans("hello `code` world"); | |
| 88 | - | assert!(!result.contains("code")); | |
| 89 | - | assert!(result.contains("hello")); | |
| 90 | - | assert!(result.contains("world")); | |
| 91 | - | } | |
| 92 | - | ||
| 93 | - | #[test] | |
| 94 | - | fn strip_fenced_code() { | |
| 95 | - | let result = strip_code_spans("text\n```\ncode block\n```\nmore"); | |
| 96 | - | assert!(!result.contains("code block")); | |
| 97 | - | assert!(result.contains("text")); | |
| 98 | - | assert!(result.contains("more")); | |
| 99 | - | } | |
| 100 | - | ||
| 101 | - | #[test] | |
| 102 | - | fn ranges_inline_code() { | |
| 103 | - | let input = "hello `code` world"; | |
| 104 | - | let ranges = code_span_ranges(input); | |
| 105 | - | assert_eq!(ranges.len(), 1); | |
| 106 | - | let (start, end) = ranges[0]; | |
| 107 | - | assert_eq!(&input[start..end], "`code`"); | |
| 108 | - | } | |
| 109 | - | ||
| 110 | - | #[test] | |
| 111 | - | fn ranges_fenced_code() { | |
| 112 | - | let input = "text\n```\ncode\n```\nmore"; | |
| 113 | - | let ranges = code_span_ranges(input); | |
| 114 | - | assert_eq!(ranges.len(), 1); | |
| 115 | - | let (start, end) = ranges[0]; | |
| 116 | - | assert!(input[start..end].starts_with("```")); | |
| 117 | - | assert!(input[start..end].ends_with("```")); | |
| 118 | - | } | |
| 119 | - | ||
| 120 | - | #[test] | |
| 121 | - | fn ranges_unclosed_backtick() { | |
| 122 | - | let input = "hello `unclosed"; | |
| 123 | - | let ranges = code_span_ranges(input); | |
| 124 | - | assert_eq!(ranges.len(), 1); | |
| 125 | - | assert_eq!(ranges[0], (6, input.len())); | |
| 126 | - | } | |
| 127 | - | ||
| 128 | - | #[test] | |
| 129 | - | fn no_code_spans() { | |
| 130 | - | assert!(code_span_ranges("no code here").is_empty()); | |
| 131 | - | assert_eq!(strip_code_spans("no code here"), "no code here"); | |
| 132 | - | } | |
| 133 | - | ||
| 134 | - | #[test] | |
| 135 | - | fn strip_triple_backtick_exact_space_count() { | |
| 136 | - | // For ```ab```: tick_count=3, skipped=3 (a,b,`), total = 3*2 + 3 = 9. | |
| 137 | - | // Distinguishes `*` from `+` (3+2=5) and pins `+ skipped` vs `- skipped`. | |
| 138 | - | let result = strip_code_spans("```ab```"); | |
| 139 | - | let spaces = result.chars().filter(|c| *c == ' ').count(); | |
| 140 | - | assert_eq!(spaces, 9, "expected 3*2 + 3 = 9 spaces, got {result:?}"); | |
| 141 | - | } | |
| 142 | - | ||
| 143 | - | #[test] | |
| 144 | - | fn strip_single_backtick_exact_space_count() { | |
| 145 | - | // For `a`: tick_count=1, skipped=2, total = 1*2 + 2 = 4. | |
| 146 | - | // Distinguishes `tick_count * 2` from `tick_count + 2` (3 vs 4). | |
| 147 | - | let result = strip_code_spans("`a`"); | |
| 148 | - | let spaces = result.chars().filter(|c| *c == ' ').count(); | |
| 149 | - | assert_eq!(spaces, 4, "expected 1*2 + 2 = 4 spaces, got {result:?}"); | |
| 150 | - | } | |
| 151 | - | ||
| 152 | - | #[test] | |
| 153 | - | fn double_backticks_require_double_close() { | |
| 154 | - | // ``a`b`` — single ` inside must NOT close the double-tick span. | |
| 155 | - | let input = "``a`b``"; | |
| 156 | - | let ranges = code_span_ranges(input); | |
| 157 | - | assert_eq!(ranges.len(), 1, "the inner single ` must not close"); | |
| 158 | - | assert_eq!(&input[ranges[0].0..ranges[0].1], "``a`b``"); | |
| 159 | - | } | |
| 160 | - | ||
| 161 | - | #[test] | |
| 162 | - | fn mismatched_tick_counts_dont_close_span() { | |
| 163 | - | // Open with 1 tick, close attempt with 3 ticks: close_count=3 != tick_count=1. | |
| 164 | - | // Span never closes → runs to EOF. Pins `close_count == tick_count`. | |
| 165 | - | let input = "`code```"; | |
| 166 | - | let ranges = code_span_ranges(input); | |
| 167 | - | assert_eq!(ranges.len(), 1); | |
| 168 | - | assert_eq!(ranges[0], (0, input.len())); | |
| 169 | - | } | |
| 170 | - | ||
| 171 | - | #[test] | |
| 172 | - | fn multiple_disjoint_spans_get_separate_ranges() { | |
| 173 | - | let input = "a `one` b `two` c"; | |
| 174 | - | let ranges = code_span_ranges(input); | |
| 175 | - | assert_eq!(ranges.len(), 2); | |
| 176 | - | assert_eq!(&input[ranges[0].0..ranges[0].1], "`one`"); | |
| 177 | - | assert_eq!(&input[ranges[1].0..ranges[1].1], "`two`"); | |
| 178 | - | } | |
| 179 | - | ||
| 180 | - | #[test] | |
| 181 | - | fn unclosed_span_range_ends_at_input_len() { | |
| 182 | - | // Pins the `if !found { ranges.push((start, len)); }` branch. | |
| 183 | - | let input = "abc `unclosed"; | |
| 184 | - | let ranges = code_span_ranges(input); | |
| 185 | - | assert_eq!(ranges.len(), 1); | |
| 186 | - | assert_eq!(ranges[0], (4, input.len())); | |
| 187 | - | } | |
| 188 | - | } |
| @@ -1,660 +1,0 @@ | |||
| 1 | - | //! Post-process rendered HTML to convert blockquote-based directives into | |
| 2 | - | //! styled elements. | |
| 3 | - | //! | |
| 4 | - | //! **Alerts:** `> [!NOTE]`, `> [!TIP]`, `> [!WARNING]`, `> [!CAUTION]`, | |
| 5 | - | //! `> [!IMPORTANT]`, and any custom `> [!TYPE]` marker become styled | |
| 6 | - | //! `<div class="alert alert-{type}">` callout elements. | |
| 7 | - | //! | |
| 8 | - | //! **Code tabs:** `> [!TABS]` followed by fenced code blocks become a tabbed | |
| 9 | - | //! interface with language-labelled tabs. | |
| 10 | - | ||
| 11 | - | use std::sync::LazyLock; | |
| 12 | - | ||
| 13 | - | /// Matches any `[!TYPE]` alert marker inside a blockquote paragraph. | |
| 14 | - | /// Accepts any uppercase word (letters, digits, hyphens, underscores). | |
| 15 | - | static ALERT_RE: LazyLock<regex_lite::Regex> = LazyLock::new(|| { | |
| 16 | - | regex_lite::Regex::new(r"<blockquote>\s*<p>\[!([A-Z][A-Z0-9_-]*)\](?:<br\s*/?>)?\s*") | |
| 17 | - | .expect("valid alert regex") | |
| 18 | - | }); | |
| 19 | - | ||
| 20 | - | /// Process all directives: UI examples first, then code tabs, then alerts. | |
| 21 | - | pub fn post_process_directives(html: &str) -> String { | |
| 22 | - | let with_ui = process_ui_examples(html); | |
| 23 | - | let with_tabs = process_tabs(&with_ui); | |
| 24 | - | process_alerts(&with_tabs) | |
| 25 | - | } | |
| 26 | - | ||
| 27 | - | /// Regex matching `[!UI] example-name` inside a blockquote paragraph. | |
| 28 | - | /// Captures the example name (alphanumeric, hyphens, underscores). | |
| 29 | - | static UI_RE: LazyLock<regex_lite::Regex> = LazyLock::new(|| { | |
| 30 | - | regex_lite::Regex::new(r"<blockquote>\s*<p>\[!UI\]\s+([a-z0-9_-]+)(?:<br\s*/?>)?\s*") | |
| 31 | - | .expect("valid UI regex") | |
| 32 | - | }); | |
| 33 | - | ||
| 34 | - | /// Replace `[!UI] name` blockquotes with `<figure>` placeholder elements. | |
| 35 | - | /// | |
| 36 | - | /// The placeholder carries `data-ui="name"` for the doc loader to resolve. | |
| 37 | - | /// Any text after the name line becomes a `<figcaption>`. | |
| 38 | - | fn process_ui_examples(html: &str) -> String { | |
| 39 | - | if !html.contains("[!UI]") { | |
| 40 | - | return html.to_string(); | |
| 41 | - | } | |
| 42 | - | ||
| 43 | - | let mut result = String::with_capacity(html.len()); | |
| 44 | - | let mut remaining = html; | |
| 45 | - | ||
| 46 | - | while let Some(bq_pos) = remaining.find("<blockquote>") { | |
| 47 | - | let close_pos = match remaining[bq_pos..].find("</blockquote>") { | |
| 48 | - | Some(p) => bq_pos + p, | |
| 49 | - | None => break, | |
| 50 | - | }; | |
| 51 | - | ||
| 52 | - | // Check if this blockquote contains [!UI] (check only up to its closing tag). | |
| 53 | - | let bq_slice = &remaining[bq_pos..close_pos + "</blockquote>".len()]; | |
| 54 | - | let is_ui = UI_RE.is_match(bq_slice); | |
| 55 | - | ||
| 56 | - | if !is_ui { | |
| 57 | - | // Not a UI blockquote — copy through the entire blockquote and continue. | |
| 58 | - | let end = close_pos + "</blockquote>".len(); | |
| 59 | - | result.push_str(&remaining[..end]); | |
| 60 | - | remaining = &remaining[end..]; | |
| 61 | - | continue; | |
| 62 | - | } | |
| 63 | - | ||
| 64 | - | // Copy everything before this blockquote. | |
| 65 | - | result.push_str(&remaining[..bq_pos]); | |
| 66 | - | ||
| 67 | - | // Extract the example name. | |
| 68 | - | if let Some(caps) = UI_RE.captures(bq_slice) { | |
| 69 | - | let name = &caps[1]; | |
| 70 | - | let marker_end = caps[0].len(); | |
| 71 | - | ||
| 72 | - | // Everything after the marker line is the caption. | |
| 73 | - | let after_marker = &remaining[(bq_pos + marker_end)..close_pos]; | |
| 74 | - | let caption = strip_html_tags_simple(after_marker).trim().to_string(); | |
| 75 | - | ||
| 76 | - | result.push_str(&format!("<figure class=\"doc-ui\" data-ui=\"{name}\">")); | |
| 77 | - | result.push_str(&format!( | |
| 78 | - | "<div class=\"doc-ui-frame\" data-ui=\"{name}\"></div>" | |
| 79 | - | )); | |
| 80 | - | if !caption.is_empty() { | |
| 81 | - | result.push_str(&format!("<figcaption>{caption}</figcaption>")); | |
| 82 | - | } | |
| 83 | - | result.push_str("</figure>"); | |
| 84 | - | } | |
| 85 | - | ||
| 86 | - | remaining = &remaining[close_pos + "</blockquote>".len()..]; | |
| 87 | - | } | |
| 88 | - | ||
| 89 | - | result.push_str(remaining); | |
| 90 | - | result | |
| 91 | - | } | |
| 92 | - | ||
| 93 | - | /// Minimal tag stripper for extracting caption text from inner HTML. | |
| 94 | - | fn strip_html_tags_simple(html: &str) -> String { | |
| 95 | - | let mut out = String::with_capacity(html.len()); | |
| 96 | - | let mut in_tag = false; | |
| 97 | - | for ch in html.chars() { | |
| 98 | - | match ch { | |
| 99 | - | '<' => in_tag = true, | |
| 100 | - | '>' => { | |
| 101 | - | in_tag = false; | |
| 102 | - | } | |
| 103 | - | _ if !in_tag => out.push(ch), | |
| 104 | - | _ => {} | |
| 105 | - | } | |
| 106 | - | } | |
| 107 | - | out | |
| 108 | - | } | |
| 109 | - | ||
| 110 | - | /// Replace alert blockquotes with styled `<div class="alert ...">` elements. | |
| 111 | - | fn process_alerts(html: &str) -> String { | |
| 112 | - | // First pass: replace opening markers. | |
| 113 | - | let opened = ALERT_RE.replace_all(html, |caps: ®ex_lite::Captures| { | |
| 114 | - | let kind = &caps[1]; | |
| 115 | - | // Skip TABS and UI — already handled by their own processors. | |
| 116 | - | if kind == "TABS" || kind == "UI" { | |
| 117 | - | return caps[0].to_string(); | |
| 118 | - | } | |
| 119 | - | let label = title_case(kind); | |
| 120 | - | format!( | |
| 121 | - | "<div class=\"alert alert-{kind}\"><p class=\"alert-title\">{label}</p><p>", | |
| 122 | - | kind = kind.to_ascii_lowercase(), | |
| 123 | - | label = label, | |
| 124 | - | ) | |
| 125 | - | }); | |
| 126 | - | ||
| 127 | - | // Second pass: close any opened alerts. | |
| 128 | - | let alert_count = ALERT_RE | |
| 129 | - | .captures_iter(html) | |
| 130 | - | .filter(|c| &c[1] != "TABS" && &c[1] != "UI") | |
| 131 | - | .count(); | |
| 132 | - | if alert_count == 0 { | |
| 133 | - | return opened.into_owned(); | |
| 134 | - | } | |
| 135 | - | ||
| 136 | - | let mut result = String::with_capacity(opened.len()); | |
| 137 | - | let mut remaining = opened.as_ref(); | |
| 138 | - | let mut replaced = 0; | |
| 139 | - | ||
| 140 | - | while replaced < alert_count { | |
| 141 | - | if let Some(pos) = remaining.find("</blockquote>") { | |
| 142 | - | result.push_str(&remaining[..pos]); | |
| 143 | - | result.push_str("</div>"); | |
| 144 | - | remaining = &remaining[(pos + "</blockquote>".len())..]; | |
| 145 | - | replaced += 1; | |
| 146 | - | } else { | |
| 147 | - | break; | |
| 148 | - | } | |
| 149 | - | } | |
| 150 | - | result.push_str(remaining); | |
| 151 | - | result | |
| 152 | - | } | |
| 153 | - | ||
| 154 | - | /// Process `[!TABS]` blockquotes into tabbed code-block interfaces. | |
| 155 | - | fn process_tabs(html: &str) -> String { | |
| 156 | - | if !html.contains("[!TABS]") { | |
| 157 | - | return html.to_string(); | |
| 158 | - | } | |
| 159 | - | ||
| 160 | - | let mut result = String::with_capacity(html.len()); | |
| 161 | - | let mut remaining = html; | |
| 162 | - | ||
| 163 | - | while let Some(bq_pos) = remaining.find("<blockquote>") { | |
| 164 | - | let after_bq_start = bq_pos + "<blockquote>".len(); | |
| 165 | - | ||
| 166 | - | // Find the closing </blockquote> for this blockquote. | |
| 167 | - | let close_pos = match remaining[bq_pos..].find("</blockquote>") { | |
| 168 | - | Some(p) => bq_pos + p, | |
| 169 | - | None => break, | |
| 170 | - | }; | |
| 171 | - | ||
| 172 | - | let inner = &remaining[after_bq_start..close_pos]; | |
| 173 | - | ||
| 174 | - | // Check if the first <p> in the blockquote contains [!TABS]. | |
| 175 | - | let is_tabs = { | |
| 176 | - | let trimmed = inner.trim_start(); | |
| 177 | - | trimmed.starts_with("<p>") && { | |
| 178 | - | let first_p_end = trimmed.find("</p>").unwrap_or(trimmed.len()); | |
| 179 | - | trimmed[..first_p_end].contains("[!TABS]") | |
| 180 | - | } | |
| 181 | - | }; | |
| 182 | - | ||
| 183 | - | if !is_tabs { | |
| 184 | - | // Not a TABS blockquote — copy through the opening tag and continue. | |
| 185 | - | result.push_str(&remaining[..after_bq_start]); | |
| 186 | - | remaining = &remaining[after_bq_start..]; | |
| 187 | - | continue; | |
| 188 | - | } | |
| 189 | - | ||
| 190 | - | // Copy everything before this blockquote. | |
| 191 | - | result.push_str(&remaining[..bq_pos]); | |
| 192 | - | ||
| 193 | - | // Extract code blocks from the inner HTML. | |
| 194 | - | let tabs = extract_code_blocks(inner); | |
| 195 | - | ||
| 196 | - | if tabs.is_empty() { | |
| 197 | - | // No code blocks found — wrap content in a plain div. | |
| 198 | - | result.push_str("<div class=\"code-tabs\">"); | |
| 199 | - | result.push_str(inner); | |
| 200 | - | result.push_str("</div>"); | |
| 201 | - | } else { | |
| 202 | - | result.push_str(&build_tabs_html(&tabs)); | |
| 203 | - | } | |
| 204 | - | ||
| 205 | - | remaining = &remaining[close_pos + "</blockquote>".len()..]; | |
| 206 | - | } | |
| 207 | - | ||
| 208 | - | result.push_str(remaining); | |
| 209 | - | result | |
| 210 | - | } | |
| 211 | - | ||
| 212 | - | /// Extract `(language, full_html_block)` pairs from HTML containing | |
| 213 | - | /// `<pre><code>` elements. | |
| 214 | - | fn extract_code_blocks(html: &str) -> Vec<(String, String)> { | |
| 215 | - | let mut blocks = Vec::new(); | |
| 216 | - | let mut search_from = 0; | |
| 217 | - | let end_marker = "</code></pre>"; | |
| 218 | - | ||
| 219 | - | while let Some(pre_pos) = html[search_from..].find("<pre><code") { | |
| 220 | - | let abs_pos = search_from + pre_pos; | |
| 221 | - | ||
| 222 | - | let end_pos = match html[abs_pos..].find(end_marker) { | |
| 223 | - | Some(p) => abs_pos + p + end_marker.len(), | |
| 224 | - | None => break, | |
| 225 | - | }; | |
| 226 | - | ||
| 227 | - | let full_block = &html[abs_pos..end_pos]; | |
| 228 | - | ||
| 229 | - | // Extract language from class="language-X". | |
| 230 | - | let lang = if let Some(class_start) = full_block.find("class=\"language-") { | |
| 231 | - | let after = &full_block[class_start + "class=\"language-".len()..]; | |
| 232 | - | after.split('"').next().unwrap_or("code").to_string() | |
| 233 | - | } else { | |
| 234 | - | "code".to_string() | |
| 235 | - | }; | |
| 236 | - | ||
| 237 | - | blocks.push((lang, full_block.to_string())); | |
| 238 | - | search_from = end_pos; | |
| 239 | - | } | |
| 240 | - | ||
| 241 | - | blocks | |
| 242 | - | } | |
| 243 | - | ||
| 244 | - | /// Build tabbed HTML from extracted code blocks. | |
| 245 | - | fn build_tabs_html(tabs: &[(String, String)]) -> String { | |
| 246 | - | let mut html = String::from("<div class=\"code-tabs\">\n<div class=\"code-tabs-bar\">"); | |
| 247 | - | ||
| 248 | - | for (i, (lang, _)) in tabs.iter().enumerate() { | |
| 249 | - | let active = if i == 0 { " active" } else { "" }; | |
| 250 | - | let label = code_language_label(lang); | |
| 251 | - | html.push_str(&format!( | |
| 252 | - | "<button class=\"code-tab{active}\" data-tab-index=\"{i}\">{label}</button>" | |
| 253 | - | )); | |
| 254 | - | } | |
| 255 | - | ||
| 256 | - | html.push_str("</div>\n"); | |
| 257 | - | ||
| 258 | - | for (i, (_, block)) in tabs.iter().enumerate() { | |
| 259 | - | let active = if i == 0 { " active" } else { "" }; | |
| 260 | - | html.push_str(&format!( | |
| 261 | - | "<div class=\"code-tab-panel{active}\" data-tab-index=\"{i}\">{block}</div>\n" | |
| 262 | - | )); | |
| 263 | - | } | |
| 264 | - | ||
| 265 | - | html.push_str("</div>"); | |
| 266 | - | html | |
| 267 | - | } | |
| 268 | - | ||
| 269 | - | /// Human-readable label for a code language identifier. | |
| 270 | - | fn code_language_label(lang: &str) -> String { | |
| 271 | - | match lang { | |
| 272 | - | "js" | "javascript" => "JavaScript".into(), | |
| 273 | - | "ts" | "typescript" => "TypeScript".into(), | |
| 274 | - | "sh" | "bash" | "zsh" | "shell" => "Shell".into(), | |
| 275 | - | "json" => "JSON".into(), | |
| 276 | - | "html" => "HTML".into(), | |
| 277 | - | "css" => "CSS".into(), | |
| 278 | - | "sql" => "SQL".into(), | |
| 279 | - | "toml" => "TOML".into(), | |
| 280 | - | "yaml" | "yml" => "YAML".into(), | |
| 281 | - | "xml" => "XML".into(), | |
| 282 | - | other => title_case(other), | |
| 283 | - | } | |
| 284 | - | } | |
| 285 | - | ||
| 286 | - | fn title_case(s: &str) -> String { | |
| 287 | - | let mut chars = s.chars(); | |
| 288 | - | match chars.next() { | |
| 289 | - | Some(c) => { | |
| 290 | - | let mut out = c.to_uppercase().to_string(); | |
| 291 | - | out.extend(chars.map(|c| c.to_ascii_lowercase())); | |
| 292 | - | out | |
| 293 | - | } | |
| 294 | - | None => String::new(), | |
| 295 | - | } | |
| 296 | - | } | |
| 297 | - | ||
| 298 | - | #[cfg(test)] | |
| 299 | - | mod tests { | |
| 300 | - | use super::*; | |
| 301 | - | ||
| 302 | - | // --- alert directives | |
| 303 | - | ||
| 304 | - | #[test] | |
| 305 | - | fn note_alert() { | |
| 306 | - | let html = "<blockquote>\n<p>[!NOTE]<br>\nThis is a note.</p>\n</blockquote>"; | |
| 307 | - | let result = post_process_directives(html); | |
| 308 | - | assert!(result.contains("alert alert-note")); | |
| 309 | - | assert!(result.contains("<p class=\"alert-title\">Note</p>")); | |
| 310 | - | assert!(result.contains("This is a note.")); | |
| 311 | - | assert!(!result.contains("<blockquote>")); | |
| 312 | - | } | |
| 313 | - | ||
| 314 | - | #[test] | |
| 315 | - | fn tip_alert() { | |
| 316 | - | let html = "<blockquote>\n<p>[!TIP]<br>\nHelpful tip here.</p>\n</blockquote>"; | |
| 317 | - | let result = post_process_directives(html); | |
| 318 | - | assert!(result.contains("alert alert-tip")); | |
| 319 | - | assert!(result.contains("<p class=\"alert-title\">Tip</p>")); | |
| 320 | - | } | |
| 321 | - | ||
| 322 | - | #[test] | |
| 323 | - | fn important_alert() { | |
| 324 | - | let html = "<blockquote>\n<p>[!IMPORTANT]<br>\nDo this.</p>\n</blockquote>"; | |
| 325 | - | let result = post_process_directives(html); | |
| 326 | - | assert!(result.contains("alert alert-important")); | |
| 327 | - | assert!(result.contains("<p class=\"alert-title\">Important</p>")); | |
| 328 | - | } | |
| 329 | - | ||
| 330 | - | #[test] | |
| 331 | - | fn warning_alert() { | |
| 332 | - | let html = "<blockquote>\n<p>[!WARNING]<br>\nBe careful.</p>\n</blockquote>"; | |
| 333 | - | let result = post_process_directives(html); | |
| 334 | - | assert!(result.contains("alert alert-warning")); | |
| 335 | - | assert!(result.contains("<p class=\"alert-title\">Warning</p>")); | |
| 336 | - | } | |
| 337 | - | ||
| 338 | - | #[test] | |
| 339 | - | fn caution_alert() { | |
| 340 | - | let html = "<blockquote>\n<p>[!CAUTION]<br/>\nDanger zone.</p>\n</blockquote>"; | |
| 341 | - | let result = post_process_directives(html); | |
| 342 | - | assert!(result.contains("alert alert-caution")); | |
| 343 | - | assert!(result.contains("<p class=\"alert-title\">Caution</p>")); | |
| 344 | - | } | |
| 345 | - | ||
| 346 | - | #[test] | |
| 347 | - | fn multi_paragraph_alert() { | |
| 348 | - | let html = "<blockquote>\n<p>[!NOTE]<br>\nFirst paragraph.</p>\n<p>Second paragraph.</p>\n</blockquote>"; | |
| 349 | - | let result = post_process_directives(html); | |
| 350 | - | assert!(result.contains("alert alert-note")); | |
| 351 | - | assert!(result.contains("First paragraph.")); | |
| 352 | - | assert!(result.contains("Second paragraph.")); | |
| 353 | - | assert!(result.contains("</div>")); | |
| 354 | - | assert!(!result.contains("</blockquote>")); | |
| 355 | - | } | |
| 356 | - | ||
| 357 | - | #[test] | |
| 358 | - | fn regular_blockquote_unchanged() { | |
| 359 | - | let html = "<blockquote>\n<p>Just a normal quote.</p>\n</blockquote>"; | |
| 360 | - | let result = post_process_directives(html); | |
| 361 | - | assert_eq!(result, html); | |
| 362 | - | } | |
| 363 | - | ||
| 364 | - | #[test] | |
| 365 | - | fn mixed_alerts_and_blockquotes() { | |
| 366 | - | let html = concat!( | |
| 367 | - | "<blockquote>\n<p>[!WARNING]<br>\nWatch out!</p>\n</blockquote>\n", | |
| 368 | - | "<blockquote>\n<p>Normal quote.</p>\n</blockquote>" | |
| 369 | - | ); | |
| 370 | - | let result = post_process_directives(html); | |
| 371 | - | assert!(result.contains("alert alert-warning")); | |
| 372 | - | assert!(result.contains("Watch out!")); | |
| 373 | - | // The normal blockquote remains unchanged. | |
| 374 | - | assert!(result.contains("<blockquote>")); | |
| 375 | - | assert!(result.contains("Normal quote.")); | |
| 376 | - | } | |
| 377 | - | ||
| 378 | - | // --- custom alert types | |
| 379 | - | ||
| 380 | - | #[test] | |
| 381 | - | fn custom_example_alert() { | |
| 382 | - | let html = "<blockquote>\n<p>[!EXAMPLE]<br>\nHere is an example.</p>\n</blockquote>"; | |
| 383 | - | let result = post_process_directives(html); | |
| 384 | - | assert!(result.contains("alert alert-example")); | |
| 385 | - | assert!(result.contains("<p class=\"alert-title\">Example</p>")); | |
| 386 | - | assert!(result.contains("Here is an example.")); | |
| 387 | - | assert!(!result.contains("<blockquote>")); | |
| 388 | - | } | |
| 389 | - | ||
| 390 | - | #[test] | |
| 391 | - | fn custom_definition_alert() { | |
| 392 | - | let html = "<blockquote>\n<p>[!DEFINITION]<br>\nA term and its meaning.</p>\n</blockquote>"; | |
| 393 | - | let result = post_process_directives(html); | |
| 394 | - | assert!(result.contains("alert alert-definition")); | |
| 395 | - | assert!(result.contains("<p class=\"alert-title\">Definition</p>")); | |
| 396 | - | } | |
| 397 | - | ||
| 398 | - | #[test] | |
| 399 | - | fn custom_alert_with_hyphen() { | |
| 400 | - | let html = "<blockquote>\n<p>[!SEE-ALSO]<br>\nRelated topics.</p>\n</blockquote>"; | |
| 401 | - | let result = post_process_directives(html); | |
| 402 | - | assert!(result.contains("alert alert-see-also")); | |
| 403 | - | assert!(result.contains("<p class=\"alert-title\">See-also</p>")); | |
| 404 | - | } | |
| 405 | - | ||
| 406 | - | // --- code tabs | |
| 407 | - | ||
| 408 | - | #[test] | |
| 409 | - | fn tabs_two_languages() { | |
| 410 | - | let html = concat!( | |
| 411 | - | "<blockquote>\n<p>[!TABS]</p>\n", | |
| 412 | - | "<pre><code class=\"language-rust\">fn main() {}\n</code></pre>\n", | |
| 413 | - | "<pre><code class=\"language-python\">def main(): pass\n</code></pre>\n", | |
| 414 | - | "</blockquote>" | |
| 415 | - | ); | |
| 416 | - | let result = post_process_directives(html); | |
| 417 | - | assert!(result.contains("code-tabs")); | |
| 418 | - | assert!(result.contains("code-tabs-bar")); | |
| 419 | - | assert!(result.contains("Rust")); | |
| 420 | - | assert!(result.contains("Python")); | |
| 421 | - | assert!(result.contains("fn main() {}")); | |
| 422 | - | assert!(result.contains("def main(): pass")); | |
| 423 | - | assert!(!result.contains("<blockquote>")); | |
| 424 | - | // First tab is active. | |
| 425 | - | assert!(result.contains("code-tab active")); | |
| 426 | - | assert!(result.contains("code-tab-panel active")); | |
| 427 | - | } | |
| 428 | - | ||
| 429 | - | #[test] | |
| 430 | - | fn tabs_three_languages() { | |
| 431 | - | let html = concat!( | |
| 432 | - | "<blockquote>\n<p>[!TABS]</p>\n", | |
| 433 | - | "<pre><code class=\"language-bash\">curl https://api.example.com\n</code></pre>\n", | |
| 434 | - | "<pre><code class=\"language-js\">fetch('https://api.example.com')\n</code></pre>\n", | |
| 435 | - | "<pre><code class=\"language-python\">requests.get('https://api.example.com')\n</code></pre>\n", | |
| 436 | - | "</blockquote>" | |
| 437 | - | ); | |
| 438 | - | let result = post_process_directives(html); | |
| 439 | - | assert!(result.contains("Shell")); // bash → Shell | |
| 440 | - | assert!(result.contains("JavaScript")); // js → JavaScript | |
| 441 | - | assert!(result.contains("Python")); | |
| 442 | - | assert!(result.contains("data-tab-index=\"0\"")); | |
| 443 | - | assert!(result.contains("data-tab-index=\"1\"")); | |
| 444 | - | assert!(result.contains("data-tab-index=\"2\"")); | |
| 445 | - | } | |
| 446 | - | ||
| 447 | - | #[test] | |
| 448 | - | fn tabs_no_language_specified() { | |
| 449 | - | let html = concat!( | |
| 450 | - | "<blockquote>\n<p>[!TABS]</p>\n", | |
| 451 | - | "<pre><code>some code\n</code></pre>\n", | |
| 452 | - | "<pre><code class=\"language-rust\">let x = 1;\n</code></pre>\n", | |
| 453 | - | "</blockquote>" | |
| 454 | - | ); | |
| 455 | - | let result = post_process_directives(html); | |
| 456 | - | assert!(result.contains("Code")); // fallback label | |
| 457 | - | assert!(result.contains("Rust")); | |
| 458 | - | } | |
| 459 | - | ||
| 460 | - | #[test] | |
| 461 | - | fn tabs_with_br_marker() { | |
| 462 | - | let html = concat!( | |
| 463 | - | "<blockquote>\n<p>[!TABS]<br>\n</p>\n", | |
| 464 | - | "<pre><code class=\"language-toml\">[package]\n</code></pre>\n", | |
| 465 | - | "<pre><code class=\"language-json\">{}\n</code></pre>\n", | |
| 466 | - | "</blockquote>" | |
| 467 | - | ); | |
| 468 | - | let result = post_process_directives(html); | |
| 469 | - | assert!(result.contains("TOML")); | |
| 470 | - | assert!(result.contains("JSON")); | |
| 471 | - | } | |
| 472 | - | ||
| 473 | - | #[test] | |
| 474 | - | fn tabs_mixed_with_alert_and_blockquote() { | |
| 475 | - | let html = concat!( | |
| 476 | - | "<blockquote>\n<p>[!NOTE]<br>\nA note.</p>\n</blockquote>\n", | |
| 477 | - | "<blockquote>\n<p>[!TABS]</p>\n", | |
| 478 | - | "<pre><code class=\"language-rust\">let x = 1;\n</code></pre>\n", | |
| 479 | - | "</blockquote>\n", | |
| 480 | - | "<blockquote>\n<p>Normal quote.</p>\n</blockquote>" | |
| 481 | - | ); | |
| 482 | - | let result = post_process_directives(html); | |
| 483 | - | // Alert processed. | |
| 484 | - | assert!(result.contains("alert alert-note")); | |
| 485 | - | // Tabs processed. | |
| 486 | - | assert!(result.contains("code-tabs")); | |
| 487 | - | assert!(result.contains("Rust")); | |
| 488 | - | // Normal blockquote unchanged. | |
| 489 | - | assert!(result.contains("<blockquote>")); | |
| 490 | - | assert!(result.contains("Normal quote.")); | |
| 491 | - | } | |
| 492 | - | ||
| 493 | - | #[test] | |
| 494 | - | fn tabs_no_code_blocks() { | |
| 495 | - | let html = concat!( | |
| 496 | - | "<blockquote>\n<p>[!TABS]</p>\n", | |
| 497 | - | "<p>Just text, no code.</p>\n", | |
| 498 | - | "</blockquote>" | |
| 499 | - | ); | |
| 500 | - | let result = post_process_directives(html); |
Lines truncated
| @@ -1,1124 +1,0 @@ | |||
| 1 | - | //! Loading a directory of markdown into rendered, in-memory documentation | |
| 2 | - | //! pages. | |
| 3 | - | //! | |
| 4 | - | //! # Sanitization ordering contract | |
| 5 | - | //! | |
| 6 | - | //! The page pipeline is: | |
| 7 | - | //! | |
| 8 | - | //! 1. `pre_process` (caller-supplied, e.g. assumption substitution) | |
| 9 | - | //! 2. `rewrite_links` | |
| 10 | - | //! 3. **`render_permissive` — the only ammonia pass** | |
| 11 | - | //! 4. `post_process_directives` (feature `directives`) | |
| 12 | - | //! 5. `resolve_ui_examples` | |
| 13 | - | //! | |
| 14 | - | //! Steps 4 and 5 run *after* sanitization and their output is never re-cleaned. | |
| 15 | - | //! Both therefore inject trusted-by-construction HTML into already-sanitized | |
| 16 | - | //! markup, and `resolve_ui_examples` in particular reads files from | |
| 17 | - | //! `examples_path` and inlines their contents **verbatim**. | |
| 18 | - | //! | |
| 19 | - | //! What that requires of callers: | |
| 20 | - | //! | |
| 21 | - | //! - `base_path` and `examples_path` must be operator-controlled directories | |
| 22 | - | //! shipped with the deployment. They are not a place to put user uploads; a | |
| 23 | - | //! writable `examples_path` is a stored-XSS primitive, since anything in an | |
| 24 | - | //! example file lands in the page unfiltered. | |
| 25 | - | //! - Anything added to steps 4-5 must emit only HTML it constructs itself, or | |
| 26 | - | //! sanitize its own input. Do not widen them to interpolate page content. | |
| 27 | - | //! | |
| 28 | - | //! The ordering is deliberate, not incidental: directives and UI examples exist | |
| 29 | - | //! precisely to emit markup ammonia's default policy would strip, so moving | |
| 30 | - | //! them before step 3 would defeat them. The safety comes from the inputs being | |
| 31 | - | //! trusted, which is why that constraint is written down here. | |
| 32 | - | ||
| 33 | - | use std::collections::HashMap; | |
| 34 | - | use std::path::Path; | |
| 35 | - | use std::sync::LazyLock; | |
| 36 | - | ||
| 37 | - | use regex_lite::Regex; | |
| 38 | - | ||
| 39 | - | static LINK_RE: LazyLock<Regex> = | |
| 40 | - | LazyLock::new(|| Regex::new(r"\[([^\]]+)\]\(([^)]+)\)").expect("valid regex")); | |
| 41 | - | ||
| 42 | - | /// Transform applied to raw markdown before link rewriting. `Err` skips the | |
| 43 | - | /// page with a warning. | |
| 44 | - | pub type PreProcessor = Box<dyn Fn(&str) -> Result<String, String> + Send + Sync>; | |
| 45 | - | ||
| 46 | - | /// Configuration for the doc loader. | |
| 47 | - | pub struct DocLoaderConfig { | |
| 48 | - | /// Sections as `(directory_name, display_name)` pairs in display order. | |
| 49 | - | pub sections: Vec<(String, String)>, | |
| 50 | - | /// URL prefix for rewritten links (e.g., "/docs"). | |
| 51 | - | pub link_prefix: String, | |
| 52 | - | /// Pattern that identifies unpublished links to strip (e.g., "unpublished/"). | |
| 53 | - | pub unpublished_pattern: Option<String>, | |
| 54 | - | /// Path to directory containing UI example `.html` fragments. | |
| 55 | - | /// If set, `[!UI] name` directives are resolved by loading `{examples_path}/{name}.html`. | |
| 56 | - | pub examples_path: Option<std::path::PathBuf>, | |
| 57 | - | /// Optional pre-processor applied to raw markdown before link rewriting. | |
| 58 | - | /// On `Err`, the page is skipped with a warning. Use to wire | |
| 59 | - | /// [`crate::Assumptions::substitute`] or a similar transform. | |
| 60 | - | pub pre_process: Option<PreProcessor>, | |
| 61 | - | } | |
| 62 | - | ||
| 63 | - | /// A rendered documentation page. | |
| 64 | - | #[derive(Clone, Debug)] | |
| 65 | - | pub struct DocPage { | |
| 66 | - | pub title: String, | |
| 67 | - | pub slug: String, | |
| 68 | - | pub section: String, | |
| 69 | - | pub html_content: String, | |
| 70 | - | } | |
| 71 | - | ||
| 72 | - | /// Ordered entry for the docs index page. | |
| 73 | - | #[derive(Clone, Debug)] | |
| 74 | - | pub struct DocIndexEntry { | |
| 75 | - | pub title: String, | |
| 76 | - | pub slug: String, | |
| 77 | - | pub section: String, | |
| 78 | - | } | |
| 79 | - | ||
| 80 | - | /// Entry in the full-text search index, serialised to JSON for client-side search. | |
| 81 | - | #[derive(Clone, Debug, serde::Serialize)] | |
| 82 | - | pub struct DocSearchEntry { | |
| 83 | - | pub slug: String, | |
| 84 | - | pub title: String, | |
| 85 | - | pub section: String, | |
| 86 | - | pub body_text: String, | |
| 87 | - | } | |
| 88 | - | ||
| 89 | - | /// Two documentation files that slugify to the same URL. | |
| 90 | - | /// | |
| 91 | - | /// Slugs are `file_stem()` keyed into one flat map with no regard for section, | |
| 92 | - | /// so `guide/faq.md` and `reference/faq.md` both want `/docs/faq`. One of them | |
| 93 | - | /// wins and the other is unreachable. | |
| 94 | - | #[derive(Clone, Debug, PartialEq, Eq)] | |
| 95 | - | pub struct SlugCollision { | |
| 96 | - | /// The contested slug. | |
| 97 | - | pub slug: String, | |
| 98 | - | /// Section display name of the page that was displaced. | |
| 99 | - | pub displaced_section: String, | |
| 100 | - | /// Section display name of the page now serving this slug. | |
| 101 | - | pub winning_section: String, | |
| 102 | - | } | |
| 103 | - | ||
| 104 | - | /// An internal doc link whose target slug matches no loaded page. | |
| 105 | - | /// | |
| 106 | - | /// `[text](missing.md)` rewrites to `{link_prefix}/missing` and serves a live | |
| 107 | - | /// link to a 404 with nothing reporting it. A broken link is an edge in the | |
| 108 | - | /// link graph whose target is absent from the page store — the same notion of | |
| 109 | - | /// "present" the router uses ([`DocLoader::get`]), so detection is faithful to | |
| 110 | - | /// what is actually served. | |
| 111 | - | #[derive(Clone, Debug, PartialEq, Eq)] | |
| 112 | - | pub struct BrokenLink { | |
| 113 | - | /// Slug of the page containing the link. | |
| 114 | - | pub source_slug: String, | |
| 115 | - | /// Target slug the link resolves to, which no page serves. | |
| 116 | - | pub target_slug: String, | |
| 117 | - | } | |
| 118 | - | ||
| 119 | - | /// In-memory store of rendered documentation pages, built once at startup. | |
| 120 | - | #[derive(Clone, Debug)] | |
| 121 | - | pub struct DocLoader { | |
| 122 | - | pages: HashMap<String, DocPage>, | |
| 123 | - | index: Vec<DocIndexEntry>, | |
| 124 | - | collisions: Vec<SlugCollision>, | |
| 125 | - | /// Adjacency list of the internal-link graph: source slug -> the target | |
| 126 | - | /// slugs it links to, in first-seen order, deduplicated per source. Only | |
| 127 | - | /// internal cross-doc `.md` links appear; external, `mailto:`, absolute, | |
| 128 | - | /// and stripped-unpublished links are excluded, exactly as [`rewrite_links`] | |
| 129 | - | /// classifies them. | |
| 130 | - | links: HashMap<String, Vec<String>>, | |
| 131 | - | /// Reverse of [`links`](Self::links): target slug -> the source slugs that | |
| 132 | - | /// link to it ("what links here"), each source once, in docs-index order. | |
| 133 | - | /// A key may be a slug no page serves (a broken target still has backlinks). | |
| 134 | - | backlinks: HashMap<String, Vec<String>>, | |
| 135 | - | broken: Vec<BrokenLink>, | |
| 136 | - | } | |
| 137 | - | ||
| 138 | - | impl DocLoader { | |
| 139 | - | /// Load all `.md` files from `base_path`, rendering them into HTML. | |
| 140 | - | /// | |
| 141 | - | /// Expects subdirectories matching the configured sections. | |
| 142 | - | pub fn load(base_path: &Path, config: &DocLoaderConfig) -> Self { | |
| 143 | - | let mut pages = HashMap::new(); | |
| 144 | - | let mut index = Vec::new(); | |
| 145 | - | let mut collisions = Vec::new(); | |
| 146 | - | let mut links: HashMap<String, Vec<String>> = HashMap::new(); | |
| 147 | - | ||
| 148 | - | for (dir_name, section_display) in &config.sections { | |
| 149 | - | let section_path = base_path.join(dir_name); | |
| 150 | - | if !section_path.is_dir() { | |
| 151 | - | continue; | |
| 152 | - | } | |
| 153 | - | ||
| 154 | - | let read_dir = match std::fs::read_dir(§ion_path) { | |
| 155 | - | Ok(rd) => rd, | |
| 156 | - | Err(e) => { | |
| 157 | - | tracing::warn!(path = %section_path.display(), error = %e, "Failed to read docs section directory"); | |
| 158 | - | continue; | |
| 159 | - | } | |
| 160 | - | }; | |
| 161 | - | ||
| 162 | - | let mut entries: Vec<_> = read_dir | |
| 163 | - | .filter_map(|e| e.ok()) | |
| 164 | - | .filter(|e| e.path().extension().map(|ext| ext == "md").unwrap_or(false)) | |
| 165 | - | .collect(); | |
| 166 | - | ||
| 167 | - | entries.sort_by_key(|e| e.file_name()); | |
| 168 | - | ||
| 169 | - | for entry in entries { | |
| 170 | - | let path = entry.path(); | |
| 171 | - | let slug = path | |
| 172 | - | .file_stem() | |
| 173 | - | .and_then(|s| s.to_str()) | |
| 174 | - | .unwrap_or_default() | |
| 175 | - | .to_string(); | |
| 176 | - | ||
| 177 | - | let raw_md = match std::fs::read_to_string(&path) { | |
| 178 | - | Ok(content) => content, | |
| 179 | - | Err(_) => continue, | |
| 180 | - | }; | |
| 181 | - | ||
| 182 | - | let raw_md = match &config.pre_process { | |
| 183 | - | Some(pp) => match pp(&raw_md) { | |
| 184 | - | Ok(md) => md, | |
| 185 | - | Err(e) => { | |
| 186 | - | tracing::warn!( | |
| 187 | - | path = %path.display(), | |
| 188 | - | error = %e, | |
| 189 | - | "pre_process failed; skipping page" | |
| 190 | - | ); | |
| 191 | - | continue; | |
| 192 | - | } | |
| 193 | - | }, | |
| 194 | - | None => raw_md, | |
| 195 | - | }; | |
| 196 | - | ||
| 197 | - | let title = crate::text::extract_title(&raw_md).unwrap_or_else(|| slug.clone()); | |
| 198 | - | // Collect the internal-link graph from the same pre-processed | |
| 199 | - | // markdown `rewrite_links` sees, so graph edges are exactly the | |
| 200 | - | // links actually served. | |
| 201 | - | let link_targets = | |
| 202 | - | collect_link_targets(&raw_md, config.unpublished_pattern.as_deref()); | |
| 203 | - | let rewritten_md = rewrite_links( | |
| 204 | - | &raw_md, | |
| 205 | - | &config.link_prefix, | |
| 206 | - | config.unpublished_pattern.as_deref(), | |
| 207 | - | ); | |
| 208 | - | let md_without_title = crate::text::strip_first_heading(&rewritten_md); | |
| 209 | - | // Heading ids on: docs are operator-authored (trusted), and | |
| 210 | - | // without them `extract_toc`'s anchors point at nothing and no | |
| 211 | - | // one can deep-link a section. | |
| 212 | - | let html_content = crate::Renderer::permissive() | |
| 213 | - | .with_heading_ids(true) | |
| 214 | - | .render(&md_without_title); | |
| 215 | - | #[cfg(feature = "directives")] | |
| 216 | - | let html_content = crate::directives::post_process_directives(&html_content); | |
| 217 | - | let html_content = | |
| 218 | - | resolve_ui_examples(&html_content, config.examples_path.as_deref()); | |
| 219 | - | ||
| 220 | - | let page = DocPage { | |
| 221 | - | title, | |
| 222 | - | slug, | |
| 223 | - | section: section_display.clone(), | |
| 224 | - | html_content, | |
| 225 | - | }; | |
| 226 | - | ||
| 227 | - | index.push(DocIndexEntry { | |
| 228 | - | title: page.title.clone(), | |
| 229 | - | slug: page.slug.clone(), | |
| 230 | - | section: page.section.clone(), | |
| 231 | - | }); | |
| 232 | - | ||
| 233 | - | // Key the graph by the served slug, last-writer-wins in step | |
| 234 | - | // with the page store above, so the graph describes the page a | |
| 235 | - | // caller actually reaches under this slug. | |
| 236 | - | links.insert(page.slug.clone(), link_targets); | |
| 237 | - | ||
| 238 | - | let slug_key = page.slug.clone(); | |
| 239 | - | if let Some(displaced) = pages.insert(slug_key, page) { | |
| 240 | - | // Last writer wins, as it always has. Surfacing the clash | |
| 241 | - | // is the fix; picking a different winner would be a routing | |
| 242 | - | // decision, and silently serving one of two pages under a | |
| 243 | - | // URL is what made this invisible in the first place. | |
| 244 | - | let winning_section = pages[&displaced.slug].section.clone(); | |
| 245 | - | tracing::warn!( | |
| 246 | - | slug = %displaced.slug, | |
| 247 | - | displaced_section = %displaced.section, | |
| 248 | - | winning_section = %winning_section, | |
| 249 | - | "docs slug collision: two pages resolve to the same URL, \ | |
| 250 | - | only the last is reachable" | |
| 251 | - | ); | |
| 252 | - | collisions.push(SlugCollision { | |
| 253 | - | slug: displaced.slug.clone(), | |
| 254 | - | displaced_section: displaced.section.clone(), | |
| 255 | - | winning_section, | |
| 256 | - | }); | |
| 257 | - | } | |
| 258 | - | } | |
| 259 | - | } | |
| 260 | - | ||
| 261 | - | // Resolve broken links once the full page set is known: a forward link | |
| 262 | - | // to a page loaded later in the corpus is not broken. Walk in index | |
| 263 | - | // order (not the nondeterministic map order) so the report is stable. | |
| 264 | - | let mut broken = Vec::new(); | |
| 265 | - | let mut backlinks: HashMap<String, Vec<String>> = HashMap::new(); | |
| 266 | - | let mut seen = std::collections::HashSet::new(); | |
| 267 | - | for entry in &index { | |
| 268 | - | // A slug collision indexes two pages under one slug; the graph holds | |
| 269 | - | // only the winner's edges. Process each slug once so the loser's | |
| 270 | - | // duplicate index entry doesn't double-report or double-link. | |
| 271 | - | if !seen.insert(entry.slug.as_str()) { | |
| 272 | - | continue; | |
| 273 | - | } | |
| 274 | - | let Some(targets) = links.get(&entry.slug) else { | |
| 275 | - | continue; | |
| 276 | - | }; | |
| 277 | - | for target in targets { | |
| 278 | - | // Reverse edge: index-ordered, one source per target (targets are | |
| 279 | - | // already deduped per source, so no duplicate source appears). | |
| 280 | - | backlinks | |
| 281 | - | .entry(target.clone()) | |
| 282 | - | .or_default() | |
| 283 | - | .push(entry.slug.clone()); | |
| 284 | - | if !pages.contains_key(target) { | |
| 285 | - | tracing::warn!( | |
| 286 | - | source = %entry.slug, | |
| 287 | - | target = %target, | |
| 288 | - | "docs broken link: internal link resolves to a slug no page serves" | |
| 289 | - | ); | |
| 290 | - | broken.push(BrokenLink { | |
| 291 | - | source_slug: entry.slug.clone(), | |
| 292 | - | target_slug: target.clone(), | |
| 293 | - | }); | |
| 294 | - | } | |
| 295 | - | } | |
| 296 | - | } | |
| 297 | - | ||
| 298 | - | DocLoader { | |
| 299 | - | pages, | |
| 300 | - | index, | |
| 301 | - | collisions, | |
| 302 | - | links, | |
| 303 | - | backlinks, | |
| 304 | - | broken, | |
| 305 | - | } | |
| 306 | - | } | |
| 307 | - | ||
| 308 | - | /// Look up a rendered page by slug. | |
| 309 | - | pub fn get(&self, slug: &str) -> Option<&DocPage> { | |
| 310 | - | self.pages.get(slug) | |
| 311 | - | } | |
| 312 | - | ||
| 313 | - | /// Get the full ordered index. | |
| 314 | - | pub fn index(&self) -> &[DocIndexEntry] { | |
| 315 | - | &self.index | |
| 316 | - | } | |
| 317 | - | ||
| 318 | - | /// Slug collisions found during load, in the order they were hit. | |
| 319 | - | /// | |
| 320 | - | /// Empty for a healthy corpus. A non-empty result means some page is | |
| 321 | - | /// indexed but unreachable, so a caller that can fail loudly (a build step, | |
| 322 | - | /// a startup check) should. Each collision is also logged at `warn`. | |
| 323 | - | pub fn collisions(&self) -> &[SlugCollision] { | |
| 324 | - | &self.collisions | |
| 325 | - | } | |
| 326 | - | ||
| 327 | - | /// Target slugs the page `slug` links to, in first-seen order. | |
| 328 | - | /// | |
| 329 | - | /// Only internal cross-doc `.md` links are recorded; a target here need not | |
| 330 | - | /// resolve to a live page (see [`broken_links`](Self::broken_links)). Empty | |
| 331 | - | /// for a page with no internal links, or for an unknown slug. | |
| 332 | - | pub fn links(&self, slug: &str) -> &[String] { | |
| 333 | - | self.links.get(slug).map_or(&[], Vec::as_slice) | |
| 334 | - | } | |
| 335 | - | ||
| 336 | - | /// Source slugs that link to the page `slug` ("what links here"), in | |
| 337 | - | /// docs-index order, each source once. | |
| 338 | - | /// | |
| 339 | - | /// Reads straight off the reverse link graph — the wiki-style backlinks a | |
| 340 | - | /// page template can render. Empty for a page nothing links to, or an | |
| 341 | - | /// unknown slug. | |
| 342 | - | pub fn backlinks(&self, slug: &str) -> &[String] { | |
| 343 | - | self.backlinks.get(slug).map_or(&[], Vec::as_slice) | |
| 344 | - | } | |
| 345 | - | ||
| 346 | - | /// Internal links whose target slug matches no loaded page, in docs-index | |
| 347 | - | /// order. | |
| 348 | - | /// | |
| 349 | - | /// Empty for a healthy corpus. A non-empty result means a page serves a link | |
| 350 | - | /// to a 404; each is also logged at `warn`. As with | |
| 351 | - | /// [`collisions`](Self::collisions), whether that warns or fails the boot is | |
| 352 | - | /// the caller's call — a build step or startup check can fail loudly on a | |
| 353 | - | /// non-empty slice. | |
| 354 | - | pub fn broken_links(&self) -> &[BrokenLink] { | |
| 355 | - | &self.broken | |
| 356 | - | } | |
| 357 | - | ||
| 358 | - | /// Build a search index with HTML stripped to plain text. | |
| 359 | - | pub fn search_index(&self) -> Vec<DocSearchEntry> { | |
| 360 | - | self.index | |
| 361 | - | .iter() | |
| 362 | - | .filter_map(|entry| { | |
| 363 | - | let page = self.pages.get(&entry.slug)?; | |
| 364 | - | Some(DocSearchEntry { | |
| 365 | - | slug: entry.slug.clone(), | |
| 366 | - | title: entry.title.clone(), | |
| 367 | - | section: entry.section.clone(), | |
| 368 | - | body_text: strip_html_tags(&page.html_content), | |
| 369 | - | }) | |
| 370 | - | }) | |
| 371 | - | .collect() | |
| 372 | - | } | |
| 373 | - | } | |
| 374 | - | ||
| 375 | - | /// Replace `<div class="doc-ui-frame" data-ui="name"></div>` placeholders with | |
| 376 | - | /// the contents of `{examples_path}/{name}.html`. | |
| 377 | - | /// | |
| 378 | - | /// If no examples path is configured or a file is missing, the placeholder is | |
| 379 | - | /// replaced with a fallback message. | |
| 380 | - | /// | |
| 381 | - | /// # Trust | |
| 382 | - | /// | |
| 383 | - | /// File contents are inlined **verbatim** into already-sanitized HTML and are | |
| 384 | - | /// never cleaned — see the sanitization ordering contract in the module docs. | |
| 385 | - | /// `examples_path` must be an operator-controlled directory. The `data-ui` | |
| 386 | - | /// capture is restricted to `[a-z0-9_-]+` by the placeholder pattern, so a | |
| 387 | - | /// name cannot traverse out of that directory. | |
| 388 | - | fn resolve_ui_examples(html: &str, examples_path: Option<&Path>) -> String { | |
| 389 | - | static UI_PLACEHOLDER: LazyLock<Regex> = LazyLock::new(|| { | |
| 390 | - | Regex::new(r#"<div class="doc-ui-frame" data-ui="([a-z0-9_-]+)"></div>"#) | |
| 391 | - | .expect("valid UI placeholder regex") | |
| 392 | - | }); | |
| 393 | - | ||
| 394 | - | if !html.contains("doc-ui-frame") { | |
| 395 | - | return html.to_string(); | |
| 396 | - | } | |
| 397 | - | ||
| 398 | - | UI_PLACEHOLDER.replace_all(html, |caps: ®ex_lite::Captures| { | |
| 399 | - | let name = &caps[1]; | |
| 400 | - | match examples_path { | |
| 401 | - | Some(dir) => { | |
| 402 | - | let file = dir.join(format!("{name}.html")); | |
| 403 | - | match std::fs::read_to_string(&file) { | |
| 404 | - | Ok(content) => format!( | |
| 405 | - | "<div class=\"doc-ui-frame\">{content}</div>" | |
| 406 | - | ), | |
| 407 | - | Err(_) => { | |
| 408 | - | tracing::warn!(example = name, "UI example file not found"); | |
| 409 | - | format!( | |
| 410 | - | "<div class=\"doc-ui-frame doc-ui-missing\">[UI example: {name}]</div>" | |
| 411 | - | ) | |
| 412 | - | } | |
| 413 | - | } | |
| 414 | - | } | |
| 415 | - | None => format!( | |
| 416 | - | "<div class=\"doc-ui-frame doc-ui-missing\">[UI example: {name}]</div>" | |
| 417 | - | ), | |
| 418 | - | } | |
| 419 | - | }).into_owned() | |
| 420 | - | } | |
| 421 | - | ||
| 422 | - | /// Strip HTML tags from a string, returning plain text. | |
| 423 | - | /// Decodes common HTML entities so search indexes match plain-text queries. | |
| 424 | - | fn strip_html_tags(html: &str) -> String { | |
| 425 | - | let mut out = String::with_capacity(html.len()); | |
| 426 | - | let mut in_tag = false; | |
| 427 | - | for ch in html.chars() { | |
| 428 | - | match ch { | |
| 429 | - | '<' => in_tag = true, | |
| 430 | - | '>' => { | |
| 431 | - | in_tag = false; | |
| 432 | - | // Add a space after closing tags to separate words. | |
| 433 | - | if !out.ends_with(' ') { | |
| 434 | - | out.push(' '); | |
| 435 | - | } | |
| 436 | - | } | |
| 437 | - | _ if !in_tag => out.push(ch), | |
| 438 | - | _ => {} | |
| 439 | - | } | |
| 440 | - | } | |
| 441 | - | // Collapse runs of whitespace. | |
| 442 | - | let collapsed: String = out.split_whitespace().collect::<Vec<_>>().join(" "); | |
| 443 | - | // Decode common HTML entities for search index accuracy. | |
| 444 | - | collapsed | |
| 445 | - | .replace("&", "&") | |
| 446 | - | .replace("<", "<") | |
| 447 | - | .replace(">", ">") | |
| 448 | - | .replace(""", "\"") | |
| 449 | - | .replace("'", "'") | |
| 450 | - | .replace("'", "'") | |
| 451 | - | } | |
| 452 | - | ||
| 453 | - | /// How a markdown link URL is treated by the doc pipeline. Shared by | |
| 454 | - | /// [`rewrite_links`] (which rewrites) and [`collect_link_targets`] (which graphs | |
| 455 | - | /// the edges) so the two share one notion of what an internal link is. | |
| 456 | - | enum LinkKind<'a> { | |
| 457 | - | /// Absolute URL, `mailto:`, internal route, or non-`.md` link — left | |
| 458 | - | /// untouched by rewriting and not a graph edge. | |
| 459 | - | Passthrough, | |
| 460 | - | /// Matches the unpublished pattern — link stripped, text kept; not an edge. | |
| 461 | - | Unpublished, | |
| 462 | - | /// Internal cross-doc `.md` link resolving to `slug`, with an optional | |
| 463 | - | /// `#anchor`. This is the one case that is both rewritten and a graph edge. | |
| 464 | - | Internal { | |
| 465 | - | slug: &'a str, | |
| 466 | - | anchor: Option<&'a str>, | |
| 467 | - | }, | |
| 468 | - | } | |
| 469 | - | ||
| 470 | - | /// Classify a link URL. Pure over `url`; the sole source of truth for "is this | |
| 471 | - | /// an internal doc link, and to which slug". | |
| 472 | - | fn classify_link<'a>(url: &'a str, unpublished_pattern: Option<&str>) -> LinkKind<'a> { | |
| 473 | - | // Preserve absolute URLs, mailto, and internal routes. | |
| 474 | - | if url.starts_with("http://") | |
| 475 | - | || url.starts_with("https://") | |
| 476 | - | || url.starts_with("mailto:") | |
| 477 | - | || url.starts_with('/') | |
| 478 | - | { | |
| 479 | - | return LinkKind::Passthrough; | |
| 480 | - | } | |
| 481 | - | ||
| 482 | - | // Unpublished docs: strip link, keep text. | |
| 483 | - | if let Some(pattern) = unpublished_pattern | |
| 484 | - | && url.contains(pattern) | |
| 485 | - | { | |
| 486 | - | return LinkKind::Unpublished; | |
| 487 | - | } | |
| 488 | - | ||
| 489 | - | // Only internal links containing .md resolve to a doc slug. | |
| 490 | - | if !url.contains(".md") { | |
| 491 | - | return LinkKind::Passthrough; | |
| 492 | - | } | |
| 493 | - | ||
| 494 | - | // Split off any #anchor. | |
| 495 | - | let (path_part, anchor) = match url.split_once('#') { | |
| 496 | - | Some((p, a)) => (p, Some(a)), | |
| 497 | - | None => (url, None), | |
| 498 | - | }; | |
| 499 | - | ||
| 500 | - | // Extract slug from filename: ../support/faq.md -> faq |
Lines truncated
| @@ -1,33 +1,0 @@ | |||
| 1 | - | /// HTML-escape a string for safe interpolation into element content or attributes. | |
| 2 | - | /// | |
| 3 | - | /// Escapes all five HTML-significant characters: `& < > " '`. | |
| 4 | - | pub(crate) fn html_escape(s: &str) -> String { | |
| 5 | - | s.replace('&', "&") | |
| 6 | - | .replace('<', "<") | |
| 7 | - | .replace('>', ">") | |
| 8 | - | .replace('"', """) | |
| 9 | - | .replace('\'', "'") | |
| 10 | - | } | |
| 11 | - | ||
| 12 | - | #[cfg(test)] | |
| 13 | - | mod tests { | |
| 14 | - | use super::*; | |
| 15 | - | ||
| 16 | - | #[test] | |
| 17 | - | fn escapes_all_five_chars() { | |
| 18 | - | assert_eq!( | |
| 19 | - | html_escape("A & B < C > D \" E ' F"), | |
| 20 | - | "A & B < C > D " E ' F" | |
| 21 | - | ); | |
| 22 | - | } | |
| 23 | - | ||
| 24 | - | #[test] | |
| 25 | - | fn no_change_for_safe_string() { | |
| 26 | - | assert_eq!(html_escape("hello world"), "hello world"); | |
| 27 | - | } | |
| 28 | - | ||
| 29 | - | #[test] | |
| 30 | - | fn empty_string() { | |
| 31 | - | assert_eq!(html_escape(""), ""); | |
| 32 | - | } | |
| 33 | - | } |
| @@ -1,139 +1,0 @@ | |||
| 1 | - | use std::collections::HashMap; | |
| 2 | - | ||
| 3 | - | use serde::Deserialize; | |
| 4 | - | ||
| 5 | - | /// Parsed TOML frontmatter from a markdown document. | |
| 6 | - | #[derive(Debug, Clone, Default, Deserialize)] | |
| 7 | - | pub struct Frontmatter { | |
| 8 | - | pub title: Option<String>, | |
| 9 | - | pub date: Option<String>, | |
| 10 | - | pub tags: Option<Vec<String>>, | |
| 11 | - | pub section: Option<String>, | |
| 12 | - | pub draft: Option<bool>, | |
| 13 | - | #[serde(flatten)] | |
| 14 | - | pub extra: HashMap<String, toml::Value>, | |
| 15 | - | } | |
| 16 | - | ||
| 17 | - | /// Parse TOML frontmatter delimited by `+++` from the beginning of a document. | |
| 18 | - | /// | |
| 19 | - | /// Returns the parsed frontmatter (if present) and the remaining markdown | |
| 20 | - | /// content. | |
| 21 | - | pub fn parse_frontmatter(input: &str) -> (Option<Frontmatter>, &str) { | |
| 22 | - | let trimmed = input.trim_start(); | |
| 23 | - | if !trimmed.starts_with("+++") { | |
| 24 | - | return (None, input); | |
| 25 | - | } | |
| 26 | - | ||
| 27 | - | // Find the closing +++ | |
| 28 | - | let after_opening = &trimmed[3..]; | |
| 29 | - | let after_opening = after_opening.strip_prefix('\n').unwrap_or(after_opening); | |
| 30 | - | ||
| 31 | - | if let Some(end_pos) = after_opening.find("\n+++") { | |
| 32 | - | let toml_content = &after_opening[..end_pos]; | |
| 33 | - | let rest_start = end_pos + 4; // skip \n+++ | |
| 34 | - | let rest = &after_opening[rest_start..]; | |
| 35 | - | let rest = rest.strip_prefix('\n').unwrap_or(rest); | |
| 36 | - | ||
| 37 | - | // Calculate the actual offset into the original input | |
| 38 | - | let rest_offset = input.len() - rest.len(); | |
| 39 | - | let rest_slice = &input[rest_offset..]; | |
| 40 | - | ||
| 41 | - | match toml::from_str::<Frontmatter>(toml_content) { | |
| 42 | - | Ok(fm) => (Some(fm), rest_slice), | |
| 43 | - | Err(e) => { | |
| 44 | - | tracing::warn!(error = %e, "Failed to parse TOML frontmatter"); | |
| 45 | - | (None, input) | |
| 46 | - | } | |
| 47 | - | } | |
| 48 | - | } else { | |
| 49 | - | (None, input) | |
| 50 | - | } | |
| 51 | - | } | |
| 52 | - | ||
| 53 | - | #[cfg(test)] | |
| 54 | - | mod tests { | |
| 55 | - | use super::*; | |
| 56 | - | ||
| 57 | - | #[test] | |
| 58 | - | fn parse_basic_frontmatter() { | |
| 59 | - | let input = "+++\ntitle = \"Hello\"\ndate = \"2026-01-01\"\n+++\n\n# Body"; | |
| 60 | - | let (fm, rest) = parse_frontmatter(input); | |
| 61 | - | let fm = fm.unwrap(); | |
| 62 | - | assert_eq!(fm.title.as_deref(), Some("Hello")); | |
| 63 | - | assert_eq!(fm.date.as_deref(), Some("2026-01-01")); | |
| 64 | - | // Exact match — `rest.contains("# Body")` would pass even if rest were | |
| 65 | - | // the entire input, so it's too loose to catch L38 arithmetic mutations | |
| 66 | - | // on `rest_offset`. Pinning the exact slice tightens the boundary. | |
| 67 | - | assert_eq!(rest, "\n# Body"); | |
| 68 | - | } | |
| 69 | - | ||
| 70 | - | #[test] | |
| 71 | - | fn parse_with_tags() { | |
| 72 | - | let input = "+++\ntitle = \"Post\"\ntags = [\"rust\", \"web\"]\n+++\nContent"; | |
| 73 | - | let (fm, _rest) = parse_frontmatter(input); | |
| 74 | - | let fm = fm.unwrap(); | |
| 75 | - | assert_eq!( | |
| 76 | - | fm.tags.as_deref(), | |
| 77 | - | Some(&["rust".to_string(), "web".to_string()][..]) | |
| 78 | - | ); | |
| 79 | - | } | |
| 80 | - | ||
| 81 | - | #[test] | |
| 82 | - | fn parse_with_draft() { | |
| 83 | - | let input = "+++\ndraft = true\n+++\nContent"; | |
| 84 | - | let (fm, _rest) = parse_frontmatter(input); | |
| 85 | - | assert_eq!(fm.unwrap().draft, Some(true)); | |
| 86 | - | } | |
| 87 | - | ||
| 88 | - | #[test] | |
| 89 | - | fn parse_with_extra_fields() { | |
| 90 | - | let input = "+++\ntitle = \"Test\"\ncustom_key = \"custom_value\"\n+++\nBody"; | |
| 91 | - | let (fm, _) = parse_frontmatter(input); | |
| 92 | - | let fm = fm.unwrap(); | |
| 93 | - | assert_eq!(fm.title.as_deref(), Some("Test")); | |
| 94 | - | assert_eq!( | |
| 95 | - | fm.extra.get("custom_key").and_then(|v| v.as_str()), | |
| 96 | - | Some("custom_value") | |
| 97 | - | ); | |
| 98 | - | } | |
| 99 | - | ||
| 100 | - | #[test] | |
| 101 | - | fn no_frontmatter() { | |
| 102 | - | let input = "# Just Markdown\n\nBody text"; | |
| 103 | - | let (fm, rest) = parse_frontmatter(input); | |
| 104 | - | assert!(fm.is_none()); | |
| 105 | - | assert_eq!(rest, input); | |
| 106 | - | } | |
| 107 | - | ||
| 108 | - | #[test] | |
| 109 | - | fn unclosed_frontmatter() { | |
| 110 | - | let input = "+++\ntitle = \"Oops\"\nNo closing delimiter"; | |
| 111 | - | let (fm, rest) = parse_frontmatter(input); | |
| 112 | - | assert!(fm.is_none()); | |
| 113 | - | assert_eq!(rest, input); | |
| 114 | - | } | |
| 115 | - | ||
| 116 | - | #[test] | |
| 117 | - | fn invalid_toml_returns_none() { | |
| 118 | - | let input = "+++\nnot valid toml {{{\n+++\nBody"; | |
| 119 | - | let (fm, rest) = parse_frontmatter(input); | |
| 120 | - | assert!(fm.is_none()); | |
| 121 | - | assert_eq!(rest, input); | |
| 122 | - | } | |
| 123 | - | ||
| 124 | - | #[test] | |
| 125 | - | fn empty_frontmatter() { | |
| 126 | - | let input = "+++\n\n+++\nBody"; | |
| 127 | - | let (fm, rest) = parse_frontmatter(input); | |
| 128 | - | let fm = fm.unwrap(); | |
| 129 | - | assert!(fm.title.is_none()); | |
| 130 | - | assert!(rest.contains("Body")); | |
| 131 | - | } | |
| 132 | - | ||
| 133 | - | #[test] | |
| 134 | - | fn frontmatter_with_section() { | |
| 135 | - | let input = "+++\nsection = \"guide\"\n+++\nContent"; | |
| 136 | - | let (fm, _) = parse_frontmatter(input); | |
| 137 | - | assert_eq!(fm.unwrap().section.as_deref(), Some("guide")); | |
| 138 | - | } | |
| 139 | - | } |