Skip to main content

max / makenotwork

Move docengine out to Libraries/docengine It now lives in its own repo so a container build can reach it by git dependency. The Alloy console is the case that forced it: it builds on fedora:43 with only its own repo in context, where ../../MNW/shared/docengine does not exist. server and multithreaded keep a path dependency, repointed at the new location. Nothing about the crate changed, so this is a move.
Author: Max Johnson <me@maxj.phd> · 2026-07-30 16:12 UTC
Signed with PGP, not checked
Commit: 5d4ff1fecd4c17c92354d0145aed8042e001644b
Parent: 2807dc6
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 - ![a diagram](https://cdn.makenot.work/img/diagram.png)
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(&quote_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: &regex_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(&section_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: &regex_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("&amp;", "&")
446 - .replace("&lt;", "<")
447 - .replace("&gt;", ">")
448 - .replace("&quot;", "\"")
449 - .replace("&#x27;", "'")
450 - .replace("&#39;", "'")
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('&', "&amp;")
6 - .replace('<', "&lt;")
7 - .replace('>', "&gt;")
8 - .replace('"', "&quot;")
9 - .replace('\'', "&#x27;")
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 &amp; B &lt; C &gt; D &quot; E &#x27; 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 - }