Skip to main content

max / docengine

Bring docengine back out of the MNW monorepo This repo is where docengine started, in April. It was absorbed into the MNW tree and carried forward there to 0.3.5 and edition 2024 while these remotes sat at 0.3.0 and edition 2021, forgotten and public. This commit is the crate as MNW has it, landed on top of that history rather than replacing it, so the lineage stays continuous and the April review notes stay reachable. What forced the move back out: the Alloy console builds inside a container on fedora:43 with only its own repo in build context, so the cross-repo path dependency every other consumer uses is unreachable. Same wall synckit hit on 2026-07-24, same answer. server, multithreaded and GoingsOn keep path dependencies, repointed at ../../Libraries/docengine. Records the license rather than changing it. Cargo.toml has said MIT since a64ec630 in the MNW tree, a rustfmt-and-lint-gates sweep that added the same line to four crates at once, while this README said PolyForm Noncommercial and no LICENSE file existed anywhere. MIT is correct and now stated in all three places. PolyForm is for the products (MNW, Multithreaded, SyncKit), not the libraries built for reuse, and the distinction is load-bearing: a GPL consumer cannot link PolyForm code, because the noncommercial term is an added restriction GPL section 7 forbids. Cargo.lock and the mutants.out directories do not come along, matching synckit: a library does not commit its lock, and mutation output regenerates.
Author: Max Johnson <me@maxj.phd> · 2026-07-30 16:19 UTC
Commit: 25fba1bfcf086b361c867abde538ff99c9b99789
Parent: f919b8c
20 files changed, +1593 insertions, -763 deletions
M .gitignore +3 -1
@@ -1,2 +1,4 @@
1 - /target/
1 + /target
2 + **/target
3 + Cargo.lock
2 4 .DS_Store
M Cargo.toml +50 -6
@@ -1,11 +1,12 @@
1 1 [package]
2 2 name = "docengine"
3 - version = "0.3.0"
4 - edition = "2021"
3 + version = "0.3.5"
4 + edition = "2024"
5 + license = "MIT"
5 6
6 7 [features]
7 8 default = []
8 - doc-loader = ["dep:regex", "dep:tracing"]
9 + doc-loader = ["dep:regex-lite", "dep:tracing"]
9 10 directives = ["dep:regex-lite"]
10 11 mentions = ["dep:regex-lite"]
11 12 quotes = ["dep:regex-lite", "dep:uuid"]
@@ -14,12 +15,55 @@
14 15 full = ["doc-loader", "directives", "mentions", "quotes", "frontmatter", "media-urls"]
15 16
16 17 [dependencies]
17 - pulldown-cmark = "0.12"
18 + pulldown-cmark = "0.13"
18 19 ammonia = "4"
19 20 serde = { version = "1", features = ["derive"] }
20 21
21 - regex = { version = "1", optional = true }
22 22 regex-lite = { version = "0.1", optional = true }
23 23 uuid = { version = "1", features = ["serde", "v4"], optional = true }
24 - toml = { version = "0.8", optional = true }
24 + toml = { version = "1.1", optional = true }
25 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"
M README.md +26 -3
@@ -4,6 +4,11 @@
4 4
5 5 Used by MNW (site docs, blog posts, user-generated content), Multithreaded (forum posts), and the desktop apps (descriptions, notes).
6 6
7 + Extracted from the MNW monorepo (2026-07-30) so it can be consumed from a
8 + container build, where a cross-repo path dependency is not reachable. Internal:
9 + not published to crates.io. Consumed by path in-tree, and by git dependency from
10 + anything built in a container.
11 +
7 12 ## Presets
8 13
9 14 Four rendering presets, each with different security/feature tradeoffs:
@@ -53,10 +58,13 @@
53 58
54 59 ```toml
55 60 # In Cargo.toml
56 - docengine = { path = "../Shared/docengine" } # Core only
57 - docengine = { path = "../Shared/docengine", features = ["full"] } # Everything
61 + docengine = { path = "../../Libraries/docengine" } # From MNW/server/ or Apps/
62 + docengine = { git = "https://makenot.work/git/max/docengine" } # From a container build
58 63 ```
59 64
65 + The path form needs the `~/Code` layout on disk, which a clone does not
66 + reproduce. Anything building in a container takes the git form.
67 +
60 68 ## Core API
61 69
62 70 ### Types
@@ -96,6 +104,15 @@
96 104 | `rewrite_media_paths(md, base, user)` | `media-urls` | Rewrite relative image paths to absolute CDN URLs |
97 105 | `img_to_video(html)` | `media-urls` | Convert `<img>` tags pointing to video files into `<video>` elements |
98 106
107 + ## Value substitution
108 +
109 + `{{ dotted.path | filter(args) }}` substitution used to be a docengine feature. It moved
110 + out on 2026-07-25 into two crates: `subst` (the generic engine) and
111 + `mnw-assumptions` (the MNW business-model layer on top), both in the MNW tree. Nothing
112 + about the render path changed -- the server builds an `Assumptions` at boot and hands its
113 + `substitute` to `DocLoaderConfig::pre_process`, which is the same hook any other
114 + pre-render text transform would use.
115 +
99 116 ## Consumers
100 117
101 118 | Project | Features used | Preset |
@@ -116,4 +133,10 @@
116 133
117 134 ## License
118 135
119 - PolyForm Noncommercial 1.0.0
136 + MIT. See [LICENSE](LICENSE).
137 +
138 + Permissive on purpose, and not the license the products carry: MNW,
139 + Multithreaded and SyncKit are PolyForm Noncommercial, while the libraries meant
140 + for reuse are MIT. That split is what lets a GPL consumer link this crate at all,
141 + since PolyForm's noncommercial term is an added restriction GPL section 7
142 + forbids. This file said PolyForm until 2026-07-30, which was wrong.
@@ -53,6 +53,23 @@
53 53
54 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 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 +
56 73 ### Mention resolution skips code
57 74
58 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.
@@ -65,7 +82,7 @@
65 82
66 83 | Consumer | Features | How it's used |
67 84 |----------|----------|---------------|
68 - | 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 |
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 |
69 86 | Multithreaded | mentions, quotes | Forum posts (strict), @username linking, quote attribution |
70 87 | GoingsOn | core | Task/event descriptions (standard) |
71 88 | Balanced Breakfast | core | RSS feed content (sanitize_only) |
@@ -1,5 +1,6 @@
1 1 /// Strip inline code (backtick) and fenced code blocks, replacing with spaces.
2 - pub fn strip_code_spans(input: &str) -> String {
2 + #[cfg_attr(not(any(feature = "mentions", test)), allow(dead_code))]
3 + pub(crate) fn strip_code_spans(input: &str) -> String {
3 4 let mut out = String::with_capacity(input.len());
4 5 let mut chars = input.chars().peekable();
5 6
@@ -36,7 +37,7 @@
36 37 }
37 38
38 39 /// Return byte ranges of inline code spans and fenced code blocks.
39 - pub fn code_span_ranges(input: &str) -> Vec<(usize, usize)> {
40 + pub(crate) fn code_span_ranges(input: &str) -> Vec<(usize, usize)> {
40 41 let mut ranges = Vec::new();
41 42 let bytes = input.as_bytes();
42 43 let len = bytes.len();
@@ -129,4 +130,59 @@
129 130 assert!(code_span_ranges("no code here").is_empty());
130 131 assert_eq!(strip_code_spans("no code here"), "no code here");
131 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 + }
132 188 }
M src/directives.rs +232 -15
@@ -13,25 +13,107 @@
13 13 /// Matches any `[!TYPE]` alert marker inside a blockquote paragraph.
14 14 /// Accepts any uppercase word (letters, digits, hyphens, underscores).
15 15 static ALERT_RE: LazyLock<regex_lite::Regex> = LazyLock::new(|| {
16 - regex_lite::Regex::new(
17 - r"<blockquote>\s*<p>\[!([A-Z][A-Z0-9_-]*)\](?:<br\s*/?>)?\s*",
18 - )
19 - .expect("valid alert regex")
16 + regex_lite::Regex::new(r"<blockquote>\s*<p>\[!([A-Z][A-Z0-9_-]*)\](?:<br\s*/?>)?\s*")
17 + .expect("valid alert regex")
20 18 });
21 19
22 - /// Process all directives: code tabs first, then alerts.
20 + /// Process all directives: UI examples first, then code tabs, then alerts.
23 21 pub fn post_process_directives(html: &str) -> String {
24 - let with_tabs = process_tabs(html);
22 + let with_ui = process_ui_examples(html);
23 + let with_tabs = process_tabs(&with_ui);
25 24 process_alerts(&with_tabs)
26 25 }
27 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 +
28 110 /// Replace alert blockquotes with styled `<div class="alert ...">` elements.
29 111 fn process_alerts(html: &str) -> String {
30 112 // First pass: replace opening markers.
31 113 let opened = ALERT_RE.replace_all(html, |caps: &regex_lite::Captures| {
32 114 let kind = &caps[1];
33 - // Skip TABS — already handled by process_tabs.
34 - if kind == "TABS" {
115 + // Skip TABS and UI — already handled by their own processors.
116 + if kind == "TABS" || kind == "UI" {
35 117 return caps[0].to_string();
36 118 }
37 119 let label = title_case(kind);
@@ -45,7 +127,7 @@
45 127 // Second pass: close any opened alerts.
46 128 let alert_count = ALERT_RE
47 129 .captures_iter(html)
48 - .filter(|c| &c[1] != "TABS")
130 + .filter(|c| &c[1] != "TABS" && &c[1] != "UI")
49 131 .count();
50 132 if alert_count == 0 {
51 133 return opened.into_owned();
@@ -217,7 +299,7 @@
217 299 mod tests {
218 300 use super::*;
219 301
220 - // ===== Alert directives =====
302 + // --- alert directives
221 303
222 304 #[test]
223 305 fn note_alert() {
@@ -293,7 +375,7 @@
293 375 assert!(result.contains("Normal quote."));
294 376 }
295 377
296 - // ===== Custom alert types =====
378 + // --- custom alert types
297 379
298 380 #[test]
299 381 fn custom_example_alert() {
@@ -315,14 +397,13 @@
315 397
316 398 #[test]
317 399 fn custom_alert_with_hyphen() {
318 - let html =
319 - "<blockquote>\n<p>[!SEE-ALSO]<br>\nRelated topics.</p>\n</blockquote>";
400 + let html = "<blockquote>\n<p>[!SEE-ALSO]<br>\nRelated topics.</p>\n</blockquote>";
320 401 let result = post_process_directives(html);
321 402 assert!(result.contains("alert alert-see-also"));
322 403 assert!(result.contains("<p class=\"alert-title\">See-also</p>"));
323 404 }
324 405
325 - // ===== Code tabs =====
406 + // --- code tabs
326 407
327 408 #[test]
328 409 fn tabs_two_languages() {
@@ -422,7 +503,7 @@
422 503 assert!(!result.contains("<blockquote>"));
423 504 }
424 505
425 - // ===== Language label mapping =====
506 + // --- language label mapping
426 507
427 508 #[test]
428 509 fn language_labels() {
@@ -440,4 +521,140 @@
440 521 assert_eq!(code_language_label("python"), "Python");
441 522 assert_eq!(code_language_label("go"), "Go");
442 523 }
524 +
525 + // --- UI example directives
526 +
527 + #[test]
528 + fn ui_example_basic() {
529 + let html = "<blockquote>\n<p>[!UI] discover-filters</p>\n</blockquote>";
530 + let result = post_process_directives(html);
531 + assert!(result.contains("doc-ui"));
532 + assert!(result.contains("data-ui=\"discover-filters\""));
533 + assert!(result.contains("<figure"));
534 + assert!(!result.contains("<blockquote>"));
535 + }
536 +
537 + #[test]
538 + fn ui_example_with_caption() {
539 + let html = "<blockquote>\n<p>[!UI] discover-filters<br>\nFilter sidebar in items mode</p>\n</blockquote>";
540 + let result = post_process_directives(html);
541 + assert!(result.contains("data-ui=\"discover-filters\""));
542 + assert!(result.contains("<figcaption>"));
543 + assert!(result.contains("Filter sidebar in items mode"));
544 + }
545 +
546 + #[test]
547 + fn ui_example_not_confused_with_alert() {
548 + let html = concat!(
549 + "<blockquote>\n<p>[!UI] my-example</p>\n</blockquote>\n",
550 + "<blockquote>\n<p>[!NOTE]<br>\nA note.</p>\n</blockquote>"
551 + );
552 + let result = post_process_directives(html);
553 + assert!(result.contains("doc-ui"));
554 + assert!(result.contains("alert alert-note"));
555 + }
556 +
557 + #[test]
558 + fn alert_close_replacement_does_not_consume_extra_blockquote() {
559 + // Pins the `replaced < alert_count` loop bound in process_alerts:
560 + // mutating `<` to `<=` would consume a SECOND `</blockquote>` (from the
561 + // following normal quote) as if it were an alert close.
562 + let html = concat!(
563 + "<blockquote>\n<p>[!WARNING]<br>\nWatch out!</p>\n</blockquote>\n",
564 + "<blockquote>\n<p>Normal quote.</p>\n</blockquote>"
565 + );
566 + let result = post_process_directives(html);
567 + // Normal blockquote must retain its closing tag. The opening also
568 + // survives (one remaining `<blockquote>`; the alert's was removed).
569 + assert_eq!(
570 + result.matches("<blockquote>").count(),
571 + 1,
572 + "expected exactly one surviving <blockquote>, got: {result}"
573 + );
574 + assert_eq!(
575 + result.matches("</blockquote>").count(),
576 + 1,
577 + "expected exactly one surviving </blockquote>, got: {result}"
578 + );
579 + // And the alert structure is correctly closed exactly once.
580 + assert_eq!(
581 + result.matches("</div>").count(),
582 + 1,
583 + "exactly one </div> for the single alert, got: {result}"
584 + );
585 + }
586 +
587 + #[test]
588 + fn strip_html_tags_keeps_text_between_tags() {
589 + // strip_html_tags_simple is exercised via UI caption extraction.
590 + // This test asserts the exact text-between-tags behavior, which pins
591 + // the `<` (enter tag), `>` (exit tag), and `_ if !in_tag` arms.
592 + let html = concat!(
593 + "<blockquote>\n<p>[!UI] widget</p>\n",
594 + "<p>Caption with <em>emphasis</em> and <code>code</code> inside.</p>\n",
595 + "</blockquote>"
596 + );
597 + let result = post_process_directives(html);
598 + // Caption text must contain every text segment with no tag fragments.
599 + assert!(
600 + result.contains("Caption with emphasis and code inside."),
601 + "stripped caption text missing or wrong: {result}"
602 + );
603 + // And must NOT contain any of the original inline tag names.
604 + assert!(
605 + !result.contains("<em>"),
606 + "<em> leaked into caption: {result}"
607 + );
608 + assert!(
609 + !result.contains("<code>"),
610 + "<code> leaked into caption: {result}"
611 + );
612 + }
613 +
614 + #[test]
615 + fn ui_caption_extraction_uses_correct_byte_range() {
616 + // Pins the `bq_pos + marker_end` and `..close_pos` arithmetic in
617 + // process_ui_examples: caption should be exactly what follows the
618 + // marker line, with no leakage from before the marker or after the
619 + // blockquote close.
620 + let html = concat!(
621 + "<p>Lead paragraph.</p>\n",
622 + "<blockquote>\n<p>[!UI] preview</p>\n<p>Exact caption.</p>\n</blockquote>\n",
623 + "<p>Trailing paragraph.</p>"
624 + );
625 + let result = post_process_directives(html);
626 + assert!(
627 + result.contains("<figcaption>Exact caption.</figcaption>"),
628 + "caption byte-range arithmetic wrong: {result}"
629 + );
630 + // The surrounding paragraphs survive intact and unduplicated.
631 + assert_eq!(
632 + result.matches("Lead paragraph.").count(),
633 + 1,
634 + "lead duplicated/missing: {result}"
635 + );
636 + assert_eq!(
637 + result.matches("Trailing paragraph.").count(),
638 + 1,
639 + "trailer duplicated/missing: {result}"
640 + );
641 + // The marker line is fully consumed — `[!UI]` must not leak through.
642 + assert!(!result.contains("[!UI]"), "marker leaked: {result}");
643 + }
644 +
645 + #[test]
646 + fn ui_example_preserves_other_blockquotes() {
647 + let html = concat!(
648 + "<blockquote>\n<p>Normal quote.</p>\n</blockquote>\n",
649 + "<blockquote>\n<p>[!UI] cart-view</p>\n</blockquote>"
650 + );
651 + let result = post_process_directives(html);
652 + // After process_ui_examples, the normal blockquote should still have <blockquote>.
653 + // But then process_alerts runs and leaves non-alert blockquotes alone.
654 + // Check the UI example was processed.
655 + assert!(result.contains("data-ui=\"cart-view\""));
656 + assert!(result.contains("Normal quote."));
657 + // The normal blockquote survives all processing.
658 + assert!(result.contains("<blockquote>"), "Result: {result}");
659 + }
443 660 }
M src/doc_loader.rs +357 -57
@@ -1,12 +1,47 @@
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 +
1 33 use std::collections::HashMap;
2 34 use std::path::Path;
3 35 use std::sync::LazyLock;
4 36
5 - use regex::Regex;
37 + use regex_lite::Regex;
6 38
7 - static LINK_RE: LazyLock<Regex> = LazyLock::new(|| {
8 - Regex::new(r"\[([^\]]+)\]\(([^)]+)\)").expect("valid regex")
9 - });
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>;
10 45
11 46 /// Configuration for the doc loader.
12 47 pub struct DocLoaderConfig {
@@ -16,6 +51,13 @@
16 51 pub link_prefix: String,
17 52 /// Pattern that identifies unpublished links to strip (e.g., "unpublished/").
18 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>,
19 61 }
20 62
21 63 /// A rendered documentation page.
@@ -44,11 +86,53 @@
44 86 pub body_text: String,
45 87 }
46 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 +
47 119 /// In-memory store of rendered documentation pages, built once at startup.
48 120 #[derive(Clone, Debug)]
49 121 pub struct DocLoader {
50 122 pages: HashMap<String, DocPage>,
51 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>,
52 136 }
53 137
54 138 impl DocLoader {
@@ -58,6 +142,8 @@
58 142 pub fn load(base_path: &Path, config: &DocLoaderConfig) -> Self {
59 143 let mut pages = HashMap::new();
60 144 let mut index = Vec::new();
145 + let mut collisions = Vec::new();
146 + let mut links: HashMap<String, Vec<String>> = HashMap::new();
61 147
62 148 for (dir_name, section_display) in &config.sections {
63 149 let section_path = base_path.join(dir_name);
@@ -75,12 +161,7 @@
75 161
76 162 let mut entries: Vec<_> = read_dir
77 163 .filter_map(|e| e.ok())
78 - .filter(|e| {
79 - e.path()
80 - .extension()
81 - .map(|ext| ext == "md")
82 - .unwrap_or(false)
83 - })
164 + .filter(|e| e.path().extension().map(|ext| ext == "md").unwrap_or(false))
84 165 .collect();
85 166
86 167 entries.sort_by_key(|e| e.file_name());
@@ -98,17 +179,43 @@
98 179 Err(_) => continue,
99 180 };
100 181
101 - let title =
102 - crate::text::extract_title(&raw_md).unwrap_or_else(|| slug.clone());
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());
103 203 let rewritten_md = rewrite_links(
104 204 &raw_md,
105 205 &config.link_prefix,
106 206 config.unpublished_pattern.as_deref(),
107 207 );
108 208 let md_without_title = crate::text::strip_first_heading(&rewritten_md);
109 - let html_content = crate::render_permissive(&md_without_title);
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);
110 215 #[cfg(feature = "directives")]
111 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());
112 219
113 220 let page = DocPage {
114 221 title,
@@ -123,12 +230,79 @@
123 230 section: page.section.clone(),
124 231 });
125 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 +
126 238 let slug_key = page.slug.clone();
127 - pages.insert(slug_key, page);
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 + }
128 258 }
129 259 }
130 260
131 - DocLoader { pages, index }
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 + }
132 306 }
133 307
134 308 /// Look up a rendered page by slug.
@@ -141,6 +315,46 @@
141 315 &self.index
142 316 }
143 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 +
144 358 /// Build a search index with HTML stripped to plain text.
145 359 pub fn search_index(&self) -> Vec<DocSearchEntry> {
146 360 self.index
@@ -158,6 +372,53 @@
158 372 }
159 373 }
160 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 +
161 422 /// Strip HTML tags from a string, returning plain text.
162 423 /// Decodes common HTML entities so search indexes match plain-text queries.
163 424 fn strip_html_tags(html: &str) -> String {
@@ -189,58 +450,100 @@
189 450 .replace("&#39;", "'")
190 451 }
191 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
501 + let slug = path_part
502 + .rsplit('/')
503 + .next()
504 + .unwrap_or(path_part)
505 + .trim_end_matches(".md");
506 +
507 + LinkKind::Internal { slug, anchor }
508 + }
509 +
192 510 /// Rewrite relative `.md` links to the configured prefix.
193 511 fn rewrite_links(markdown: &str, link_prefix: &str, unpublished_pattern: Option<&str>) -> String {
194 512 LINK_RE
195 - .replace_all(markdown, |caps: &regex::Captures| {
513 + .replace_all(markdown, |caps: &regex_lite::Captures| {
196 514 let text = &caps[1];
197 - let url = &caps[2];
198 -
199 - // Preserve absolute URLs, mailto, and internal routes.
200 - if url.starts_with("http://")
201 - || url.starts_with("https://")
202 - || url.starts_with("mailto:")
203 - || url.starts_with('/')
204 - {
205 - return caps[0].to_string();
206 - }
207 -
208 - // Unpublished docs: strip link, keep text.
209 - if let Some(pattern) = unpublished_pattern {
210 - if url.contains(pattern) {
211 - return text.to_string();
515 + match classify_link(&caps[2], unpublished_pattern) {
516 + LinkKind::Passthrough => caps[0].to_string(),
517 + LinkKind::Unpublished => text.to_string(),
518 + LinkKind::Internal { slug, anchor } => {
519 + let mut new_url = format!("{link_prefix}/{slug}");
520 + if let Some(anchor) = anchor {
521 + new_url.push('#');
522 + new_url.push_str(anchor);
523 + }
524 + format!("[{text}]({new_url})")
212 525 }
213 526 }
214 -
215 - // Only rewrite links containing .md
216 - if !url.contains(".md") {
217 - return caps[0].to_string();
218 - }
219 -
220 - // Split off any #anchor.
221 - let (path_part, anchor): (&str, Option<&str>) = match url.split_once('#') {
222 - Some((p, a)) => (p, Some(a)),
223 - None => (url, None),
224 - };
225 -
226 - // Extract slug from filename: ../support/faq.md -> faq
227 - let filename = path_part
228 - .rsplit('/')
229 - .next()
230 - .unwrap_or(path_part)
231 - .trim_end_matches(".md");
232 -
233 - let mut new_url = format!("{link_prefix}/{filename}");
234 - if let Some(anchor) = anchor {
235 - new_url.push('#');
236 - new_url.push_str(anchor);
237 - }
238 -
239 - format!("[{text}]({new_url})")
240 527 })
241 528 .to_string()
242 529 }
243 530
531 + /// Collect the internal-link targets of a page, deduplicated in first-seen
532 + /// order. Reads the same pre-processed markdown and the same [`classify_link`]
533 + /// rules as [`rewrite_links`], so every recorded edge corresponds to a link that
534 + /// is actually rewritten and served.
535 + fn collect_link_targets(markdown: &str, unpublished_pattern: Option<&str>) -> Vec<String> {
536 + let mut targets: Vec<String> = Vec::new();
537 + for caps in LINK_RE.captures_iter(markdown) {
538 + if let LinkKind::Internal { slug, .. } = classify_link(&caps[2], unpublished_pattern)
539 + && !targets.iter().any(|t| t == slug)
540 + {
541 + targets.push(slug.to_string());
542 + }
543 + }
Lines truncated
@@ -61,7 +61,10 @@
61 61 let fm = fm.unwrap();
62 62 assert_eq!(fm.title.as_deref(), Some("Hello"));
63 63 assert_eq!(fm.date.as_deref(), Some("2026-01-01"));
64 - assert!(rest.contains("# Body"));
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");
65 68 }
66 69
67 70 #[test]
@@ -69,7 +72,10 @@
69 72 let input = "+++\ntitle = \"Post\"\ntags = [\"rust\", \"web\"]\n+++\nContent";
70 73 let (fm, _rest) = parse_frontmatter(input);
71 74 let fm = fm.unwrap();
72 - assert_eq!(fm.tags.as_deref(), Some(&["rust".to_string(), "web".to_string()][..]));
75 + assert_eq!(
76 + fm.tags.as_deref(),
77 + Some(&["rust".to_string(), "web".to_string()][..])
78 + );
73 79 }
74 80
75 81 #[test]
M src/lib.rs +112 -5
@@ -1,5 +1,8 @@
1 1 //! Configurable markdown-to-HTML rendering with sanitization presets.
2 2 //!
3 + //! Design + roadmap: maintainer wiki.
4 + //! <!-- wiki: docengine-overview -->
5 + //!
3 6 //! Provides four rendering presets for different trust levels:
4 7 //! - **Permissive** -- full GFM (tables, footnotes, images, raw HTML). For trusted content.
5 8 //! - **Standard** -- GFM without images. For app text fields.
@@ -23,12 +26,12 @@
23 26 mod doc_loader;
24 27 #[cfg(feature = "frontmatter")]
25 28 mod frontmatter;
29 + #[cfg(feature = "media-urls")]
30 + mod media_urls;
26 31 #[cfg(feature = "mentions")]
27 32 mod mentions;
28 33 #[cfg(feature = "quotes")]
29 34 mod quotes;
30 - #[cfg(feature = "media-urls")]
31 - mod media_urls;
32 35
33 36 // Re-export core types
34 37 pub use render::{RenderResult, Renderer};
@@ -40,15 +43,17 @@
40 43 #[cfg(feature = "directives")]
41 44 pub use directives::post_process_directives;
42 45 #[cfg(feature = "doc-loader")]
43 - pub use doc_loader::{DocIndexEntry, DocLoader, DocLoaderConfig, DocPage, DocSearchEntry};
46 + pub use doc_loader::{
47 + DocIndexEntry, DocLoader, DocLoaderConfig, DocPage, DocSearchEntry, SlugCollision,
48 + };
44 49 #[cfg(feature = "frontmatter")]
45 50 pub use frontmatter::{Frontmatter, parse_frontmatter};
51 + #[cfg(feature = "media-urls")]
52 + pub use media_urls::{img_to_video, rewrite_media_paths};
46 53 #[cfg(feature = "mentions")]
47 54 pub use mentions::{extract_mentions, resolve_mentions};
48 55 #[cfg(feature = "quotes")]
49 56 pub use quotes::{QuoteAuthor, post_process_quotes};
50 - #[cfg(feature = "media-urls")]
51 - pub use media_urls::{img_to_video, rewrite_media_paths};
52 57
53 58 /// Render markdown with the permissive preset (GFM features, default ammonia).
54 59 pub fn render_permissive(markdown: &str) -> String {
@@ -69,3 +74,105 @@
69 74 pub fn sanitize_html(html: &str) -> String {
70 75 Renderer::sanitize_only().sanitize_html(html)
71 76 }
77 +
78 + /// Drop `src`/`poster`/`srcset` on media elements (`img`/`video`/`audio`/
79 + /// `source`) that point at an off-platform host, so creator markdown cannot load
80 + /// external images (tracking pixels: an external `<img>` leaks every viewer's
81 + /// IP/UA/referrer to an arbitrary third party — contrary to the no-tracking
82 + /// promise). Relative URLs and URLs under `allowed_host` are kept; external text
83 + /// links are untouched (only media loads matter for the beacon). Run this on the
84 + /// permissive-rendered HTML *before* `img_to_video`, since it works on `<img>`.
85 + /// Additive and opt-in — existing `render_permissive` behaviour is unchanged.
86 + pub fn restrict_media_hosts(html: &str, allowed_host: &str) -> String {
87 + let allowed = allowed_host.trim_end_matches('/').to_string();
88 + ammonia::Builder::default()
89 + .attribute_filter(move |element, attribute, value| {
90 + let is_media_src = matches!(
91 + (element, attribute),
92 + ("img" | "source", "src" | "srcset")
93 + | ("video", "src" | "poster")
94 + | ("audio", "src")
95 + );
96 + if is_media_src {
97 + let v = value.trim();
98 + let on_platform =
99 + !v.contains("://") || v.starts_with('/') || v.starts_with(&allowed);
100 + if !on_platform {
101 + return None; // drop the attribute → no external media load
102 + }
103 + }
104 + Some(std::borrow::Cow::Borrowed(value))
105 + })
106 + .clean(html)
107 + .to_string()
108 + }
109 +
110 + #[cfg(test)]
111 + mod top_level_tests {
112 + use super::*;
113 +
114 + // Direct unit tests for the top-level convenience wrappers. Without these,
115 + // mutating any of them to return `String::new()` or a sentinel passes the
116 + // suite (the wrappers were untested at the function level).
117 +
118 + #[test]
119 + fn restrict_media_hosts_drops_external_img_keeps_cdn_and_links() {
120 + let cdn = "https://cdn.makenot.work";
121 + let html = concat!(
122 + r#"<img src="https://tracker.evil/px.png?u=v">"#,
123 + r#"<img src="https://cdn.makenot.work/u/a.png">"#,
124 + r#"<img src="/rel/b.png">"#,
125 + r#"<a href="https://example.com/post">link</a>"#,
126 + );
127 + let out = restrict_media_hosts(html, cdn);
128 + assert!(
129 + !out.contains("tracker.evil"),
130 + "external img src must be dropped: {out}"
131 + );
132 + assert!(
133 + out.contains("cdn.makenot.work/u/a.png"),
134 + "cdn img kept: {out}"
135 + );
136 + assert!(out.contains("/rel/b.png"), "relative img kept: {out}");
137 + assert!(
138 + out.contains(r#"href="https://example.com/post""#),
139 + "external text link kept: {out}"
140 + );
141 + }
142 +
143 + #[test]
144 + fn render_permissive_emits_paragraph_markup() {
145 + let out = render_permissive("Hello **world**.");
146 + assert!(out.contains("<p>"), "expected paragraph: {out:?}");
147 + assert!(
148 + out.contains("<strong>world</strong>"),
149 + "expected bold: {out:?}"
150 + );
151 + }
152 +
153 + #[test]
154 + fn render_standard_strips_images() {
155 + let out = render_standard("![alt](pic.png)");
156 + assert!(
157 + !out.contains("<img"),
158 + "standard preset must strip images: {out:?}"
159 + );
160 + }
161 +
162 + #[test]
163 + fn render_strict_strips_images_and_raw_html() {
164 + let out = render_strict("Hi <script>evil()</script> ![x](y.png)");
165 + assert!(!out.contains("<img"), "strict must strip images: {out:?}");
166 + assert!(
167 + !out.contains("<script"),
168 + "strict must strip scripts: {out:?}"
169 + );
170 + }
171 +
172 + #[test]
173 + fn sanitize_html_strips_dangerous_tags() {
174 + let out = sanitize_html("<p>safe</p><script>bad()</script>");
175 + assert!(out.contains("safe"), "kept text: {out:?}");
176 + assert!(!out.contains("<script"), "stripped script: {out:?}");
177 + }
178 + }
M src/media_urls.rs +16 -14
@@ -1,9 +1,9 @@
1 1 //! Pre-process and post-process markdown/HTML for media file references.
2 2 //!
3 3 //! Two-stage pipeline:
4 - //! 1. **Pre-process markdown** — rewrite `![alt](folder/file.png)` to
4 + //! 1. **Pre-process markdown**: rewrite `![alt](folder/file.png)` to
5 5 //! `![alt](https://cdn.makenot.work/{user_id}/media/folder/file.png)`.
6 - //! 2. **Post-process HTML** — convert `<img src="...file.mp4">` to
6 + //! 2. **Post-process HTML**: convert `<img src="...file.mp4">` to
7 7 //! `<video controls src="..."></video>`.
8 8
9 9 use std::sync::LazyLock;
@@ -16,16 +16,13 @@
16 16
17 17 /// Matches `<img` tags with a src pointing to a video extension.
18 18 static IMG_VIDEO_RE: LazyLock<regex_lite::Regex> = LazyLock::new(|| {
19 - regex_lite::Regex::new(
20 - r#"<img\s+([^>]*?)src="([^"]*\.(?:mp4|webm|mov))"([^>]*?)\s*/?>"#,
21 - )
22 - .expect("valid img video regex")
19 + regex_lite::Regex::new(r#"<img\s+([^>]*?)src="([^"]*\.(?:mp4|webm|mov))"([^>]*?)\s*/?>"#)
20 + .expect("valid img video regex")
23 21 });
24 22
25 23 /// Matches `alt="..."` in an img tag's attributes.
26 - static ALT_RE: LazyLock<regex_lite::Regex> = LazyLock::new(|| {
27 - regex_lite::Regex::new(r#"alt="([^"]*)""#).expect("valid alt regex")
28 - });
24 + static ALT_RE: LazyLock<regex_lite::Regex> =
25 + LazyLock::new(|| regex_lite::Regex::new(r#"alt="([^"]*)""#).expect("valid alt regex"));
29 26
30 27 /// Rewrite relative image paths in markdown to absolute CDN URLs.
31 28 ///
@@ -79,8 +76,16 @@
79 76 .map(|c| c[1].to_string())
80 77 .unwrap_or_default();
81 78
79 + // Escape `src` too, not just `alt`. The regex capture is `[^"]*` and
80 + // ammonia (which ran before this) entity-escapes `"`/`<`/`>` inside
81 + // attribute values, so a breakout isn't representable today — but
82 + // don't depend on that invariant holding upstream (Run #21 UX NOTE).
83 + let src = crate::escape::html_escape(src);
82 84 if alt.is_empty() {
83 - format!(r#"<video controls src="{}">Your browser does not support video.</video>"#, src)
85 + format!(
86 + r#"<video controls src="{}">Your browser does not support video.</video>"#,
87 + src
88 + )
84 89 } else {
85 90 format!(
86 91 r#"<video controls src="{}">{}</video>"#,
@@ -208,10 +213,7 @@
208 213 fn empty_alt_text() {
209 214 let md = "![](photo.jpg)";
210 215 let result = rewrite_media_paths(md, "https://cdn.makenot.work", "u1");
211 - assert_eq!(
212 - result,
213 - "![](https://cdn.makenot.work/u1/media/photo.jpg)"
214 - );
216 + assert_eq!(result, "![](https://cdn.makenot.work/u1/media/photo.jpg)");
215 217 }
216 218
217 219 #[test]
M src/mentions.rs +12 -2
@@ -39,12 +39,22 @@
39 39
40 40 for (code_start, code_end) in code_span_ranges(input) {
41 41 let before = &input[pos..code_start];
42 - result.push_str(&replace_mentions(before, valid_usernames, url_template, &MENTION_RE));
42 + result.push_str(&replace_mentions(
43 + before,
44 + valid_usernames,
45 + url_template,
46 + &MENTION_RE,
47 + ));
43 48 result.push_str(&input[code_start..code_end]);
44 49 pos = code_end;
45 50 }
46 51 let tail = &input[pos..];
47 - result.push_str(&replace_mentions(tail, valid_usernames, url_template, &MENTION_RE));
52 + result.push_str(&replace_mentions(
53 + tail,
54 + valid_usernames,
55 + url_template,
56 + &MENTION_RE,
57 + ));
48 58
49 59 result
50 60 }
M src/quotes.rs +1 -4
@@ -11,10 +11,7 @@
11 11
12 12 /// Post-process rendered HTML to replace `[quote:POST_ID:HASH]` markers with
13 13 /// clickable author attribution.
14 - pub fn post_process_quotes(
15 - html: &str,
16 - quote_authors: &HashMap<uuid::Uuid, QuoteAuthor>,
17 - ) -> String {
14 + pub fn post_process_quotes(html: &str, quote_authors: &HashMap<uuid::Uuid, QuoteAuthor>) -> String {
18 15 static QUOTE_RE: std::sync::LazyLock<regex_lite::Regex> = std::sync::LazyLock::new(|| {
19 16 regex_lite::Regex::new(r"\[quote:([0-9a-f\-]{36}):([0-9a-f]{8})\]").unwrap()
20 17 });
M src/render.rs +190 -17
@@ -1,6 +1,7 @@
1 1 use pulldown_cmark::{CowStr, Event, Options, Parser, Tag, TagEnd, html};
2 2
3 3 use crate::sanitize::SanitizePreset;
4 + use crate::toc::AnchorGen;
4 5
5 6 /// Returns true if the URL uses a scheme not in the safe allowlist.
6 7 ///
@@ -9,10 +10,7 @@
9 10 let trimmed = url.trim();
10 11 if let Some(colon_pos) = trimmed.find(':') {
11 12 let before_colon = &trimmed[..colon_pos];
12 - if before_colon.contains('/')
13 - || before_colon.contains('#')
14 - || before_colon.contains('?')
15 - {
13 + if before_colon.contains('/') || before_colon.contains('#') || before_colon.contains('?') {
16 14 return false;
17 15 }
18 16 let scheme = before_colon.to_ascii_lowercase();
@@ -40,9 +38,47 @@
40 38 strip_images: bool,
41 39 strip_raw_html: bool,
42 40 dangerous_scheme_filter: bool,
41 + heading_ids: bool,
43 42 sanitize: SanitizePreset,
44 43 }
45 44
45 + /// Give every heading the anchor [`extract_toc`](crate::extract_toc) will point
46 + /// at, so a rendered TOC actually navigates.
47 + ///
48 + /// `id` is assigned in document order through the same [`AnchorGen`] the TOC
49 + /// uses, which is what keeps the two in agreement — including the `-1`, `-2`
50 + /// suffixes on repeated headings.
51 + fn inject_heading_ids(mut events: Vec<Event<'_>>) -> Vec<Event<'_>> {
52 + let mut anchors = AnchorGen::default();
53 + let mut i = 0;
54 + while i < events.len() {
55 + if !matches!(events[i], Event::Start(Tag::Heading { .. })) {
56 + i += 1;
57 + continue;
58 + }
59 + // Heading text is only known once its inner events have been seen.
60 + let mut text = String::new();
61 + let mut j = i + 1;
62 + while j < events.len() {
63 + match &events[j] {
64 + Event::End(TagEnd::Heading(_)) => break,
65 + Event::Text(t) => text.push_str(t),
66 + Event::Code(c) => text.push_str(c),
67 + _ => {}
68 + }
69 + j += 1;
70 + }
71 + let anchor = anchors.next(&text);
72 + if let Event::Start(Tag::Heading { id, .. }) = &mut events[i] {
73 + // Always None in practice: ENABLE_HEADING_ATTRIBUTES is never set,
74 + // so markdown cannot carry its own `{#id}`.
75 + *id = Some(CowStr::Boxed(anchor.into_boxed_str()));
76 + }
77 + i = j + 1;
78 + }
79 + events
80 + }
81 +
46 82 impl Renderer {
47 83 /// GFM features, default ammonia sanitization. Suitable for trusted content
48 84 /// like docs and blog posts.
@@ -56,6 +92,7 @@
56 92 strip_images: false,
57 93 strip_raw_html: false,
58 94 dangerous_scheme_filter: false,
95 + heading_ids: false,
59 96 sanitize: SanitizePreset::Permissive,
60 97 }
61 98 }
@@ -72,6 +109,7 @@
72 109 strip_images: true,
73 110 strip_raw_html: false,
74 111 dangerous_scheme_filter: false,
112 + heading_ids: false,
75 113 sanitize: SanitizePreset::Standard,
76 114 }
77 115 }
@@ -88,11 +126,12 @@
88 126 strip_images: true,
89 127 strip_raw_html: true,
90 128 dangerous_scheme_filter: true,
129 + heading_ids: false,
91 130 sanitize: SanitizePreset::Strict,
92 131 }
93 132 }
94 133
95 - /// No markdown parsing, just ammonia sanitization. Suitable for HTML from
134 + /// No markdown parsing, only ammonia sanitization. Suitable for HTML from
96 135 /// external sources (RSS feeds).
97 136 pub fn sanitize_only() -> Self {
98 137 Self {
@@ -104,55 +143,83 @@
104 143 strip_images: false,
105 144 strip_raw_html: false,
106 145 dangerous_scheme_filter: false,
146 + heading_ids: false,
107 147 sanitize: SanitizePreset::Permissive,
108 148 }
109 149 }
110 150
151 + #[must_use]
111 152 pub fn with_tables(mut self, enabled: bool) -> Self {
112 153 self.tables = enabled;
113 154 self
114 155 }
115 156
157 + #[must_use]
116 158 pub fn with_strikethrough(mut self, enabled: bool) -> Self {
117 159 self.strikethrough = enabled;
118 160 self
119 161 }
120 162
163 + #[must_use]
121 164 pub fn with_footnotes(mut self, enabled: bool) -> Self {
122 165 self.footnotes = enabled;
123 166 self
124 167 }
125 168
169 + #[must_use]
126 170 pub fn with_smart_punctuation(mut self, enabled: bool) -> Self {
127 171 self.smart_punctuation = enabled;
128 172 self
129 173 }
130 174
175 + #[must_use]
131 176 pub fn with_tasklists(mut self, enabled: bool) -> Self {
132 177 self.tasklists = enabled;
133 178 self
134 179 }
135 180
181 + #[must_use]
136 182 pub fn with_strip_images(mut self, enabled: bool) -> Self {
137 183 self.strip_images = enabled;
138 184 self
139 185 }
140 186
187 + #[must_use]
141 188 pub fn with_strip_raw_html(mut self, enabled: bool) -> Self {
142 189 self.strip_raw_html = enabled;
143 190 self
144 191 }
145 192
193 + #[must_use]
146 194 pub fn with_dangerous_scheme_filter(mut self, enabled: bool) -> Self {
147 195 self.dangerous_scheme_filter = enabled;
148 196 self
149 197 }
150 198
199 + #[must_use]
151 200 pub fn with_sanitize(mut self, preset: SanitizePreset) -> Self {
152 201 self.sanitize = preset;
153 202 self
154 203 }
155 204
205 + /// Emit `id` on every heading, matching the anchors [`extract_toc`] emits,
206 + /// so a rendered table of contents navigates instead of going nowhere.
207 + ///
208 + /// Off by default, and deliberately opt-in: it is the only setting that
209 + /// lets an `id` attribute survive sanitization, and an attacker-chosen `id`
210 + /// on a page is a DOM-clobbering primitive (an element whose `id` shadows a
211 + /// global the page's own script reads). Generated anchors are slugified to
212 + /// alphanumerics, hyphens, and underscores, but raw-HTML headings in the
213 + /// source can also carry an `id` once this is on. Enable it for trusted
214 + /// content — docs, your own long-form — not for arbitrary UGC.
215 + ///
216 + /// [`extract_toc`]: crate::extract_toc
217 + #[must_use]
218 + pub fn with_heading_ids(mut self, enabled: bool) -> Self {
219 + self.heading_ids = enabled;
220 + self
221 + }
222 +
156 223 fn build_options(&self) -> Options {
157 224 let mut opts = Options::empty();
158 225 if self.tables {
@@ -179,7 +246,7 @@
179 246 return String::new();
180 247 }
181 248 let html_output = self.render_raw(input);
182 - self.sanitize.clean(&html_output)
249 + self.sanitize.clean_with(&html_output, self.heading_ids)
183 250 }
184 251
185 252 /// Render markdown to sanitized HTML with metadata.
@@ -229,7 +296,16 @@
229 296 });
230 297
231 298 let mut output = String::new();
232 - html::push_html(&mut output, filtered);
299 + if self.heading_ids {
300 + // Buffering is required: a heading's anchor depends on text that
301 + // only arrives after its Start event.
302 + html::push_html(
303 + &mut output,
304 + inject_heading_ids(filtered.collect()).into_iter(),
305 + );
306 + } else {
307 + html::push_html(&mut output, filtered);
308 + }
233 309 output
234 310 }
235 311 }
@@ -238,7 +314,7 @@
238 314 mod tests {
239 315 use super::*;
240 316
241 - // ===== has_dangerous_scheme =====
317 + // --- has_dangerous_scheme
242 318
243 319 #[test]
244 320 fn safe_schemes() {
@@ -269,7 +345,20 @@
269 345 assert!(!has_dangerous_scheme("path/to:file"));
270 346 }
271 347
272 - // ===== Permissive preset =====
348 + #[test]
349 + fn query_string_before_colon_is_safe() {
350 + // "x?y:z" — '?' before ':' means the part before ':' isn't a scheme.
351 + // Pins the `|| before_colon.contains('?')` arm of the disjunction.
352 + assert!(!has_dangerous_scheme("page?q=foo:bar"));
353 + }
354 +
355 + #[test]
356 + fn fragment_before_colon_is_safe() {
357 + // "x#y:z" — '#' before ':' likewise. Pins the `|| contains('#')` arm.
358 + assert!(!has_dangerous_scheme("page#sec:1"));
359 + }
360 +
361 + // --- permissive preset
273 362
274 363 #[test]
275 364 fn permissive_basic_markdown() {
@@ -291,9 +380,7 @@
291 380 fn permissive_smart_punctuation() {
292 381 let r = Renderer::permissive();
293 382 let html = r.render("It's a \"test\"");
294 - assert!(
295 - html.contains('\u{201c}') || html.contains('\u{201d}') || html.contains("\"")
296 - );
383 + assert!(html.contains('\u{201c}') || html.contains('\u{201d}') || html.contains('"'));
297 384 }
298 385
299 386 #[test]
@@ -315,7 +402,7 @@
315 402 assert_eq!(Renderer::permissive().render(""), "");
316 403 }
317 404
318 - // ===== Standard preset =====
405 + // --- standard preset
319 406
320 407 #[test]
321 408 fn standard_strips_images() {
@@ -332,7 +419,27 @@
332 419 assert!(html.contains("<table>"));
333 420 }
334 421
335 - // ===== Strict preset =====
422 + // --- strict preset
423 +
424 + #[test]
425 + fn with_strip_raw_html_toggle_is_observable() {
426 + // Pins the `if strip_raw_html` guard in render_raw: the same renderer
427 + // preset with the flag toggled must produce different output. `<u>` is
428 + // allowed by ammonia's permissive sanitizer, so the only thing removing
429 + // it is the pulldown-stage Event::Html filter.
430 + let kept = Renderer::permissive().render("hello <u>raw</u> world");
431 + let stripped = Renderer::permissive()
432 + .with_strip_raw_html(true)
433 + .render("hello <u>raw</u> world");
434 + assert!(
435 + kept.contains("<u>"),
436 + "<u> should survive permissive: {kept}"
437 + );
438 + assert!(
439 + !stripped.contains("<u>"),
440 + "<u> should be stripped: {stripped}"
441 + );
442 + }
336 443
337 444 #[test]
338 445 fn strict_strips_raw_html() {
@@ -475,7 +582,7 @@
475 582 assert_eq!(Renderer::strict().render(""), "");
476 583 }
477 584
478 - // ===== Sanitize-only preset =====
585 + // --- Sanitize-only preset
479 586
480 587 #[test]
481 588 fn sanitize_only_cleans_html() {
@@ -485,7 +592,7 @@
485 592 assert!(!html.contains("<script>"));
486 593 }
487 594
488 - // ===== Builder methods =====
595 + // --- builder methods
489 596
490 597 #[test]
491 598 fn builder_override() {
@@ -494,7 +601,7 @@
494 601 assert!(html.contains("<img"));
495 602 }
496 603
497 - // ===== render_with_meta =====
604 + // --- render_with_meta
498 605
499 606 #[test]
500 607 fn render_with_meta_includes_counts() {
@@ -508,4 +615,70 @@
508 615 fn result_has_rel(html: &str, rel_value: &str) -> bool {
509 616 html.contains(rel_value)
510 617 }
618 +
619 + // --- heading ids
620 +
621 + #[test]
622 + fn heading_ids_are_off_by_default() {
623 + assert!(
624 + Renderer::permissive()
625 + .render("## Section")
626 + .contains("<h2>Section</h2>")
627 + );
628 + assert!(!Renderer::permissive().render("## Section").contains("id="));
629 + }
630 +
631 + #[test]
632 + fn heading_ids_survive_sanitization_when_enabled() {
633 + // Ammonia strips `id` by default, so this asserts the sanitizer opt-in
634 + // is wired, not just the event injection.
635 + let html = Renderer::permissive()
636 + .with_heading_ids(true)
637 + .render("## Section Title");
638 + assert!(html.contains(r#"<h2 id="section-title">"#), "got: {html}");
639 + }
640 +
641 + #[test]
642 + fn every_toc_anchor_has_a_matching_heading_id() {
643 + // The bug this fixes: TOC emitted href="#anchor" and nothing on the
644 + // page carried that id. Pin the two together, repeats included.
645 + let md = "# Guide\n\n## Setup\n\n### Notes\n\n## Usage\n\n### Notes\n\n## render_raw";
646 + let html = Renderer::permissive().with_heading_ids(true).render(md);
647 + let toc = crate::extract_toc(md);
648 + assert_eq!(toc.len(), 6);
649 + for entry in &toc {
650 + assert!(
651 + html.contains(&format!(r#"id="{}""#, entry.anchor)),
652 + "TOC points at #{} but no heading carries it: {html}",
653 + entry.anchor
654 + );
655 + }
656 + }
657 +
658 + #[test]
659 + fn heading_ids_enabled_on_other_presets_too() {
660 + let html = Renderer::strict()
661 + .with_heading_ids(true)
662 + .render("## Section");
663 + assert!(html.contains(r#"id="section""#), "got: {html}");
664 + }
665 +
666 + #[test]
667 + fn heading_ids_do_not_open_other_attributes() {
668 + // The opt-in must widen the sanitizer for `id` on headings only.
669 + let html = Renderer::permissive()
670 + .with_heading_ids(true)
671 + .render(r#"<h2 id="ok" onclick="evil()" class="x">T</h2>"#);
672 + assert!(
673 + !html.contains("onclick"),
674 + "event handler must stay stripped: {html}"
675 + );
676 + let html = Renderer::permissive()
677 + .with_heading_ids(true)
678 + .render(r#"<p id="para">text</p>"#);
679 + assert!(
680 + !html.contains(r#"id="para""#),
681 + "id on non-headings stays stripped: {html}"
682 + );
683 + }
511 684 }
M src/sanitize.rs +145 -12
@@ -4,7 +4,7 @@
4 4 /// Default ammonia settings. Allows most safe HTML.
5 5 Permissive,
6 6 /// Default ammonia settings (same sanitization as Permissive; the difference
7 - /// is at the Renderer level — Standard strips images, Permissive doesn't).
7 + /// is at the Renderer level: Standard strips images, Permissive doesn't).
8 8 Standard,
9 9 /// Adds `rel="noopener noreferrer nofollow"` to all links.
10 10 Strict,
@@ -12,26 +12,59 @@
12 12 Minimal,
13 13 }
14 14
15 + /// The single in-repo authority for permissive (creator long-form) sanitization.
16 + ///
17 + /// `ammonia::clean` is exactly `Builder::default().clean()`, so the XSS guarantee
18 + /// for all creator markdown rested implicitly on ammonia's evolving defaults with
19 + /// nothing in-repo pinning it (UX-S3). Routing through a named builder gives one
20 + /// explicit construction point, and the regression tests in this module pin the
21 + /// guarantee — `<script>`/`<iframe>`/`<style>`/`on*` handlers/`javascript:` URLs
22 + /// must stay stripped — so a floating ammonia minor that weakened a default would
23 + /// fail CI here rather than silently opening an injection hole.
24 + pub(crate) fn permissive_builder() -> ammonia::Builder<'static> {
25 + ammonia::Builder::default()
26 + }
27 +
15 28 impl SanitizePreset {
16 - pub(crate) fn clean(&self, html: &str) -> String {
17 - match self {
18 - SanitizePreset::Permissive | SanitizePreset::Standard => ammonia::clean(html),
19 - SanitizePreset::Strict => ammonia::Builder::default()
20 - .link_rel(Some("noopener noreferrer nofollow"))
21 - .clean(html)
22 - .to_string(),
29 + pub(crate) fn clean(self, html: &str) -> String {
30 + self.clean_with(html, false)
31 + }
32 +
33 + /// Clean `html`, optionally letting `id` survive on headings.
34 + ///
35 + /// Ammonia's defaults strip `id` from everything, which is why TOC anchors
36 + /// pointed at nothing: the renderer could emit ids all it liked and
37 + /// sanitization removed them. `allow_heading_ids` is scoped as tightly as
38 + /// the feature permits — `id` only, `h1`-`h6` only, and only when the
39 + /// caller set [`Renderer::with_heading_ids`], which carries the reasoning
40 + /// about when that is safe.
41 + ///
42 + /// [`Renderer::with_heading_ids`]: crate::Renderer::with_heading_ids
43 + pub(crate) fn clean_with(self, html: &str, allow_heading_ids: bool) -> String {
44 + let mut builder = match self {
45 + SanitizePreset::Permissive | SanitizePreset::Standard => permissive_builder(),
46 + SanitizePreset::Strict => {
47 + let mut b = ammonia::Builder::default();
48 + b.link_rel(Some("noopener noreferrer nofollow"));
49 + b
50 + }
23 51 SanitizePreset::Minimal => {
24 52 let tags: std::collections::HashSet<&str> =
25 53 ["p", "em", "strong", "code", "br", "pre"]
26 54 .iter()
27 55 .copied()
28 56 .collect();
29 - ammonia::Builder::default()
30 - .tags(tags)
31 - .clean(html)
32 - .to_string()
57 + let mut b = ammonia::Builder::default();
58 + b.tags(tags);
59 + b
60 + }
61 + };
62 + if allow_heading_ids {
63 + for tag in ["h1", "h2", "h3", "h4", "h5", "h6"] {
64 + builder.add_tag_attributes(tag, ["id"]);
33 65 }
34 66 }
67 + builder.clean(html).to_string()
35 68 }
36 69 }
37 70
@@ -77,4 +110,104 @@
77 110 assert!(result.contains("<code>"));
78 111 assert!(result.contains("<pre>"));
79 112 }
113 +
114 + // UX-S3: pin the permissive XSS guarantee so a future ammonia default change
115 + // can't silently weaken it. These cover the high-value injection vectors that
116 + // all creator long-form content relies on being stripped.
117 +
118 + #[test]
119 + fn permissive_strips_iframe_and_srcdoc() {
120 + let html = r#"<p>x</p><iframe srcdoc="<script>alert(1)</script>"></iframe>"#;
121 + let result = SanitizePreset::Permissive.clean(html);
122 + assert!(
123 + !result.contains("<iframe"),
124 + "iframe must be stripped: {result}"
125 + );
126 + assert!(
127 + !result.contains("srcdoc"),
128 + "srcdoc must be stripped: {result}"
129 + );
130 + assert!(
131 + !result.contains("<script"),
132 + "script must be stripped: {result}"
133 + );
134 + }
135 +
136 + #[test]
137 + fn permissive_strips_style_element() {
138 + let html = "<style>body{background:url(javascript:alert(1))}</style><p>x</p>";
139 + let result = SanitizePreset::Permissive.clean(html);
140 + assert!(
141 + !result.contains("<style"),
142 + "style element must be stripped: {result}"
143 + );
144 + }
145 +
146 + #[test]
147 + fn permissive_strips_event_handlers() {
148 + let html =
149 + r#"<p onclick="alert(1)" onmouseover="alert(2)">x</p><img src="x" onerror="alert(3)">"#;
150 + let result = SanitizePreset::Permissive.clean(html);
151 + assert!(
152 + !result.contains("onclick"),
153 + "onclick must be stripped: {result}"
154 + );
155 + assert!(
156 + !result.contains("onmouseover"),
157 + "onmouseover must be stripped: {result}"
158 + );
159 + assert!(
160 + !result.contains("onerror"),
161 + "onerror must be stripped: {result}"
162 + );
163 + }
164 +
165 + #[test]
166 + fn permissive_strips_javascript_url() {
167 + let html = r#"<a href="javascript:alert(1)">click</a>"#;
168 + let result = SanitizePreset::Permissive.clean(html);
169 + assert!(
170 + !result.contains("javascript:"),
171 + "javascript: URL must be stripped: {result}"
172 + );
173 + }
174 +
175 + #[test]
176 + fn permissive_strips_object_and_embed() {
177 + let html = r#"<object data="evil.swf"></object><embed src="evil.swf">"#;
178 + let result = SanitizePreset::Permissive.clean(html);
179 + assert!(
180 + !result.contains("<object"),
181 + "object must be stripped: {result}"
182 + );
183 + assert!(
184 + !result.contains("<embed"),
185 + "embed must be stripped: {result}"
186 + );
187 + }
188 +
189 + #[test]
190 + fn permissive_strips_svg_and_inline_script() {
191 + // SVG is a script-carrier (`<svg><script>` / animated `<set>`); ammonia's
192 + // default allowlist excludes it. Pin that so a default change can't admit
193 + // an SVG XSS vector into creator long-form content.
194 + let html = r"<p>x</p><svg><script>alert(1)</script></svg>";
195 + let result = SanitizePreset::Permissive.clean(html);
196 + assert!(!result.contains("<svg"), "svg must be stripped: {result}");
197 + assert!(
198 + !result.contains("<script"),
199 + "inline svg script must be stripped: {result}"
200 + );
201 + }
202 +
203 + #[test]
204 + fn permissive_strips_data_uri_links() {
205 + // `data:text/html` navigations execute script in the document origin.
206 + let html = r#"<a href="data:text/html,<script>alert(1)</script>">x</a>"#;
207 + let result = SanitizePreset::Permissive.clean(html);
208 + assert!(
209 + !result.contains("data:text/html"),
210 + "data: URL must be stripped: {result}"
211 + );
212 + }
80 213 }
M src/toc.rs +73 -8
@@ -1,7 +1,40 @@
1 + use std::collections::HashMap;
2 + use std::collections::hash_map::Entry;
3 + use std::fmt::Write as _;
4 +
1 5 use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
2 6
3 7 use crate::escape::html_escape;
4 8
9 + /// Assigns heading anchors over one document, disambiguating repeats the way
10 + /// GitHub does (`overview`, `overview-1`, `overview-2`).
11 + ///
12 + /// The table of contents and the rendered headings must agree character for
13 + /// character or every TOC link is dead, so both drive this same generator over
14 + /// the same heading sequence rather than each slugifying on its own.
15 + #[derive(Default)]
16 + pub(crate) struct AnchorGen {
17 + seen: HashMap<String, usize>,
18 + }
19 +
20 + impl AnchorGen {
21 + /// The anchor for the next heading in document order.
22 + pub(crate) fn next(&mut self, heading_text: &str) -> String {
23 + let base = make_anchor(heading_text);
24 + match self.seen.entry(base.clone()) {
25 + Entry::Vacant(slot) => {
26 + slot.insert(0);
27 + base
28 + }
29 + Entry::Occupied(mut slot) => {
30 + let n = slot.get() + 1;
31 + slot.insert(n);
32 + format!("{base}-{n}")
33 + }
34 + }
35 + }
36 + }
37 +
5 38 /// A single entry in a table of contents.
6 39 #[derive(Debug, Clone, PartialEq, Eq)]
7 40 pub struct TocEntry {
@@ -18,6 +51,7 @@
18 51
19 52 let parser = Parser::new_ext(markdown, options);
20 53 let mut entries = Vec::new();
54 + let mut anchors = AnchorGen::default();
21 55 let mut in_heading: Option<u8> = None;
22 56 let mut heading_text = String::new();
23 57
@@ -35,7 +69,7 @@
35 69 }
36 70 Event::End(TagEnd::Heading(_)) => {
37 71 if let Some(level) = in_heading.take() {
38 - let anchor = make_anchor(&heading_text);
72 + let anchor = anchors.next(&heading_text);
39 73 entries.push(TocEntry {
40 74 level,
41 75 text: heading_text.clone(),
@@ -56,24 +90,29 @@
56 90 }
57 91 let mut html = String::from("<nav class=\"toc\"><ul>\n");
58 92 for entry in entries {
59 - html.push_str(&format!(
60 - "<li class=\"toc-h{}\"><a href=\"#{}\">{}</a></li>\n",
93 + let _ = writeln!(
94 + html,
95 + "<li class=\"toc-h{}\"><a href=\"#{}\">{}</a></li>",
61 96 entry.level,
62 97 html_escape(&entry.anchor),
63 98 html_escape(&entry.text),
64 - ));
99 + );
65 100 }
66 101 html.push_str("</ul></nav>");
67 102 html
68 103 }
69 104
70 - /// GitHub-style anchor generation: lowercase, spaces to hyphens, strip
71 - /// non-alphanumeric (except hyphens).
105 + /// GitHub-style anchor generation: lowercase, whitespace to hyphens, drop
106 + /// everything that is not alphanumeric, a hyphen, or an underscore.
107 + ///
108 + /// Underscores survive because GitHub keeps them, so `render_raw` and
109 + /// `#render_raw` address the same heading on both. Repeat headings are
110 + /// disambiguated by [`AnchorGen`], not here — this function is pure.
72 111 fn make_anchor(text: &str) -> String {
73 112 text.to_lowercase()
74 113 .chars()
75 - .map(|c| if c == ' ' { '-' } else { c })
76 - .filter(|c| c.is_alphanumeric() || *c == '-')
114 + .map(|c| if c.is_whitespace() { '-' } else { c })
115 + .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
77 116 .collect()
78 117 }
79 118
@@ -103,6 +142,32 @@
103 142 assert_eq!(make_anchor("Version 2.0"), "version-20");
104 143 }
105 144
145 + #[test]
146 + fn anchor_keeps_underscores() {
147 + // GitHub keeps `_`, so `render_raw` must not slug to `renderraw` or a
148 + // hand-written `#render_raw` link lands nowhere.
149 + assert_eq!(make_anchor("render_raw"), "render_raw");
150 + assert_eq!(make_anchor("Doc Loader_v2"), "doc-loader_v2");
151 + }
152 +
153 + #[test]
154 + fn repeat_headings_get_disambiguating_suffixes() {
155 + let mut anchors = AnchorGen::default();
156 + assert_eq!(anchors.next("Overview"), "overview");
157 + assert_eq!(anchors.next("Overview"), "overview-1");
158 + assert_eq!(anchors.next("Overview"), "overview-2");
159 + assert_eq!(anchors.next("Other"), "other");
160 + assert_eq!(anchors.next("Overview"), "overview-3");
161 + }
162 +
163 + #[test]
164 + fn toc_anchors_are_unique_across_repeat_headings() {
165 + let md = "## Setup\n\n### Notes\n\n## Usage\n\n### Notes";
166 + let toc = extract_toc(md);
167 + let anchors: Vec<&str> = toc.iter().map(|e| e.anchor.as_str()).collect();
168 + assert_eq!(anchors, ["setup", "notes", "usage", "notes-1"]);
169 + }
170 +
106 171 #[test]
107 172 fn extract_empty() {
108 173 let toc = extract_toc("No headings here, just text.");
D Cargo.lock -500
@@ -1,1221 +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 = "ammonia"
16 - version = "4.1.2"
17 - source = "registry+https://github.com/rust-lang/crates.io-index"
18 - checksum = "17e913097e1a2124b46746c980134e8c954bc17a6a59bb3fde96f088d126dde6"
19 - dependencies = [
20 - "cssparser",
21 - "html5ever",
22 - "maplit",
23 - "tendril",
24 - "url",
25 - ]
26 -
27 - [[package]]
28 - name = "anyhow"
29 - version = "1.0.102"
30 - source = "registry+https://github.com/rust-lang/crates.io-index"
31 - checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
32 -
33 - [[package]]
34 - name = "bitflags"
35 - version = "2.11.0"
36 - source = "registry+https://github.com/rust-lang/crates.io-index"
37 - checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
38 -
39 - [[package]]
40 - name = "bumpalo"
41 - version = "3.20.2"
42 - source = "registry+https://github.com/rust-lang/crates.io-index"
43 - checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
44 -
45 - [[package]]
46 - name = "cfg-if"
47 - version = "1.0.4"
48 - source = "registry+https://github.com/rust-lang/crates.io-index"
49 - checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
50 -
51 - [[package]]
52 - name = "cssparser"
53 - version = "0.35.0"
54 - source = "registry+https://github.com/rust-lang/crates.io-index"
55 - checksum = "4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa"
56 - dependencies = [
57 - "cssparser-macros",
58 - "dtoa-short",
59 - "itoa",
60 - "phf",
61 - "smallvec",
62 - ]
63 -
64 - [[package]]
65 - name = "cssparser-macros"
66 - version = "0.6.1"
67 - source = "registry+https://github.com/rust-lang/crates.io-index"
68 - checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
69 - dependencies = [
70 - "quote",
71 - "syn",
72 - ]
73 -
74 - [[package]]
75 - name = "displaydoc"
76 - version = "0.2.5"
77 - source = "registry+https://github.com/rust-lang/crates.io-index"
78 - checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
79 - dependencies = [
80 - "proc-macro2",
81 - "quote",
82 - "syn",
83 - ]
84 -
85 - [[package]]
86 - name = "docengine"
87 - version = "0.3.0"
88 - dependencies = [
89 - "ammonia",
90 - "pulldown-cmark",
91 - "regex",
92 - "regex-lite",
93 - "serde",
94 - "toml",
95 - "tracing",
96 - "uuid",
97 - ]
98 -
99 - [[package]]
100 - name = "dtoa"
101 - version = "1.0.11"
102 - source = "registry+https://github.com/rust-lang/crates.io-index"
103 - checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
104 -
105 - [[package]]
106 - name = "dtoa-short"
107 - version = "0.3.5"
108 - source = "registry+https://github.com/rust-lang/crates.io-index"
109 - checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
110 - dependencies = [
111 - "dtoa",
112 - ]
113 -
114 - [[package]]
115 - name = "equivalent"
116 - version = "1.0.2"
117 - source = "registry+https://github.com/rust-lang/crates.io-index"
118 - checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
119 -
120 - [[package]]
121 - name = "foldhash"
122 - version = "0.1.5"
123 - source = "registry+https://github.com/rust-lang/crates.io-index"
124 - checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
125 -
126 - [[package]]
127 - name = "form_urlencoded"
128 - version = "1.2.2"
129 - source = "registry+https://github.com/rust-lang/crates.io-index"
130 - checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
131 - dependencies = [
132 - "percent-encoding",
133 - ]
134 -
135 - [[package]]
136 - name = "futf"
137 - version = "0.1.5"
138 - source = "registry+https://github.com/rust-lang/crates.io-index"
139 - checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843"
140 - dependencies = [
141 - "mac",
142 - "new_debug_unreachable",
143 - ]
144 -
145 - [[package]]
146 - name = "getopts"
147 - version = "0.2.24"
148 - source = "registry+https://github.com/rust-lang/crates.io-index"
149 - checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
150 - dependencies = [
151 - "unicode-width",
152 - ]
153 -
154 - [[package]]
155 - name = "getrandom"
156 - version = "0.4.2"
157 - source = "registry+https://github.com/rust-lang/crates.io-index"
158 - checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
159 - dependencies = [
160 - "cfg-if",
161 - "libc",
162 - "r-efi",
163 - "wasip2",
164 - "wasip3",
165 - ]
166 -
167 - [[package]]
168 - name = "hashbrown"
169 - version = "0.15.5"
170 - source = "registry+https://github.com/rust-lang/crates.io-index"
171 - checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
172 - dependencies = [
173 - "foldhash",
174 - ]
175 -
176 - [[package]]
177 - name = "hashbrown"
178 - version = "0.16.1"
179 - source = "registry+https://github.com/rust-lang/crates.io-index"
180 - checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
181 -
182 - [[package]]
183 - name = "heck"
184 - version = "0.5.0"
185 - source = "registry+https://github.com/rust-lang/crates.io-index"
186 - checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
187 -
188 - [[package]]
189 - name = "html5ever"
190 - version = "0.35.0"
191 - source = "registry+https://github.com/rust-lang/crates.io-index"
192 - checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4"
193 - dependencies = [
194 - "log",
195 - "markup5ever",
196 - "match_token",
197 - ]
198 -
199 - [[package]]
200 - name = "icu_collections"
201 - version = "2.1.1"
202 - source = "registry+https://github.com/rust-lang/crates.io-index"
203 - checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43"
204 - dependencies = [
205 - "displaydoc",
206 - "potential_utf",
207 - "yoke",
208 - "zerofrom",
209 - "zerovec",
210 - ]
211 -
212 - [[package]]
213 - name = "icu_locale_core"
214 - version = "2.1.1"
215 - source = "registry+https://github.com/rust-lang/crates.io-index"
216 - checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6"
217 - dependencies = [
218 - "displaydoc",
219 - "litemap",
220 - "tinystr",
221 - "writeable",
222 - "zerovec",
223 - ]
224 -
225 - [[package]]
226 - name = "icu_normalizer"
227 - version = "2.1.1"
228 - source = "registry+https://github.com/rust-lang/crates.io-index"
229 - checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599"
230 - dependencies = [
231 - "icu_collections",
232 - "icu_normalizer_data",
233 - "icu_properties",
234 - "icu_provider",
235 - "smallvec",
236 - "zerovec",
237 - ]
238 -
239 - [[package]]
240 - name = "icu_normalizer_data"
241 - version = "2.1.1"
242 - source = "registry+https://github.com/rust-lang/crates.io-index"
243 - checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a"
244 -
245 - [[package]]
246 - name = "icu_properties"
247 - version = "2.1.2"
248 - source = "registry+https://github.com/rust-lang/crates.io-index"
249 - checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec"
250 - dependencies = [
251 - "icu_collections",
252 - "icu_locale_core",
253 - "icu_properties_data",
254 - "icu_provider",
255 - "zerotrie",
256 - "zerovec",
257 - ]
258 -
259 - [[package]]
260 - name = "icu_properties_data"
261 - version = "2.1.2"
262 - source = "registry+https://github.com/rust-lang/crates.io-index"
263 - checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af"
264 -
265 - [[package]]
266 - name = "icu_provider"
267 - version = "2.1.1"
268 - source = "registry+https://github.com/rust-lang/crates.io-index"
269 - checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614"
270 - dependencies = [
271 - "displaydoc",
272 - "icu_locale_core",
273 - "writeable",
274 - "yoke",
275 - "zerofrom",
276 - "zerotrie",
277 - "zerovec",
278 - ]
279 -
280 - [[package]]
281 - name = "id-arena"
282 - version = "2.3.0"
283 - source = "registry+https://github.com/rust-lang/crates.io-index"
284 - checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
285 -
286 - [[package]]
287 - name = "idna"
288 - version = "1.1.0"
289 - source = "registry+https://github.com/rust-lang/crates.io-index"
290 - checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
291 - dependencies = [
292 - "idna_adapter",
293 - "smallvec",
294 - "utf8_iter",
295 - ]
296 -
297 - [[package]]
298 - name = "idna_adapter"
299 - version = "1.2.1"
300 - source = "registry+https://github.com/rust-lang/crates.io-index"
301 - checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344"
302 - dependencies = [
303 - "icu_normalizer",
304 - "icu_properties",
305 - ]
306 -
307 - [[package]]
308 - name = "indexmap"
309 - version = "2.13.0"
310 - source = "registry+https://github.com/rust-lang/crates.io-index"
311 - checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
312 - dependencies = [
313 - "equivalent",
314 - "hashbrown 0.16.1",
315 - "serde",
316 - "serde_core",
317 - ]
318 -
319 - [[package]]
320 - name = "itoa"
321 - version = "1.0.18"
322 - source = "registry+https://github.com/rust-lang/crates.io-index"
323 - checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
324 -
325 - [[package]]
326 - name = "js-sys"
327 - version = "0.3.91"
328 - source = "registry+https://github.com/rust-lang/crates.io-index"
329 - checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c"
330 - dependencies = [
331 - "once_cell",
332 - "wasm-bindgen",
333 - ]
334 -
335 - [[package]]
336 - name = "leb128fmt"
337 - version = "0.1.0"
338 - source = "registry+https://github.com/rust-lang/crates.io-index"
339 - checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
340 -
341 - [[package]]
342 - name = "libc"
343 - version = "0.2.183"
344 - source = "registry+https://github.com/rust-lang/crates.io-index"
345 - checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
346 -
347 - [[package]]
348 - name = "litemap"
349 - version = "0.8.1"
350 - source = "registry+https://github.com/rust-lang/crates.io-index"
351 - checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
352 -
353 - [[package]]
354 - name = "lock_api"
355 - version = "0.4.14"
356 - source = "registry+https://github.com/rust-lang/crates.io-index"
357 - checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
358 - dependencies = [
359 - "scopeguard",
360 - ]
361 -
362 - [[package]]
363 - name = "log"
364 - version = "0.4.29"
365 - source = "registry+https://github.com/rust-lang/crates.io-index"
366 - checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
367 -
368 - [[package]]
369 - name = "mac"
370 - version = "0.1.1"
371 - source = "registry+https://github.com/rust-lang/crates.io-index"
372 - checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
373 -
374 - [[package]]
375 - name = "maplit"
376 - version = "1.0.2"
377 - source = "registry+https://github.com/rust-lang/crates.io-index"
378 - checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
379 -
380 - [[package]]
381 - name = "markup5ever"
382 - version = "0.35.0"
383 - source = "registry+https://github.com/rust-lang/crates.io-index"
384 - checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3"
385 - dependencies = [
386 - "log",
387 - "tendril",
388 - "web_atoms",
389 - ]
390 -
391 - [[package]]
392 - name = "match_token"
393 - version = "0.35.0"
394 - source = "registry+https://github.com/rust-lang/crates.io-index"
395 - checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf"
396 - dependencies = [
397 - "proc-macro2",
398 - "quote",
399 - "syn",
400 - ]
401 -
402 - [[package]]
403 - name = "memchr"
404 - version = "2.8.0"
405 - source = "registry+https://github.com/rust-lang/crates.io-index"
406 - checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
407 -
408 - [[package]]
409 - name = "new_debug_unreachable"
410 - version = "1.0.6"
411 - source = "registry+https://github.com/rust-lang/crates.io-index"
412 - checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
413 -
414 - [[package]]
415 - name = "once_cell"
416 - version = "1.21.4"
417 - source = "registry+https://github.com/rust-lang/crates.io-index"
418 - checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
419 -
420 - [[package]]
421 - name = "parking_lot"
422 - version = "0.12.5"
423 - source = "registry+https://github.com/rust-lang/crates.io-index"
424 - checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
425 - dependencies = [
426 - "lock_api",
427 - "parking_lot_core",
428 - ]
429 -
430 - [[package]]
431 - name = "parking_lot_core"
432 - version = "0.9.12"
433 - source = "registry+https://github.com/rust-lang/crates.io-index"
434 - checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
435 - dependencies = [
436 - "cfg-if",
437 - "libc",
438 - "redox_syscall",
439 - "smallvec",
440 - "windows-link",
441 - ]
442 -
443 - [[package]]
444 - name = "percent-encoding"
445 - version = "2.3.2"
446 - source = "registry+https://github.com/rust-lang/crates.io-index"
447 - checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
448 -
449 - [[package]]
450 - name = "phf"
451 - version = "0.11.3"
452 - source = "registry+https://github.com/rust-lang/crates.io-index"
453 - checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
454 - dependencies = [
455 - "phf_macros",
456 - "phf_shared",
457 - ]
458 -
459 - [[package]]
460 - name = "phf_codegen"
461 - version = "0.11.3"
462 - source = "registry+https://github.com/rust-lang/crates.io-index"
463 - checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
464 - dependencies = [
465 - "phf_generator",
466 - "phf_shared",
467 - ]
468 -
469 - [[package]]
470 - name = "phf_generator"
471 - version = "0.11.3"
472 - source = "registry+https://github.com/rust-lang/crates.io-index"
473 - checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
474 - dependencies = [
475 - "phf_shared",
476 - "rand",
477 - ]
478 -
479 - [[package]]
480 - name = "phf_macros"
481 - version = "0.11.3"
482 - source = "registry+https://github.com/rust-lang/crates.io-index"
483 - checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
484 - dependencies = [
485 - "phf_generator",
486 - "phf_shared",
487 - "proc-macro2",
488 - "quote",
489 - "syn",
490 - ]
491 -
492 - [[package]]
493 - name = "phf_shared"
494 - version = "0.11.3"
495 - source = "registry+https://github.com/rust-lang/crates.io-index"
496 - checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
497 - dependencies = [
498 - "siphasher",
499 - ]
500 -
Lines truncated
A LICENSE +21
@@ -1,0 +1,21 @@
1 + MIT License
2 +
3 + Copyright (c) 2026 Make Creative, LLC
4 +
5 + Permission is hereby granted, free of charge, to any person obtaining a copy
6 + of this software and associated documentation files (the "Software"), to deal
7 + in the Software without restriction, including without limitation the rights
8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 + copies of the Software, and to permit persons to whom the Software is
10 + furnished to do so, subject to the following conditions:
11 +
12 + The above copyright notice and this permission notice shall be included in all
13 + copies or substantial portions of the Software.
14 +
15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 + SOFTWARE.