Skip to main content

max / makenotwork

24.6 KB · 692 lines History Blame Raw
1 use std::collections::HashMap;
2 use std::path::Path;
3 use std::sync::LazyLock;
4
5 use regex::Regex;
6
7 static LINK_RE: LazyLock<Regex> = LazyLock::new(|| {
8 Regex::new(r"\[([^\]]+)\]\(([^)]+)\)").expect("valid regex")
9 });
10
11 /// Configuration for the doc loader.
12 pub struct DocLoaderConfig {
13 /// Sections as `(directory_name, display_name)` pairs in display order.
14 pub sections: Vec<(String, String)>,
15 /// URL prefix for rewritten links (e.g., "/docs").
16 pub link_prefix: String,
17 /// Pattern that identifies unpublished links to strip (e.g., "unpublished/").
18 pub unpublished_pattern: Option<String>,
19 /// Path to directory containing UI example `.html` fragments.
20 /// If set, `[!UI] name` directives are resolved by loading `{examples_path}/{name}.html`.
21 pub examples_path: Option<std::path::PathBuf>,
22 /// Optional pre-processor applied to raw markdown before link rewriting.
23 /// On `Err`, the page is skipped with a warning. Use to wire
24 /// [`crate::Assumptions::substitute`] or a similar transform.
25 pub pre_process: Option<Box<dyn Fn(&str) -> Result<String, String> + Send + Sync>>,
26 }
27
28 /// A rendered documentation page.
29 #[derive(Clone, Debug)]
30 pub struct DocPage {
31 pub title: String,
32 pub slug: String,
33 pub section: String,
34 pub html_content: String,
35 }
36
37 /// Ordered entry for the docs index page.
38 #[derive(Clone, Debug)]
39 pub struct DocIndexEntry {
40 pub title: String,
41 pub slug: String,
42 pub section: String,
43 }
44
45 /// Entry in the full-text search index, serialised to JSON for client-side search.
46 #[derive(Clone, Debug, serde::Serialize)]
47 pub struct DocSearchEntry {
48 pub slug: String,
49 pub title: String,
50 pub section: String,
51 pub body_text: String,
52 }
53
54 /// In-memory store of rendered documentation pages, built once at startup.
55 #[derive(Clone, Debug)]
56 pub struct DocLoader {
57 pages: HashMap<String, DocPage>,
58 index: Vec<DocIndexEntry>,
59 }
60
61 impl DocLoader {
62 /// Load all `.md` files from `base_path`, rendering them into HTML.
63 ///
64 /// Expects subdirectories matching the configured sections.
65 pub fn load(base_path: &Path, config: &DocLoaderConfig) -> Self {
66 let mut pages = HashMap::new();
67 let mut index = Vec::new();
68
69 for (dir_name, section_display) in &config.sections {
70 let section_path = base_path.join(dir_name);
71 if !section_path.is_dir() {
72 continue;
73 }
74
75 let read_dir = match std::fs::read_dir(&section_path) {
76 Ok(rd) => rd,
77 Err(e) => {
78 tracing::warn!(path = %section_path.display(), error = %e, "Failed to read docs section directory");
79 continue;
80 }
81 };
82
83 let mut entries: Vec<_> = read_dir
84 .filter_map(|e| e.ok())
85 .filter(|e| {
86 e.path()
87 .extension()
88 .map(|ext| ext == "md")
89 .unwrap_or(false)
90 })
91 .collect();
92
93 entries.sort_by_key(|e| e.file_name());
94
95 for entry in entries {
96 let path = entry.path();
97 let slug = path
98 .file_stem()
99 .and_then(|s| s.to_str())
100 .unwrap_or_default()
101 .to_string();
102
103 let raw_md = match std::fs::read_to_string(&path) {
104 Ok(content) => content,
105 Err(_) => continue,
106 };
107
108 let raw_md = match &config.pre_process {
109 Some(pp) => match pp(&raw_md) {
110 Ok(md) => md,
111 Err(e) => {
112 tracing::warn!(
113 path = %path.display(),
114 error = %e,
115 "pre_process failed; skipping page"
116 );
117 continue;
118 }
119 },
120 None => raw_md,
121 };
122
123 let title =
124 crate::text::extract_title(&raw_md).unwrap_or_else(|| slug.clone());
125 let rewritten_md = rewrite_links(
126 &raw_md,
127 &config.link_prefix,
128 config.unpublished_pattern.as_deref(),
129 );
130 let md_without_title = crate::text::strip_first_heading(&rewritten_md);
131 let html_content = crate::render_permissive(&md_without_title);
132 #[cfg(feature = "directives")]
133 let html_content = crate::directives::post_process_directives(&html_content);
134 let html_content = resolve_ui_examples(&html_content, config.examples_path.as_deref());
135
136 let page = DocPage {
137 title,
138 slug,
139 section: section_display.clone(),
140 html_content,
141 };
142
143 index.push(DocIndexEntry {
144 title: page.title.clone(),
145 slug: page.slug.clone(),
146 section: page.section.clone(),
147 });
148
149 let slug_key = page.slug.clone();
150 pages.insert(slug_key, page);
151 }
152 }
153
154 DocLoader { pages, index }
155 }
156
157 /// Look up a rendered page by slug.
158 pub fn get(&self, slug: &str) -> Option<&DocPage> {
159 self.pages.get(slug)
160 }
161
162 /// Get the full ordered index.
163 pub fn index(&self) -> &[DocIndexEntry] {
164 &self.index
165 }
166
167 /// Build a search index with HTML stripped to plain text.
168 pub fn search_index(&self) -> Vec<DocSearchEntry> {
169 self.index
170 .iter()
171 .filter_map(|entry| {
172 let page = self.pages.get(&entry.slug)?;
173 Some(DocSearchEntry {
174 slug: entry.slug.clone(),
175 title: entry.title.clone(),
176 section: entry.section.clone(),
177 body_text: strip_html_tags(&page.html_content),
178 })
179 })
180 .collect()
181 }
182 }
183
184 /// Replace `<div class="doc-ui-frame" data-ui="name"></div>` placeholders with
185 /// the contents of `{examples_path}/{name}.html`.
186 ///
187 /// If no examples path is configured or a file is missing, the placeholder is
188 /// replaced with a fallback message.
189 fn resolve_ui_examples(html: &str, examples_path: Option<&Path>) -> String {
190 static UI_PLACEHOLDER: LazyLock<Regex> = LazyLock::new(|| {
191 Regex::new(r#"<div class="doc-ui-frame" data-ui="([a-z0-9_-]+)"></div>"#)
192 .expect("valid UI placeholder regex")
193 });
194
195 if !html.contains("doc-ui-frame") {
196 return html.to_string();
197 }
198
199 UI_PLACEHOLDER.replace_all(html, |caps: &regex::Captures| {
200 let name = &caps[1];
201 match examples_path {
202 Some(dir) => {
203 let file = dir.join(format!("{name}.html"));
204 match std::fs::read_to_string(&file) {
205 Ok(content) => format!(
206 "<div class=\"doc-ui-frame\">{content}</div>"
207 ),
208 Err(_) => {
209 tracing::warn!(example = name, "UI example file not found");
210 format!(
211 "<div class=\"doc-ui-frame doc-ui-missing\">[UI example: {name}]</div>"
212 )
213 }
214 }
215 }
216 None => format!(
217 "<div class=\"doc-ui-frame doc-ui-missing\">[UI example: {name}]</div>"
218 ),
219 }
220 }).into_owned()
221 }
222
223 /// Strip HTML tags from a string, returning plain text.
224 /// Decodes common HTML entities so search indexes match plain-text queries.
225 fn strip_html_tags(html: &str) -> String {
226 let mut out = String::with_capacity(html.len());
227 let mut in_tag = false;
228 for ch in html.chars() {
229 match ch {
230 '<' => in_tag = true,
231 '>' => {
232 in_tag = false;
233 // Add a space after closing tags to separate words.
234 if !out.ends_with(' ') {
235 out.push(' ');
236 }
237 }
238 _ if !in_tag => out.push(ch),
239 _ => {}
240 }
241 }
242 // Collapse runs of whitespace.
243 let collapsed: String = out.split_whitespace().collect::<Vec<_>>().join(" ");
244 // Decode common HTML entities for search index accuracy.
245 collapsed
246 .replace("&amp;", "&")
247 .replace("&lt;", "<")
248 .replace("&gt;", ">")
249 .replace("&quot;", "\"")
250 .replace("&#x27;", "'")
251 .replace("&#39;", "'")
252 }
253
254 /// Rewrite relative `.md` links to the configured prefix.
255 fn rewrite_links(markdown: &str, link_prefix: &str, unpublished_pattern: Option<&str>) -> String {
256 LINK_RE
257 .replace_all(markdown, |caps: &regex::Captures| {
258 let text = &caps[1];
259 let url = &caps[2];
260
261 // Preserve absolute URLs, mailto, and internal routes.
262 if url.starts_with("http://")
263 || url.starts_with("https://")
264 || url.starts_with("mailto:")
265 || url.starts_with('/')
266 {
267 return caps[0].to_string();
268 }
269
270 // Unpublished docs: strip link, keep text.
271 if let Some(pattern) = unpublished_pattern {
272 if url.contains(pattern) {
273 return text.to_string();
274 }
275 }
276
277 // Only rewrite links containing .md
278 if !url.contains(".md") {
279 return caps[0].to_string();
280 }
281
282 // Split off any #anchor.
283 let (path_part, anchor): (&str, Option<&str>) = match url.split_once('#') {
284 Some((p, a)) => (p, Some(a)),
285 None => (url, None),
286 };
287
288 // Extract slug from filename: ../support/faq.md -> faq
289 let filename = path_part
290 .rsplit('/')
291 .next()
292 .unwrap_or(path_part)
293 .trim_end_matches(".md");
294
295 let mut new_url = format!("{link_prefix}/{filename}");
296 if let Some(anchor) = anchor {
297 new_url.push('#');
298 new_url.push_str(anchor);
299 }
300
301 format!("[{text}]({new_url})")
302 })
303 .to_string()
304 }
305
306 #[cfg(test)]
307 mod tests {
308 use super::*;
309
310 #[test]
311 fn rewrite_same_section_link() {
312 let md = "See [SLA](./guarantees.md) for details.";
313 let result = rewrite_links(md, "/docs", Some("unpublished/"));
314 assert_eq!(result, "See [SLA](/docs/guarantees) for details.");
315 }
316
317 #[test]
318 fn rewrite_cross_section_link() {
319 let md = "Check [FAQ](../support/faq.md) for more.";
320 let result = rewrite_links(md, "/docs", Some("unpublished/"));
321 assert_eq!(result, "Check [FAQ](/docs/faq) for more.");
322 }
323
324 #[test]
325 fn rewrite_unpublished_link_becomes_plain_text() {
326 let md = "See [Content Moderation](../../unpublished/legal/moderation.md) for details.";
327 let result = rewrite_links(md, "/docs", Some("unpublished/"));
328 assert_eq!(result, "See Content Moderation for details.");
329 }
330
331 #[test]
332 fn rewrite_preserves_absolute_urls() {
333 let md = "Visit [our site](https://example.com) today.";
334 let result = rewrite_links(md, "/docs", Some("unpublished/"));
335 assert_eq!(result, md);
336 }
337
338 #[test]
339 fn rewrite_preserves_plain_http_urls() {
340 // Distinct from https — catches the `url.starts_with("http://")` arm
341 // mutation (L244 `||` → `&&`). Without this case, the only protocol
342 // tested is https, leaving the http arm uncovered.
343 let md = "Visit [legacy](http://example.com) today.";
344 let result = rewrite_links(md, "/docs", Some("unpublished/"));
345 assert_eq!(result, md);
346 }
347
348 #[test]
349 fn rewrite_preserves_external_md_links() {
350 // Absolute URLs that happen to end in .md must NOT be rewritten.
351 // This catches the L244 `||` → `&&` mutation: under the mutant, the
352 // early-return short-circuit fails (since one URL can't both start
353 // with "http://" AND "https://"), so the URL falls through to the
354 // .md-rewrite path and gets incorrectly mangled.
355 let md_http = "See [external](http://example.com/foo.md).";
356 assert_eq!(
357 rewrite_links(md_http, "/docs", Some("unpublished/")),
358 md_http,
359 "http:// + .md must be preserved"
360 );
361 let md_https = "See [external](https://example.com/foo.md).";
362 assert_eq!(
363 rewrite_links(md_https, "/docs", Some("unpublished/")),
364 md_https,
365 "https:// + .md must be preserved"
366 );
367 let md_mailto = "Email [us](mailto:a@b.md).";
368 assert_eq!(
369 rewrite_links(md_mailto, "/docs", Some("unpublished/")),
370 md_mailto,
371 "mailto: + .md must be preserved"
372 );
373 }
374
375 #[test]
376 fn rewrite_preserves_mailto() {
377 let md = "Email [us](mailto:test@example.com)";
378 let result = rewrite_links(md, "/docs", Some("unpublished/"));
379 assert_eq!(result, md);
380 }
381
382 #[test]
383 fn rewrite_preserves_internal_routes() {
384 let md = "Go to [pricing](/pricing) page.";
385 let result = rewrite_links(md, "/docs", Some("unpublished/"));
386 assert_eq!(result, md);
387 }
388
389 #[test]
390 fn rewrite_link_with_anchor() {
391 let md = "See [section](./faq.md#billing).";
392 let result = rewrite_links(md, "/docs", Some("unpublished/"));
393 assert_eq!(result, "See [section](/docs/faq#billing).");
394 }
395
396 #[test]
397 fn rewrite_public_cross_ref() {
398 let md = "See [Acceptable Use](../../public/legal/acceptable-use.md).";
399 let result = rewrite_links(md, "/docs", Some("unpublished/"));
400 assert_eq!(result, "See [Acceptable Use](/docs/acceptable-use).");
401 }
402
403 #[test]
404 fn rewrite_custom_prefix() {
405 let md = "See [FAQ](./faq.md) here.";
406 let result = rewrite_links(md, "/help", None);
407 assert_eq!(result, "See [FAQ](/help/faq) here.");
408 }
409
410 #[test]
411 fn rewrite_no_unpublished_pattern() {
412 let md = "See [doc](../../unpublished/foo.md).";
413 let result = rewrite_links(md, "/docs", None);
414 // Without the pattern, it just rewrites normally
415 assert_eq!(result, "See [doc](/docs/foo).");
416 }
417
418 #[test]
419 fn rewrite_non_md_link_preserved() {
420 let md = "See [image](./photo.png) here.";
421 let result = rewrite_links(md, "/docs", None);
422 assert_eq!(result, md);
423 }
424
425 #[test]
426 fn strip_html_tags_removes_tags() {
427 let html = "<p>Hello <strong>world</strong></p>";
428 assert_eq!(strip_html_tags(html), "Hello world");
429 }
430
431 #[test]
432 fn strip_html_tags_empty_input() {
433 assert_eq!(strip_html_tags(""), "");
434 }
435
436 #[test]
437 fn strip_html_tags_decodes_entities() {
438 let html = "<p>Price: $10 &amp; free</p>";
439 assert_eq!(strip_html_tags(html), "Price: $10 & free");
440
441 let html2 = "<p>a &lt; b &gt; c</p>";
442 assert_eq!(strip_html_tags(html2), "a < b > c");
443
444 let html3 = "<p>&quot;hello&quot; &amp; &#x27;world&#39;</p>";
445 assert_eq!(strip_html_tags(html3), "\"hello\" & 'world'");
446 }
447
448 #[test]
449 fn strip_html_tags_nested_tags() {
450 let html = "<div><p>A <em>nested <strong>deep</strong></em> tag</p></div>";
451 assert_eq!(strip_html_tags(html), "A nested deep tag");
452 }
453
454 // ── DocLoader::load / get / index / search_index (tempdir fixtures) ──
455
456 fn config_for(base: &Path) -> DocLoaderConfig {
457 // Sections listed in display order: "guide" first, then "support".
458 let _ = base; // base path lives at the call site; config doesn't need it.
459 DocLoaderConfig {
460 sections: vec![
461 ("guide".to_string(), "Guide".to_string()),
462 ("support".to_string(), "Support".to_string()),
463 ],
464 link_prefix: "/docs".to_string(),
465 unpublished_pattern: Some("unpublished/".to_string()),
466 examples_path: None,
467 pre_process: None,
468 }
469 }
470
471 #[test]
472 fn pre_process_hook_transforms_markdown_before_render() {
473 let tmp = tempfile::tempdir().unwrap();
474 let base = tmp.path();
475 let p = base.join("guide");
476 std::fs::create_dir_all(&p).unwrap();
477 std::fs::write(p.join("a.md"), "# Hi\n\nValue: TOKEN").unwrap();
478
479 let mut config = config_for(base);
480 config.pre_process = Some(Box::new(|md: &str| {
481 Ok(md.replace("TOKEN", "42"))
482 }));
483 let loader = DocLoader::load(base, &config);
484 let page = loader.get("a").expect("page loaded");
485 assert!(page.html_content.contains("42"), "got: {}", page.html_content);
486 assert!(!page.html_content.contains("TOKEN"), "got: {}", page.html_content);
487 }
488
489 #[test]
490 fn pre_process_hook_error_skips_page() {
491 let tmp = tempfile::tempdir().unwrap();
492 let base = tmp.path();
493 let p = base.join("guide");
494 std::fs::create_dir_all(&p).unwrap();
495 std::fs::write(p.join("good.md"), "# Good").unwrap();
496 std::fs::write(p.join("bad.md"), "# Bad\n\n{{ missing }}").unwrap();
497
498 let mut config = config_for(base);
499 config.pre_process = Some(Box::new(|md: &str| {
500 if md.contains("{{") {
501 Err("unresolved placeholder".into())
502 } else {
503 Ok(md.to_string())
504 }
505 }));
506 let loader = DocLoader::load(base, &config);
507 assert!(loader.get("good").is_some(), "good page should load");
508 assert!(loader.get("bad").is_none(), "bad page should be skipped");
509 }
510
511 fn write(base: &Path, rel: &str, content: &str) {
512 let p = base.join(rel);
513 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
514 std::fs::write(p, content).unwrap();
515 }
516
517 #[test]
518 fn load_indexes_pages_across_sections_in_display_order() {
519 let tmp = tempfile::tempdir().unwrap();
520 let base = tmp.path();
521 // Guide section: two pages, with the file order intentionally reversed
522 // from desired sort order to confirm `entries.sort_by_key(file_name)`.
523 write(base, "guide/zzz-last.md", "# Z Page\n\nzzz body");
524 write(base, "guide/aaa-first.md", "# A Page\n\naaa body");
525 // Support section: one page.
526 write(base, "support/faq.md", "# FAQ\n\nfaq body");
527
528 let loader = DocLoader::load(base, &config_for(base));
529 let idx = loader.index();
530 assert_eq!(idx.len(), 3, "expected 3 indexed pages, got: {idx:?}");
531
532 // Sections appear in config order; entries within a section in
533 // sort_by_key(file_name) order.
534 assert_eq!(idx[0].slug, "aaa-first");
535 assert_eq!(idx[0].section, "Guide");
536 assert_eq!(idx[1].slug, "zzz-last");
537 assert_eq!(idx[1].section, "Guide");
538 assert_eq!(idx[2].slug, "faq");
539 assert_eq!(idx[2].section, "Support");
540 }
541
542 #[test]
543 fn load_extracts_title_from_first_heading_and_strips_it_from_body() {
544 let tmp = tempfile::tempdir().unwrap();
545 let base = tmp.path();
546 write(base, "guide/welcome.md", "# Welcome Title\n\nBody paragraph here.");
547
548 let loader = DocLoader::load(base, &config_for(base));
549 let page = loader.get("welcome").expect("page should be indexed");
550 assert_eq!(page.title, "Welcome Title");
551 // The H1 itself must be stripped from html_content — only body remains.
552 assert!(!page.html_content.contains("Welcome Title"),
553 "title leaked into body: {}", page.html_content);
554 assert!(page.html_content.contains("Body paragraph here"));
555 }
556
557 #[test]
558 fn load_falls_back_to_slug_when_no_title_heading() {
559 let tmp = tempfile::tempdir().unwrap();
560 let base = tmp.path();
561 write(base, "guide/no-title.md", "Body without any heading.");
562
563 let loader = DocLoader::load(base, &config_for(base));
564 let page = loader.get("no-title").unwrap();
565 assert_eq!(page.title, "no-title");
566 }
567
568 #[test]
569 fn load_skips_non_markdown_files() {
570 let tmp = tempfile::tempdir().unwrap();
571 let base = tmp.path();
572 write(base, "guide/keep.md", "# Keep\n\nbody");
573 write(base, "guide/ignore.txt", "should not be indexed");
574 write(base, "guide/also-ignore.json", "{}");
575
576 let loader = DocLoader::load(base, &config_for(base));
577 assert_eq!(loader.index().len(), 1);
578 assert_eq!(loader.index()[0].slug, "keep");
579 }
580
581 #[test]
582 fn load_skips_missing_section_directories() {
583 let tmp = tempfile::tempdir().unwrap();
584 let base = tmp.path();
585 // Only `guide` exists; `support` is missing entirely.
586 write(base, "guide/page.md", "# Page\n\nbody");
587
588 let loader = DocLoader::load(base, &config_for(base));
589 // Should index the one page that exists, not panic on the missing dir.
590 assert_eq!(loader.index().len(), 1);
591 assert_eq!(loader.index()[0].slug, "page");
592 }
593
594 #[test]
595 fn load_rewrites_relative_md_links() {
596 let tmp = tempfile::tempdir().unwrap();
597 let base = tmp.path();
598 write(
599 base,
600 "guide/main.md",
601 "# Main\n\nSee [FAQ](../support/faq.md) for help.",
602 );
603 write(base, "support/faq.md", "# FAQ\n\nFAQ body.");
604
605 let loader = DocLoader::load(base, &config_for(base));
606 let main = loader.get("main").unwrap();
607 // The .md link must be rewritten to /docs/<slug>; the original
608 // `../support/faq.md` path must not appear.
609 assert!(main.html_content.contains("/docs/faq"),
610 "link not rewritten: {}", main.html_content);
611 assert!(!main.html_content.contains("faq.md"),
612 "raw .md path leaked: {}", main.html_content);
613 }
614
615 #[test]
616 fn get_returns_none_for_unknown_slug() {
617 let tmp = tempfile::tempdir().unwrap();
618 let base = tmp.path();
619 write(base, "guide/exists.md", "# Exists\n\nbody");
620
621 let loader = DocLoader::load(base, &config_for(base));
622 assert!(loader.get("nope").is_none());
623 assert!(loader.get("exists").is_some());
624 }
625
626 #[test]
627 fn search_index_strips_html_and_preserves_metadata() {
628 let tmp = tempfile::tempdir().unwrap();
629 let base = tmp.path();
630 write(
631 base,
632 "guide/with-html.md",
633 "# Title\n\nA **bold** word and an `inline code` token.",
634 );
635
636 let loader = DocLoader::load(base, &config_for(base));
637 let entries = loader.search_index();
638 assert_eq!(entries.len(), 1);
639 let e = &entries[0];
640 assert_eq!(e.slug, "with-html");
641 assert_eq!(e.title, "Title");
642 assert_eq!(e.section, "Guide");
643 // body_text must be plain text — no surviving tags.
644 assert!(!e.body_text.contains('<'), "tag leaked into search: {}", e.body_text);
645 assert!(e.body_text.contains("bold"));
646 assert!(e.body_text.contains("inline code"));
647 }
648
649 // ── resolve_ui_examples ──
650
651 #[test]
652 fn resolve_ui_examples_inlines_file_contents_when_present() {
653 let tmp = tempfile::tempdir().unwrap();
654 let dir = tmp.path();
655 std::fs::write(dir.join("cart.html"), "<button>Buy</button>").unwrap();
656
657 let html = r#"<div class="doc-ui-frame" data-ui="cart"></div>"#;
658 let result = resolve_ui_examples(html, Some(dir));
659 assert!(result.contains("<button>Buy</button>"));
660 // The data-ui attribute is consumed during inlining.
661 assert!(!result.contains(r#"data-ui="cart""#));
662 }
663
664 #[test]
665 fn resolve_ui_examples_falls_back_when_file_missing() {
666 let tmp = tempfile::tempdir().unwrap();
667 let dir = tmp.path();
668 // examples_path exists but file does not.
669 let html = r#"<div class="doc-ui-frame" data-ui="ghost"></div>"#;
670 let result = resolve_ui_examples(html, Some(dir));
671 assert!(result.contains("doc-ui-missing"));
672 assert!(result.contains("[UI example: ghost]"));
673 }
674
675 #[test]
676 fn resolve_ui_examples_falls_back_when_no_examples_path() {
677 // Pins the `None` arm of the `match examples_path`.
678 let html = r#"<div class="doc-ui-frame" data-ui="anything"></div>"#;
679 let result = resolve_ui_examples(html, None);
680 assert!(result.contains("doc-ui-missing"));
681 assert!(result.contains("[UI example: anything]"));
682 }
683
684 #[test]
685 fn resolve_ui_examples_short_circuits_when_no_placeholder() {
686 // Pins the `if !html.contains("doc-ui-frame") { return html.to_string(); }` early return.
687 let html = "<p>Just regular HTML, no placeholders.</p>";
688 let result = resolve_ui_examples(html, None);
689 assert_eq!(result, html);
690 }
691 }
692