Skip to main content

max / makenotwork

23.7 KB · 648 lines History Blame Raw
1 //! Post-process rendered HTML to convert blockquote-based directives into
2 //! styled elements.
3 //!
4 //! **Alerts:** `> [!NOTE]`, `> [!TIP]`, `> [!WARNING]`, `> [!CAUTION]`,
5 //! `> [!IMPORTANT]`, and any custom `> [!TYPE]` marker become styled
6 //! `<div class="alert alert-{type}">` callout elements.
7 //!
8 //! **Code tabs:** `> [!TABS]` followed by fenced code blocks become a tabbed
9 //! interface with language-labelled tabs.
10
11 use std::sync::LazyLock;
12
13 /// Matches any `[!TYPE]` alert marker inside a blockquote paragraph.
14 /// Accepts any uppercase word (letters, digits, hyphens, underscores).
15 static ALERT_RE: LazyLock<regex_lite::Regex> = LazyLock::new(|| {
16 regex_lite::Regex::new(
17 r"<blockquote>\s*<p>\[!([A-Z][A-Z0-9_-]*)\](?:<br\s*/?>)?\s*",
18 )
19 .expect("valid alert regex")
20 });
21
22 /// Process all directives: UI examples first, then code tabs, then alerts.
23 pub fn post_process_directives(html: &str) -> String {
24 let with_ui = process_ui_examples(html);
25 let with_tabs = process_tabs(&with_ui);
26 process_alerts(&with_tabs)
27 }
28
29 /// Regex matching `[!UI] example-name` inside a blockquote paragraph.
30 /// Captures the example name (alphanumeric, hyphens, underscores).
31 static UI_RE: LazyLock<regex_lite::Regex> = LazyLock::new(|| {
32 regex_lite::Regex::new(
33 r"<blockquote>\s*<p>\[!UI\]\s+([a-z0-9_-]+)(?:<br\s*/?>)?\s*",
34 )
35 .expect("valid UI regex")
36 });
37
38 /// Replace `[!UI] name` blockquotes with `<figure>` placeholder elements.
39 ///
40 /// The placeholder carries `data-ui="name"` for the doc loader to resolve.
41 /// Any text after the name line becomes a `<figcaption>`.
42 fn process_ui_examples(html: &str) -> String {
43 if !html.contains("[!UI]") {
44 return html.to_string();
45 }
46
47 let mut result = String::with_capacity(html.len());
48 let mut remaining = html;
49
50 while let Some(bq_pos) = remaining.find("<blockquote>") {
51 let close_pos = match remaining[bq_pos..].find("</blockquote>") {
52 Some(p) => bq_pos + p,
53 None => break,
54 };
55
56 // Check if this blockquote contains [!UI] (check only up to its closing tag).
57 let bq_slice = &remaining[bq_pos..close_pos + "</blockquote>".len()];
58 let is_ui = UI_RE.is_match(bq_slice);
59
60 if !is_ui {
61 // Not a UI blockquote — copy through the entire blockquote and continue.
62 let end = close_pos + "</blockquote>".len();
63 result.push_str(&remaining[..end]);
64 remaining = &remaining[end..];
65 continue;
66 }
67
68 // Copy everything before this blockquote.
69 result.push_str(&remaining[..bq_pos]);
70
71 // Extract the example name.
72 if let Some(caps) = UI_RE.captures(bq_slice) {
73 let name = &caps[1];
74 let marker_end = caps[0].len();
75
76 // Everything after the marker line is the caption.
77 let after_marker = &remaining[(bq_pos + marker_end)..close_pos];
78 let caption = strip_html_tags_simple(after_marker).trim().to_string();
79
80 result.push_str(&format!(
81 "<figure class=\"doc-ui\" data-ui=\"{name}\">"
82 ));
83 result.push_str(&format!(
84 "<div class=\"doc-ui-frame\" data-ui=\"{name}\"></div>"
85 ));
86 if !caption.is_empty() {
87 result.push_str(&format!("<figcaption>{caption}</figcaption>"));
88 }
89 result.push_str("</figure>");
90 }
91
92 remaining = &remaining[close_pos + "</blockquote>".len()..];
93 }
94
95 result.push_str(remaining);
96 result
97 }
98
99 /// Minimal tag stripper for extracting caption text from inner HTML.
100 fn strip_html_tags_simple(html: &str) -> String {
101 let mut out = String::with_capacity(html.len());
102 let mut in_tag = false;
103 for ch in html.chars() {
104 match ch {
105 '<' => in_tag = true,
106 '>' => { in_tag = false; }
107 _ if !in_tag => out.push(ch),
108 _ => {}
109 }
110 }
111 out
112 }
113
114 /// Replace alert blockquotes with styled `<div class="alert ...">` elements.
115 fn process_alerts(html: &str) -> String {
116 // First pass: replace opening markers.
117 let opened = ALERT_RE.replace_all(html, |caps: &regex_lite::Captures| {
118 let kind = &caps[1];
119 // Skip TABS and UI — already handled by their own processors.
120 if kind == "TABS" || kind == "UI" {
121 return caps[0].to_string();
122 }
123 let label = title_case(kind);
124 format!(
125 "<div class=\"alert alert-{kind}\"><p class=\"alert-title\">{label}</p><p>",
126 kind = kind.to_ascii_lowercase(),
127 label = label,
128 )
129 });
130
131 // Second pass: close any opened alerts.
132 let alert_count = ALERT_RE
133 .captures_iter(html)
134 .filter(|c| &c[1] != "TABS" && &c[1] != "UI")
135 .count();
136 if alert_count == 0 {
137 return opened.into_owned();
138 }
139
140 let mut result = String::with_capacity(opened.len());
141 let mut remaining = opened.as_ref();
142 let mut replaced = 0;
143
144 while replaced < alert_count {
145 if let Some(pos) = remaining.find("</blockquote>") {
146 result.push_str(&remaining[..pos]);
147 result.push_str("</div>");
148 remaining = &remaining[(pos + "</blockquote>".len())..];
149 replaced += 1;
150 } else {
151 break;
152 }
153 }
154 result.push_str(remaining);
155 result
156 }
157
158 /// Process `[!TABS]` blockquotes into tabbed code-block interfaces.
159 fn process_tabs(html: &str) -> String {
160 if !html.contains("[!TABS]") {
161 return html.to_string();
162 }
163
164 let mut result = String::with_capacity(html.len());
165 let mut remaining = html;
166
167 while let Some(bq_pos) = remaining.find("<blockquote>") {
168 let after_bq_start = bq_pos + "<blockquote>".len();
169
170 // Find the closing </blockquote> for this blockquote.
171 let close_pos = match remaining[bq_pos..].find("</blockquote>") {
172 Some(p) => bq_pos + p,
173 None => break,
174 };
175
176 let inner = &remaining[after_bq_start..close_pos];
177
178 // Check if the first <p> in the blockquote contains [!TABS].
179 let is_tabs = {
180 let trimmed = inner.trim_start();
181 trimmed.starts_with("<p>") && {
182 let first_p_end = trimmed.find("</p>").unwrap_or(trimmed.len());
183 trimmed[..first_p_end].contains("[!TABS]")
184 }
185 };
186
187 if !is_tabs {
188 // Not a TABS blockquote — copy through the opening tag and continue.
189 result.push_str(&remaining[..after_bq_start]);
190 remaining = &remaining[after_bq_start..];
191 continue;
192 }
193
194 // Copy everything before this blockquote.
195 result.push_str(&remaining[..bq_pos]);
196
197 // Extract code blocks from the inner HTML.
198 let tabs = extract_code_blocks(inner);
199
200 if tabs.is_empty() {
201 // No code blocks found — wrap content in a plain div.
202 result.push_str("<div class=\"code-tabs\">");
203 result.push_str(inner);
204 result.push_str("</div>");
205 } else {
206 result.push_str(&build_tabs_html(&tabs));
207 }
208
209 remaining = &remaining[close_pos + "</blockquote>".len()..];
210 }
211
212 result.push_str(remaining);
213 result
214 }
215
216 /// Extract `(language, full_html_block)` pairs from HTML containing
217 /// `<pre><code>` elements.
218 fn extract_code_blocks(html: &str) -> Vec<(String, String)> {
219 let mut blocks = Vec::new();
220 let mut search_from = 0;
221 let end_marker = "</code></pre>";
222
223 while let Some(pre_pos) = html[search_from..].find("<pre><code") {
224 let abs_pos = search_from + pre_pos;
225
226 let end_pos = match html[abs_pos..].find(end_marker) {
227 Some(p) => abs_pos + p + end_marker.len(),
228 None => break,
229 };
230
231 let full_block = &html[abs_pos..end_pos];
232
233 // Extract language from class="language-X".
234 let lang = if let Some(class_start) = full_block.find("class=\"language-") {
235 let after = &full_block[class_start + "class=\"language-".len()..];
236 after.split('"').next().unwrap_or("code").to_string()
237 } else {
238 "code".to_string()
239 };
240
241 blocks.push((lang, full_block.to_string()));
242 search_from = end_pos;
243 }
244
245 blocks
246 }
247
248 /// Build tabbed HTML from extracted code blocks.
249 fn build_tabs_html(tabs: &[(String, String)]) -> String {
250 let mut html = String::from("<div class=\"code-tabs\">\n<div class=\"code-tabs-bar\">");
251
252 for (i, (lang, _)) in tabs.iter().enumerate() {
253 let active = if i == 0 { " active" } else { "" };
254 let label = code_language_label(lang);
255 html.push_str(&format!(
256 "<button class=\"code-tab{active}\" data-tab-index=\"{i}\">{label}</button>"
257 ));
258 }
259
260 html.push_str("</div>\n");
261
262 for (i, (_, block)) in tabs.iter().enumerate() {
263 let active = if i == 0 { " active" } else { "" };
264 html.push_str(&format!(
265 "<div class=\"code-tab-panel{active}\" data-tab-index=\"{i}\">{block}</div>\n"
266 ));
267 }
268
269 html.push_str("</div>");
270 html
271 }
272
273 /// Human-readable label for a code language identifier.
274 fn code_language_label(lang: &str) -> String {
275 match lang {
276 "js" | "javascript" => "JavaScript".into(),
277 "ts" | "typescript" => "TypeScript".into(),
278 "sh" | "bash" | "zsh" | "shell" => "Shell".into(),
279 "json" => "JSON".into(),
280 "html" => "HTML".into(),
281 "css" => "CSS".into(),
282 "sql" => "SQL".into(),
283 "toml" => "TOML".into(),
284 "yaml" | "yml" => "YAML".into(),
285 "xml" => "XML".into(),
286 other => title_case(other),
287 }
288 }
289
290 fn title_case(s: &str) -> String {
291 let mut chars = s.chars();
292 match chars.next() {
293 Some(c) => {
294 let mut out = c.to_uppercase().to_string();
295 out.extend(chars.map(|c| c.to_ascii_lowercase()));
296 out
297 }
298 None => String::new(),
299 }
300 }
301
302 #[cfg(test)]
303 mod tests {
304 use super::*;
305
306 // ===== Alert directives =====
307
308 #[test]
309 fn note_alert() {
310 let html = "<blockquote>\n<p>[!NOTE]<br>\nThis is a note.</p>\n</blockquote>";
311 let result = post_process_directives(html);
312 assert!(result.contains("alert alert-note"));
313 assert!(result.contains("<p class=\"alert-title\">Note</p>"));
314 assert!(result.contains("This is a note."));
315 assert!(!result.contains("<blockquote>"));
316 }
317
318 #[test]
319 fn tip_alert() {
320 let html = "<blockquote>\n<p>[!TIP]<br>\nHelpful tip here.</p>\n</blockquote>";
321 let result = post_process_directives(html);
322 assert!(result.contains("alert alert-tip"));
323 assert!(result.contains("<p class=\"alert-title\">Tip</p>"));
324 }
325
326 #[test]
327 fn important_alert() {
328 let html = "<blockquote>\n<p>[!IMPORTANT]<br>\nDo this.</p>\n</blockquote>";
329 let result = post_process_directives(html);
330 assert!(result.contains("alert alert-important"));
331 assert!(result.contains("<p class=\"alert-title\">Important</p>"));
332 }
333
334 #[test]
335 fn warning_alert() {
336 let html = "<blockquote>\n<p>[!WARNING]<br>\nBe careful.</p>\n</blockquote>";
337 let result = post_process_directives(html);
338 assert!(result.contains("alert alert-warning"));
339 assert!(result.contains("<p class=\"alert-title\">Warning</p>"));
340 }
341
342 #[test]
343 fn caution_alert() {
344 let html = "<blockquote>\n<p>[!CAUTION]<br/>\nDanger zone.</p>\n</blockquote>";
345 let result = post_process_directives(html);
346 assert!(result.contains("alert alert-caution"));
347 assert!(result.contains("<p class=\"alert-title\">Caution</p>"));
348 }
349
350 #[test]
351 fn multi_paragraph_alert() {
352 let html = "<blockquote>\n<p>[!NOTE]<br>\nFirst paragraph.</p>\n<p>Second paragraph.</p>\n</blockquote>";
353 let result = post_process_directives(html);
354 assert!(result.contains("alert alert-note"));
355 assert!(result.contains("First paragraph."));
356 assert!(result.contains("Second paragraph."));
357 assert!(result.contains("</div>"));
358 assert!(!result.contains("</blockquote>"));
359 }
360
361 #[test]
362 fn regular_blockquote_unchanged() {
363 let html = "<blockquote>\n<p>Just a normal quote.</p>\n</blockquote>";
364 let result = post_process_directives(html);
365 assert_eq!(result, html);
366 }
367
368 #[test]
369 fn mixed_alerts_and_blockquotes() {
370 let html = concat!(
371 "<blockquote>\n<p>[!WARNING]<br>\nWatch out!</p>\n</blockquote>\n",
372 "<blockquote>\n<p>Normal quote.</p>\n</blockquote>"
373 );
374 let result = post_process_directives(html);
375 assert!(result.contains("alert alert-warning"));
376 assert!(result.contains("Watch out!"));
377 // The normal blockquote remains unchanged.
378 assert!(result.contains("<blockquote>"));
379 assert!(result.contains("Normal quote."));
380 }
381
382 // ===== Custom alert types =====
383
384 #[test]
385 fn custom_example_alert() {
386 let html = "<blockquote>\n<p>[!EXAMPLE]<br>\nHere is an example.</p>\n</blockquote>";
387 let result = post_process_directives(html);
388 assert!(result.contains("alert alert-example"));
389 assert!(result.contains("<p class=\"alert-title\">Example</p>"));
390 assert!(result.contains("Here is an example."));
391 assert!(!result.contains("<blockquote>"));
392 }
393
394 #[test]
395 fn custom_definition_alert() {
396 let html = "<blockquote>\n<p>[!DEFINITION]<br>\nA term and its meaning.</p>\n</blockquote>";
397 let result = post_process_directives(html);
398 assert!(result.contains("alert alert-definition"));
399 assert!(result.contains("<p class=\"alert-title\">Definition</p>"));
400 }
401
402 #[test]
403 fn custom_alert_with_hyphen() {
404 let html =
405 "<blockquote>\n<p>[!SEE-ALSO]<br>\nRelated topics.</p>\n</blockquote>";
406 let result = post_process_directives(html);
407 assert!(result.contains("alert alert-see-also"));
408 assert!(result.contains("<p class=\"alert-title\">See-also</p>"));
409 }
410
411 // ===== Code tabs =====
412
413 #[test]
414 fn tabs_two_languages() {
415 let html = concat!(
416 "<blockquote>\n<p>[!TABS]</p>\n",
417 "<pre><code class=\"language-rust\">fn main() {}\n</code></pre>\n",
418 "<pre><code class=\"language-python\">def main(): pass\n</code></pre>\n",
419 "</blockquote>"
420 );
421 let result = post_process_directives(html);
422 assert!(result.contains("code-tabs"));
423 assert!(result.contains("code-tabs-bar"));
424 assert!(result.contains("Rust"));
425 assert!(result.contains("Python"));
426 assert!(result.contains("fn main() {}"));
427 assert!(result.contains("def main(): pass"));
428 assert!(!result.contains("<blockquote>"));
429 // First tab is active.
430 assert!(result.contains("code-tab active"));
431 assert!(result.contains("code-tab-panel active"));
432 }
433
434 #[test]
435 fn tabs_three_languages() {
436 let html = concat!(
437 "<blockquote>\n<p>[!TABS]</p>\n",
438 "<pre><code class=\"language-bash\">curl https://api.example.com\n</code></pre>\n",
439 "<pre><code class=\"language-js\">fetch('https://api.example.com')\n</code></pre>\n",
440 "<pre><code class=\"language-python\">requests.get('https://api.example.com')\n</code></pre>\n",
441 "</blockquote>"
442 );
443 let result = post_process_directives(html);
444 assert!(result.contains("Shell")); // bash → Shell
445 assert!(result.contains("JavaScript")); // js → JavaScript
446 assert!(result.contains("Python"));
447 assert!(result.contains("data-tab-index=\"0\""));
448 assert!(result.contains("data-tab-index=\"1\""));
449 assert!(result.contains("data-tab-index=\"2\""));
450 }
451
452 #[test]
453 fn tabs_no_language_specified() {
454 let html = concat!(
455 "<blockquote>\n<p>[!TABS]</p>\n",
456 "<pre><code>some code\n</code></pre>\n",
457 "<pre><code class=\"language-rust\">let x = 1;\n</code></pre>\n",
458 "</blockquote>"
459 );
460 let result = post_process_directives(html);
461 assert!(result.contains("Code")); // fallback label
462 assert!(result.contains("Rust"));
463 }
464
465 #[test]
466 fn tabs_with_br_marker() {
467 let html = concat!(
468 "<blockquote>\n<p>[!TABS]<br>\n</p>\n",
469 "<pre><code class=\"language-toml\">[package]\n</code></pre>\n",
470 "<pre><code class=\"language-json\">{}\n</code></pre>\n",
471 "</blockquote>"
472 );
473 let result = post_process_directives(html);
474 assert!(result.contains("TOML"));
475 assert!(result.contains("JSON"));
476 }
477
478 #[test]
479 fn tabs_mixed_with_alert_and_blockquote() {
480 let html = concat!(
481 "<blockquote>\n<p>[!NOTE]<br>\nA note.</p>\n</blockquote>\n",
482 "<blockquote>\n<p>[!TABS]</p>\n",
483 "<pre><code class=\"language-rust\">let x = 1;\n</code></pre>\n",
484 "</blockquote>\n",
485 "<blockquote>\n<p>Normal quote.</p>\n</blockquote>"
486 );
487 let result = post_process_directives(html);
488 // Alert processed.
489 assert!(result.contains("alert alert-note"));
490 // Tabs processed.
491 assert!(result.contains("code-tabs"));
492 assert!(result.contains("Rust"));
493 // Normal blockquote unchanged.
494 assert!(result.contains("<blockquote>"));
495 assert!(result.contains("Normal quote."));
496 }
497
498 #[test]
499 fn tabs_no_code_blocks() {
500 let html = concat!(
501 "<blockquote>\n<p>[!TABS]</p>\n",
502 "<p>Just text, no code.</p>\n",
503 "</blockquote>"
504 );
505 let result = post_process_directives(html);
506 assert!(result.contains("code-tabs"));
507 assert!(result.contains("Just text, no code."));
508 assert!(!result.contains("<blockquote>"));
509 }
510
511 // ===== Language label mapping =====
512
513 #[test]
514 fn language_labels() {
515 assert_eq!(code_language_label("js"), "JavaScript");
516 assert_eq!(code_language_label("typescript"), "TypeScript");
517 assert_eq!(code_language_label("bash"), "Shell");
518 assert_eq!(code_language_label("json"), "JSON");
519 assert_eq!(code_language_label("html"), "HTML");
520 assert_eq!(code_language_label("css"), "CSS");
521 assert_eq!(code_language_label("sql"), "SQL");
522 assert_eq!(code_language_label("toml"), "TOML");
523 assert_eq!(code_language_label("yaml"), "YAML");
524 assert_eq!(code_language_label("xml"), "XML");
525 assert_eq!(code_language_label("rust"), "Rust");
526 assert_eq!(code_language_label("python"), "Python");
527 assert_eq!(code_language_label("go"), "Go");
528 }
529
530 // ===== UI example directives =====
531
532 #[test]
533 fn ui_example_basic() {
534 let html = "<blockquote>\n<p>[!UI] discover-filters</p>\n</blockquote>";
535 let result = post_process_directives(html);
536 assert!(result.contains("doc-ui"));
537 assert!(result.contains("data-ui=\"discover-filters\""));
538 assert!(result.contains("<figure"));
539 assert!(!result.contains("<blockquote>"));
540 }
541
542 #[test]
543 fn ui_example_with_caption() {
544 let html = "<blockquote>\n<p>[!UI] discover-filters<br>\nFilter sidebar in items mode</p>\n</blockquote>";
545 let result = post_process_directives(html);
546 assert!(result.contains("data-ui=\"discover-filters\""));
547 assert!(result.contains("<figcaption>"));
548 assert!(result.contains("Filter sidebar in items mode"));
549 }
550
551 #[test]
552 fn ui_example_not_confused_with_alert() {
553 let html = concat!(
554 "<blockquote>\n<p>[!UI] my-example</p>\n</blockquote>\n",
555 "<blockquote>\n<p>[!NOTE]<br>\nA note.</p>\n</blockquote>"
556 );
557 let result = post_process_directives(html);
558 assert!(result.contains("doc-ui"));
559 assert!(result.contains("alert alert-note"));
560 }
561
562 #[test]
563 fn alert_close_replacement_does_not_consume_extra_blockquote() {
564 // Pins the `replaced < alert_count` loop bound in process_alerts:
565 // mutating `<` to `<=` would consume a SECOND `</blockquote>` (from the
566 // following normal quote) as if it were an alert close.
567 let html = concat!(
568 "<blockquote>\n<p>[!WARNING]<br>\nWatch out!</p>\n</blockquote>\n",
569 "<blockquote>\n<p>Normal quote.</p>\n</blockquote>"
570 );
571 let result = post_process_directives(html);
572 // Normal blockquote must retain its closing tag. The opening also
573 // survives (one remaining `<blockquote>`; the alert's was removed).
574 assert_eq!(
575 result.matches("<blockquote>").count(),
576 1,
577 "expected exactly one surviving <blockquote>, got: {result}"
578 );
579 assert_eq!(
580 result.matches("</blockquote>").count(),
581 1,
582 "expected exactly one surviving </blockquote>, got: {result}"
583 );
584 // And the alert structure is correctly closed exactly once.
585 assert_eq!(
586 result.matches("</div>").count(),
587 1,
588 "exactly one </div> for the single alert, got: {result}"
589 );
590 }
591
592 #[test]
593 fn strip_html_tags_keeps_text_between_tags() {
594 // strip_html_tags_simple is exercised via UI caption extraction.
595 // This test asserts the exact text-between-tags behavior, which pins
596 // the `<` (enter tag), `>` (exit tag), and `_ if !in_tag` arms.
597 let html = concat!(
598 "<blockquote>\n<p>[!UI] widget</p>\n",
599 "<p>Caption with <em>emphasis</em> and <code>code</code> inside.</p>\n",
600 "</blockquote>"
601 );
602 let result = post_process_directives(html);
603 // Caption text must contain every text segment with no tag fragments.
604 assert!(result.contains("Caption with emphasis and code inside."),
605 "stripped caption text missing or wrong: {result}");
606 // And must NOT contain any of the original inline tag names.
607 assert!(!result.contains("<em>"), "<em> leaked into caption: {result}");
608 assert!(!result.contains("<code>"), "<code> leaked into caption: {result}");
609 }
610
611 #[test]
612 fn ui_caption_extraction_uses_correct_byte_range() {
613 // Pins the `bq_pos + marker_end` and `..close_pos` arithmetic in
614 // process_ui_examples: caption should be exactly what follows the
615 // marker line, with no leakage from before the marker or after the
616 // blockquote close.
617 let html = concat!(
618 "<p>Lead paragraph.</p>\n",
619 "<blockquote>\n<p>[!UI] preview</p>\n<p>Exact caption.</p>\n</blockquote>\n",
620 "<p>Trailing paragraph.</p>"
621 );
622 let result = post_process_directives(html);
623 assert!(result.contains("<figcaption>Exact caption.</figcaption>"),
624 "caption byte-range arithmetic wrong: {result}");
625 // The surrounding paragraphs survive intact and unduplicated.
626 assert_eq!(result.matches("Lead paragraph.").count(), 1, "lead duplicated/missing: {result}");
627 assert_eq!(result.matches("Trailing paragraph.").count(), 1, "trailer duplicated/missing: {result}");
628 // The marker line is fully consumed — `[!UI]` must not leak through.
629 assert!(!result.contains("[!UI]"), "marker leaked: {result}");
630 }
631
632 #[test]
633 fn ui_example_preserves_other_blockquotes() {
634 let html = concat!(
635 "<blockquote>\n<p>Normal quote.</p>\n</blockquote>\n",
636 "<blockquote>\n<p>[!UI] cart-view</p>\n</blockquote>"
637 );
638 let result = post_process_directives(html);
639 // After process_ui_examples, the normal blockquote should still have <blockquote>.
640 // But then process_alerts runs and leaves non-alert blockquotes alone.
641 // Check the UI example was processed.
642 assert!(result.contains("data-ui=\"cart-view\""));
643 assert!(result.contains("Normal quote."));
644 // The normal blockquote survives all processing.
645 assert!(result.contains("<blockquote>"), "Result: {result}");
646 }
647 }
648