1use std::collections::HashMap;
7use std::fs;
8use std::path::Path;
9use std::sync::atomic::AtomicU64;
10use std::time::Instant;
11
12use console::style;
13use rayon::prelude::*;
14use serde_json::{Map, Value};
15use webui::BuildOptions;
16use webui_handler::{RenderOptions, ResponseWriter, WebUIHandler};
17
18use crate::content::process_content;
19use crate::error::{Error, Result};
20use crate::markdown::Highlighter;
21use crate::types::{BuildStats, DocsConfig};
22
23#[derive(Default)]
33pub struct BuildCache {
34 pub(crate) highlighter: Option<Highlighter>,
37 pub quiet: bool,
41 pub skip_clean: bool,
46}
47
48impl BuildCache {
49 #[must_use]
53 pub fn new() -> Self {
54 Self::default()
55 }
56
57 pub fn set_dev_rebuild(&mut self) {
60 self.quiet = true;
61 self.skip_clean = true;
62 }
63}
64
65static REBUILD_NONCE: AtomicU64 = AtomicU64::new(0);
76
77fn print_header(cache: &BuildCache, title: &str) {
78 if cache.quiet {
79 return;
80 }
81 eprintln!(
82 "\n {} {}",
83 style("⚡").cyan().bold(),
84 style(title).cyan().bold()
85 );
86}
87
88fn print_success(cache: &BuildCache, message: &str) {
89 if cache.quiet {
90 return;
91 }
92 eprintln!(" {} {message}", style("✔").green());
93}
94
95fn print_finish(cache: &BuildCache, message: &str) {
96 if cache.quiet {
97 return;
98 }
99 eprintln!("\n {} {message}\n", style("✨").green());
100}
101
102fn json_obj<const N: usize>(entries: [(&str, Value); N]) -> Value {
104 let mut map = Map::with_capacity(N);
105 for (k, v) in entries {
106 map.insert(k.to_string(), v);
107 }
108 Value::Object(map)
109}
110
111struct StringWriter {
113 buf: String,
114}
115
116impl StringWriter {
117 fn with_capacity(cap: usize) -> Self {
118 Self {
119 buf: String::with_capacity(cap),
120 }
121 }
122}
123
124impl ResponseWriter for StringWriter {
125 fn write(&mut self, content: &str) -> webui_handler::Result<()> {
126 self.buf.push_str(content);
127 Ok(())
128 }
129
130 fn end(&mut self) -> webui_handler::Result<()> {
131 Ok(())
132 }
133}
134
135pub fn build_docs(
145 config: &DocsConfig,
146 config_dir: &Path,
147 template_dir: &Path,
148) -> Result<BuildStats> {
149 let mut cache = BuildCache::new();
150 build_docs_with_cache(config, config_dir, template_dir, &mut cache)
151}
152
153pub fn build_docs_with_cache(
166 config: &DocsConfig,
167 config_dir: &Path,
168 template_dir: &Path,
169 cache: &mut BuildCache,
170) -> Result<BuildStats> {
171 let start = Instant::now();
172 let base_path = &config.base_path;
173 let out_dir = Path::new(&config.out_dir);
174
175 let site_dir = out_dir.to_path_buf();
181
182 let custom_css = config
184 .css
185 .as_ref()
186 .and_then(|p| fs::read_to_string(p).ok())
187 .unwrap_or_default();
188
189 print_header(cache, &config.site.title);
190 eprintln!();
191
192 let base_css_src = template_dir.join("docs.css");
201 let has_base_css = base_css_src.exists();
202 let base_css_link = if has_base_css {
203 format!("<link rel=\"stylesheet\" href=\"{base_path}docs.css\">")
204 } else {
205 String::new()
206 };
207 let has_theme_css = !custom_css.is_empty();
208 let theme_css_link = if has_theme_css {
209 format!("<link rel=\"stylesheet\" href=\"{base_path}theme.css\">")
210 } else {
211 String::new()
212 };
213 let head_injection = {
214 let mut parts: Vec<String> = Vec::with_capacity(2 + config.head.len());
215 if !base_css_link.is_empty() {
216 parts.push(base_css_link.clone());
217 }
218 if !theme_css_link.is_empty() {
219 parts.push(theme_css_link.clone());
220 }
221 for tag in &config.head {
222 parts.push(tag.to_html());
223 }
224 parts.join("\n ")
225 };
226
227 let highlighter = cache.highlighter.take().unwrap_or_default();
231
232 let pages = process_content(config, config_dir, &highlighter, &head_injection)?;
233
234 cache.highlighter = Some(highlighter);
236
237 let mut component_sources: Vec<String> = Vec::new();
239 let builtin_components = template_dir.parent().map(|p| p.join("components"));
241 if let Some(ref bc) = builtin_components {
242 if bc.exists() {
243 component_sources.push(bc.to_string_lossy().to_string());
244 }
245 }
246 if let Some(ref user_dirs) = config.components {
248 for d in user_dirs {
249 let abs = std::env::current_dir().unwrap_or_default().join(d);
250 component_sources.push(abs.to_string_lossy().to_string());
251 }
252 }
253 component_sources.push(template_dir.to_string_lossy().to_string());
256
257 let template_html = fs::read_to_string(template_dir.join("index.html"))
258 .map_err(|e| Error::Build(format!("Failed to read template: {e}")))?;
259
260 if out_dir.exists() && !cache.skip_clean {
265 remove_dir_all_retry(out_dir)
266 .map_err(|e| Error::Io(format!("Cannot clean output dir: {e}")))?;
267 }
268 fs::create_dir_all(&site_dir).map_err(|e| Error::Io(format!("Cannot create site dir: {e}")))?;
269
270 if has_base_css {
276 fs::copy(&base_css_src, site_dir.join("docs.css"))
277 .map_err(|e| Error::Io(format!("Cannot copy docs.css: {e}")))?;
278 }
279 if has_theme_css {
280 fs::write(site_dir.join("theme.css"), &custom_css)
281 .map_err(|e| Error::Io(format!("Cannot write theme.css: {e}")))?;
282 }
283
284 let handler = WebUIHandler::with_plugin(|| {
285 Box::new(webui_handler::plugin::webui::WebUIHydrationPlugin::new())
286 });
287
288 for page in &pages {
291 let page_dir = site_dir.join(page.path.strip_prefix(base_path).unwrap_or(&page.path));
292 fs::create_dir_all(&page_dir)
293 .map_err(|e| Error::Io(format!("Cannot create dir {}: {e}", page_dir.display())))?;
294 }
295
296 let template_dir_owned = template_dir.to_path_buf();
301 let component_sources_clone = component_sources.clone();
302 let site_dir_clone = site_dir.clone();
303 let bundle_handle = std::thread::spawn(move || -> Result<usize> {
304 let node_modules = std::env::current_dir()
305 .unwrap_or_default()
306 .join("node_modules");
307 bundle_components(
308 &template_dir_owned,
309 &component_sources_clone,
310 &site_dir_clone,
311 &node_modules,
312 )
313 });
314
315 let total_bytes = std::sync::atomic::AtomicUsize::new(0);
325 let component_css: std::sync::Mutex<HashMap<String, String>> =
326 std::sync::Mutex::new(HashMap::new());
327
328 pages.par_iter().try_for_each(|page| -> Result<()> {
329 let page_dir = site_dir.join(page.path.strip_prefix(base_path).unwrap_or(&page.path));
330 let target = page_dir.join("index.html");
331
332 let content = page.state["page"]["content"].as_str().unwrap_or("");
333
334 let (protected, pre_blocks) = protect_pre_blocks(content);
336
337 let page_html = template_html.replace("{{{page.content}}}", &protected);
339
340 let nonce = REBUILD_NONCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
349 let page_tmp = std::env::temp_dir().join(format!(
350 "webui-press-page-{}-{:x}-{nonce:x}",
351 std::process::id(),
352 fxhash(&page.path),
353 ));
354 if page_tmp.exists() {
355 fs::remove_dir_all(&page_tmp).ok();
356 }
357 fs::create_dir_all(&page_tmp)
358 .map_err(|e| Error::Io(format!("Cannot create page temp: {e}")))?;
359 fs::write(page_tmp.join("index.html"), &page_html)
360 .map_err(|e| Error::Io(format!("Cannot write page temp: {e}")))?;
361
362 let build_result = webui::build(BuildOptions {
363 app_dir: page_tmp.clone(),
364 entry: "index.html".to_string(),
365 plugin: Some(webui::Plugin::WebUI),
366 components: component_sources.clone(),
367 ..BuildOptions::default()
368 })
369 .map_err(|e| Error::Build(format!("{}: {e}", page.path)))?;
370
371 total_bytes.fetch_add(
372 build_result.protocol_bytes.len(),
373 std::sync::atomic::Ordering::Relaxed,
374 );
375
376 if !build_result.css_files.is_empty() {
381 let mut css_map = component_css
382 .lock()
383 .unwrap_or_else(std::sync::PoisonError::into_inner);
384 for (name, content) in build_result.css_files {
385 css_map.entry(name).or_insert(content);
386 }
387 }
388
389 let mut writer = StringWriter::with_capacity(8192);
390 handler
391 .render(
392 &build_result.protocol,
393 &page.state,
394 &RenderOptions::new("index.html", &page.path),
395 &mut writer,
396 )
397 .map_err(|e| Error::Render(format!("{}: {e}", page.path)))?;
398
399 let html = restore_pre_blocks(&writer.buf, &pre_blocks);
401
402 fs::write(&target, &html)
404 .map_err(|e| Error::Io(format!("Cannot write {}: {e}", page.path)))?;
405
406 fs::remove_dir_all(&page_tmp).ok();
407 Ok(())
408 })?;
409
410 print_success(cache, &format!("Rendered {} pages", pages.len()));
411
412 let search_path = site_dir.join("search-index.json");
415 let search_entries: Vec<Value> = pages
416 .iter()
417 .filter(|p| !p.is_home)
418 .map(|p| {
419 let html = p.state["page"]["content"].as_str().unwrap_or("");
420 let title = p.state["page"]["title"].as_str().unwrap_or("");
421 build_search_entry(title, &p.path, html)
422 })
423 .collect();
424 fs::write(
425 &search_path,
426 serde_json::to_string(&search_entries)
427 .map_err(|e| Error::Build(format!("JSON error: {e}")))?,
428 )
429 .map_err(|e| Error::Io(e.to_string()))?;
430 print_success(
431 cache,
432 &format!("Indexed {} pages for search", search_entries.len()),
433 );
434
435 let public_dir = Path::new(&config.public_dir);
437 if public_dir.exists() {
438 copy_dir(public_dir, &site_dir)?;
439 }
440
441 let nav_val = pages
443 .first()
444 .map(|p| p.state["navigation"].clone())
445 .unwrap_or_default();
446
447 let footer_val = config
448 .footer
449 .as_ref()
450 .map(|f| json_obj([("html", Value::String(f.html.clone()))]))
451 .unwrap_or(Value::Null);
452
453 let not_found_content = format!(
454 "<h1>404 — Page Not Found</h1>\
455 <p>The page you're looking for doesn't exist or has been moved.</p>\
456 <p><a href=\"{base_path}\">← Back to Home</a></p>"
457 );
458 let mut not_found_state = json_obj([
459 (
460 "site",
461 json_obj([
462 ("title", Value::String(config.site.title.clone())),
463 ("base", Value::String(base_path.to_string())),
464 ]),
465 ),
466 ("navigation", nav_val),
467 ("sidebar", json_obj([("sections", Value::Array(vec![]))])),
468 (
469 "page",
470 json_obj([
471 ("title", Value::String("Page Not Found".to_string())),
472 (
473 "description",
474 Value::String("The page you're looking for doesn't exist.".to_string()),
475 ),
476 ("content", Value::String(not_found_content.clone())),
477 ("isHome", Value::Bool(false)),
478 ("layout", Value::String("doc".to_string())),
479 ]),
480 ),
481 ("hero", Value::Null),
482 ("footer", footer_val),
483 ("prev", Value::Null),
484 ("next", Value::Null),
485 ]);
486 not_found_state["headTags"] = Value::String(head_injection);
487 not_found_state["label"] = Value::String("Copy".to_string());
488 not_found_state["icon"] = Value::String("🌙".to_string());
489
490 let not_found_html = template_html.replace("{{{page.content}}}", ¬_found_content);
491 let nf_tmp = std::env::temp_dir().join(format!(
492 "webui-press-404-{}-{:x}",
493 std::process::id(),
494 REBUILD_NONCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
495 ));
496 if nf_tmp.exists() {
497 fs::remove_dir_all(&nf_tmp).ok();
498 }
499 fs::create_dir_all(&nf_tmp).map_err(|e| Error::Io(e.to_string()))?;
500 fs::write(nf_tmp.join("index.html"), ¬_found_html).map_err(|e| Error::Io(e.to_string()))?;
501
502 let nf_build = webui::build(BuildOptions {
503 app_dir: nf_tmp.clone(),
504 entry: "index.html".to_string(),
505 plugin: Some(webui::Plugin::WebUI),
506 components: component_sources.clone(),
507 ..BuildOptions::default()
508 })
509 .map_err(|e| Error::Build(format!("404 build failed: {e}")))?;
510
511 {
516 let mut css_map = component_css
517 .lock()
518 .unwrap_or_else(std::sync::PoisonError::into_inner);
519 for (name, content) in &nf_build.css_files {
520 css_map
521 .entry(name.clone())
522 .or_insert_with(|| content.clone());
523 }
524 }
525
526 let mut writer_404 = StringWriter::with_capacity(4096);
527 handler
528 .render(
529 &nf_build.protocol,
530 ¬_found_state,
531 &RenderOptions::new("index.html", &format!("{base_path}404/")),
532 &mut writer_404,
533 )
534 .map_err(|e| Error::Render(format!("404: {e}")))?;
535
536 fs::write(site_dir.join("404.html"), writer_404.buf).map_err(|e| Error::Io(e.to_string()))?;
537 fs::remove_dir_all(&nf_tmp).ok();
538 print_success(cache, "Generated 404 page");
539
540 let css_map_local = component_css
544 .into_inner()
545 .unwrap_or_else(std::sync::PoisonError::into_inner);
546 let mut css_names: Vec<&String> = css_map_local.keys().collect();
547 css_names.sort();
548 let css_count = css_names.len();
549 for name in css_names {
550 fs::write(site_dir.join(name), &css_map_local[name])
551 .map_err(|e| Error::Io(format!("Cannot write {name}: {e}")))?;
552 }
553 if css_count > 0 {
554 print_success(cache, &format!("Wrote {css_count} component stylesheets"));
555 }
556
557 let component_count = bundle_handle
559 .join()
560 .map_err(|_| Error::Build("Bundle thread panicked".to_string()))??;
561 print_success(cache, &format!("Bundled {component_count} components"));
562
563 let elapsed = start.elapsed();
564 let total_bytes = total_bytes.load(std::sync::atomic::Ordering::Relaxed);
565 print_finish(cache, &format!("Built in {:.1}s", elapsed.as_secs_f64()));
566
567 Ok(BuildStats {
568 pages: pages.len(),
569 protocol_bytes: total_bytes,
570 })
571}
572
573fn remove_dir_all_retry(path: &Path) -> std::io::Result<()> {
592 const MAX_ATTEMPTS: u32 = 5;
593 let mut delay = std::time::Duration::from_millis(20);
594 for attempt in 1..=MAX_ATTEMPTS {
595 match fs::remove_dir_all(path) {
596 Ok(()) => return Ok(()),
597 Err(e) if attempt < MAX_ATTEMPTS && is_dir_not_empty(&e) => {
598 std::thread::sleep(delay);
599 delay = delay.saturating_mul(2);
600 }
601 Err(e) => return Err(e),
602 }
603 }
604 Ok(())
607}
608
609fn is_dir_not_empty(e: &std::io::Error) -> bool {
613 match e.raw_os_error() {
614 Some(66) => true, Some(39) => true, Some(145) => true, _ => false,
618 }
619}
620
621fn copy_dir(src: &Path, dest: &Path) -> Result<()> {
623 if !src.exists() {
624 return Ok(());
625 }
626 fs::create_dir_all(dest).map_err(|e| Error::Io(e.to_string()))?;
627 let mut stack: Vec<(std::path::PathBuf, std::path::PathBuf)> =
628 vec![(src.to_path_buf(), dest.to_path_buf())];
629 while let Some((s, d)) = stack.pop() {
630 for entry in fs::read_dir(&s).map_err(|e| Error::Io(e.to_string()))? {
631 let entry = entry.map_err(|e| Error::Io(e.to_string()))?;
632 let dest_path = d.join(entry.file_name());
633 let ft = entry.file_type().map_err(|e| Error::Io(e.to_string()))?;
634 if ft.is_dir() {
635 fs::create_dir_all(&dest_path).map_err(|e| Error::Io(e.to_string()))?;
636 stack.push((entry.path(), dest_path));
637 } else {
638 fs::copy(entry.path(), &dest_path).map_err(|e| Error::Io(e.to_string()))?;
639 }
640 }
641 }
642 Ok(())
643}
644
645pub(crate) fn truncate_utf8(s: &str, max: usize) -> &str {
647 if s.len() <= max {
648 return s;
649 }
650 let mut end = max;
651 while end > 0 && !s.is_char_boundary(end) {
652 end -= 1;
653 }
654 &s[..end]
655}
656
657pub(crate) fn build_search_entry(title: &str, path: &str, html: &str) -> Value {
662 let mut clean = String::with_capacity(html.len() / 2);
663 let mut in_tag = false;
664 for ch in html.chars() {
665 match ch {
666 '<' => in_tag = true,
667 '>' => {
668 in_tag = false;
669 clean.push(' ');
670 }
671 _ if !in_tag => clean.push(ch),
672 _ => {}
673 }
674 }
675 let mut content = String::with_capacity(clean.len());
679 let mut first = true;
680 for tok in clean.split_whitespace() {
681 if !first {
682 content.push(' ');
683 }
684 content.push_str(tok);
685 first = false;
686 }
687 let truncated = truncate_utf8(&content, 500);
688 json_obj([
689 ("title", Value::String(title.to_string())),
690 ("path", Value::String(path.to_string())),
691 ("content", Value::String(truncated.to_string())),
692 ])
693}
694
695fn collect_ts_files(dir: &Path) -> Vec<std::path::PathBuf> {
697 let mut files = Vec::new();
698 let mut stack = vec![dir.to_path_buf()];
699 while let Some(d) = stack.pop() {
700 let Ok(entries) = fs::read_dir(&d) else {
701 continue;
702 };
703 for entry in entries.flatten() {
704 let path = entry.path();
705 if path.is_dir() {
706 stack.push(path);
707 } else if path.extension().is_some_and(|e| e == "ts") {
708 files.push(path);
709 }
710 }
711 }
712 files.sort();
713 files
714}
715
716fn bundle_components(
719 template_dir: &Path,
720 component_sources: &[String],
721 site_dir: &Path,
722 node_modules: &Path,
723) -> Result<usize> {
724 let mut ts_files = Vec::new();
725
726 for dir in component_sources {
728 let p = Path::new(dir);
729 if p.exists() {
730 ts_files.extend(collect_ts_files(p));
731 }
732 }
733
734 if let Ok(entries) = fs::read_dir(template_dir) {
736 for entry in entries.flatten() {
737 if entry.path().is_dir() {
738 ts_files.extend(collect_ts_files(&entry.path()));
739 }
740 }
741 }
742
743 if ts_files.is_empty() {
744 return Ok(0);
745 }
746
747 let imports: String = ts_files
749 .iter()
750 .map(|f| format!("import \"{}\";", f.to_string_lossy().replace('\\', "/")))
751 .collect::<Vec<_>>()
752 .join("\n");
753
754 let out_file = site_dir.join("components.js");
755
756 let status = std::process::Command::new("npx")
757 .arg("esbuild")
758 .arg("--bundle")
759 .arg("--format=esm")
760 .arg("--minify")
761 .arg("--loader:.html=text")
762 .arg("--loader:.css=text")
763 .arg(format!("--outfile={}", out_file.to_string_lossy()))
764 .env("NODE_PATH", node_modules)
765 .stdin(std::process::Stdio::piped())
766 .stdout(std::process::Stdio::piped())
767 .stderr(std::process::Stdio::piped())
768 .spawn()
769 .and_then(|mut child| {
770 use std::io::Write;
771 if let Some(ref mut stdin) = child.stdin {
772 let _ = stdin.write_all(imports.as_bytes());
773 }
774 child.wait_with_output()
775 })
776 .map_err(|e| Error::Build(format!("esbuild failed: {e}")))?;
777
778 if !status.status.success() {
779 let stderr = String::from_utf8_lossy(&status.stderr);
780 return Err(Error::Build(format!("esbuild error: {stderr}")));
781 }
782
783 Ok(ts_files.len())
784}
785
786fn protect_pre_blocks(content: &str) -> (String, Vec<String>) {
791 use std::fmt::Write as _;
792 let mut blocks: Vec<String> = Vec::new();
793 let mut out = String::with_capacity(content.len());
794 let mut cursor = 0;
795 while let Some(rel_start) = find_pre_open(&content[cursor..]) {
796 let start = cursor + rel_start;
797 if let Some(rel_end) = content[start..].find("</pre>") {
798 let end = start + rel_end + "</pre>".len();
799 out.push_str(&content[cursor..start]);
800 out.push_str("<!--PRE_BLOCK_");
801 let _ = write!(&mut out, "{}", blocks.len());
803 out.push_str("-->");
804 blocks.push(content[start..end].to_string());
805 cursor = end;
806 } else {
807 break;
808 }
809 }
810 out.push_str(&content[cursor..]);
811 (out, blocks)
812}
813
814fn find_pre_open(s: &str) -> Option<usize> {
817 let bytes = s.as_bytes();
818 let mut i = 0;
819 while let Some(rel) = s[i..].find("<pre") {
820 let pos = i + rel;
821 let after = pos + 4;
822 match bytes.get(after) {
823 Some(b'>' | b' ' | b'\t' | b'\n' | b'\r') => return Some(pos),
824 _ => i = after,
825 }
826 }
827 None
828}
829
830fn restore_pre_blocks(html: &str, blocks: &[String]) -> String {
833 if blocks.is_empty() {
834 return html.to_string();
835 }
836 const PREFIX: &str = "<!--PRE_BLOCK_";
837 let extra: usize = blocks.iter().map(|b| b.len()).sum();
838 let mut out = String::with_capacity(html.len() + extra);
839 let mut cursor = 0;
840 while let Some(rel) = html[cursor..].find(PREFIX) {
841 let p = cursor + rel;
842 out.push_str(&html[cursor..p]);
843 let after = p + PREFIX.len();
844 if let Some(end_rel) = html[after..].find("-->") {
845 let num_str = &html[after..after + end_rel];
846 if let Ok(idx) = num_str.parse::<usize>() {
847 if let Some(block) = blocks.get(idx) {
848 out.push_str(block);
849 cursor = after + end_rel + 3;
850 continue;
851 }
852 }
853 out.push_str(&html[p..after + end_rel + 3]);
855 cursor = after + end_rel + 3;
856 } else {
857 out.push_str(&html[p..]);
858 return out;
859 }
860 }
861 out.push_str(&html[cursor..]);
862 out
863}
864
865fn fxhash(s: &str) -> u64 {
867 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
868 for b in s.bytes() {
869 hash ^= u64::from(b);
870 hash = hash.wrapping_mul(0x100_0000_01b3);
871 }
872 hash
873}
874
875#[cfg(test)]
876mod tests {
877 use super::*;
878
879 #[test]
882 fn truncate_utf8_short_string_unchanged() {
883 assert_eq!(truncate_utf8("hello", 100), "hello");
884 }
885
886 #[test]
887 fn truncate_utf8_ascii_at_exact_boundary() {
888 assert_eq!(truncate_utf8("hello world", 5), "hello");
889 }
890
891 #[test]
892 fn truncate_utf8_steps_back_off_multibyte_boundary() {
893 let s = "é-suffix";
895 let out = truncate_utf8(s, 1);
896 assert!(s.is_char_boundary(out.len()), "out.len()={}", out.len());
897 assert_eq!(out, "");
898 }
899
900 #[test]
901 fn truncate_utf8_keeps_multibyte_when_room_allows() {
902 let s = "café";
903 let out = truncate_utf8(s, 5); assert_eq!(out, "café");
905 }
906
907 #[test]
910 fn protect_pre_blocks_with_attrs() {
911 let input = r#"<p>before</p><pre class="hljs">code</pre><p>after</p>"#;
912 let (out, blocks) = protect_pre_blocks(input);
913 assert_eq!(blocks.len(), 1);
914 assert!(out.contains("<!--PRE_BLOCK_0-->"));
915 assert!(!out.contains("<pre"));
916 assert_eq!(blocks[0], r#"<pre class="hljs">code</pre>"#);
917 }
918
919 #[test]
920 fn protect_pre_blocks_bare_open_tag() {
921 let input = "<pre>code</pre>";
923 let (out, blocks) = protect_pre_blocks(input);
924 assert_eq!(blocks.len(), 1, "got out={out:?} blocks={blocks:?}");
925 assert_eq!(blocks[0], "<pre>code</pre>");
926 }
927
928 #[test]
929 fn protect_pre_blocks_does_not_match_presentation_tag() {
930 let input = "<presentation>x</presentation>";
932 let (_out, blocks) = protect_pre_blocks(input);
933 assert_eq!(blocks.len(), 0);
934 }
935
936 #[test]
937 fn protect_then_restore_round_trip() {
938 let input = "before <pre>a</pre> mid <pre class=\"x\">b</pre> end";
939 let (protected, blocks) = protect_pre_blocks(input);
940 let restored = restore_pre_blocks(&protected, &blocks);
941 assert_eq!(restored, input);
942 }
943
944 #[test]
945 fn restore_pre_blocks_no_blocks_returns_input() {
946 assert_eq!(restore_pre_blocks("plain html", &[]), "plain html");
947 }
948
949 #[test]
952 fn fxhash_deterministic() {
953 assert_eq!(fxhash("abc"), fxhash("abc"));
954 assert_ne!(fxhash("abc"), fxhash("abd"));
955 }
956}