max / docengine
20 files changed,
+1593 insertions,
-763 deletions
| @@ -1,2 +1,4 @@ | |||
| 1 | - | /target/ | |
| 1 | + | /target | |
| 2 | + | **/target | |
| 3 | + | Cargo.lock | |
| 2 | 4 | .DS_Store |
| @@ -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" |
| @@ -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 | } |
| @@ -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: ®ex_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 | } |
| @@ -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: ®ex_lite::Captures| { | |
| 399 | + | let name = &caps[1]; | |
| 400 | + | match examples_path { | |
| 401 | + | Some(dir) => { | |
| 402 | + | let file = dir.join(format!("{name}.html")); | |
| 403 | + | match std::fs::read_to_string(&file) { | |
| 404 | + | Ok(content) => format!( | |
| 405 | + | "<div class=\"doc-ui-frame\">{content}</div>" | |
| 406 | + | ), | |
| 407 | + | Err(_) => { | |
| 408 | + | tracing::warn!(example = name, "UI example file not found"); | |
| 409 | + | format!( | |
| 410 | + | "<div class=\"doc-ui-frame doc-ui-missing\">[UI example: {name}]</div>" | |
| 411 | + | ) | |
| 412 | + | } | |
| 413 | + | } | |
| 414 | + | } | |
| 415 | + | None => format!( | |
| 416 | + | "<div class=\"doc-ui-frame doc-ui-missing\">[UI example: {name}]</div>" | |
| 417 | + | ), | |
| 418 | + | } | |
| 419 | + | }).into_owned() | |
| 420 | + | } | |
| 421 | + | ||
| 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("'", "'") | |
| 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: ®ex::Captures| { | |
| 513 | + | .replace_all(markdown, |caps: ®ex_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] |
| @@ -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(""); | |
| 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> "); | |
| 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 | + | } |
| @@ -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 `` to | |
| 4 | + | //! 1. **Pre-process markdown**: rewrite `` to | |
| 5 | 5 | //! ``. | |
| 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 = ""; | |
| 210 | 215 | let result = rewrite_media_paths(md, "https://cdn.makenot.work", "u1"); | |
| 211 | - | assert_eq!( | |
| 212 | - | result, | |
| 213 | - | "" | |
| 214 | - | ); | |
| 216 | + | assert_eq!(result, ""); | |
| 215 | 217 | } | |
| 216 | 218 | ||
| 217 | 219 | #[test] |
| @@ -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 | } |
| @@ -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 | }); |
| @@ -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 | } |
| @@ -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 | } |
| @@ -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."); |
| @@ -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
| @@ -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. |