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 css: webui::CssStrategy::Link,
366 dom: webui::DomStrategy::Shadow,
367 plugin: Some(webui::Plugin::WebUI),
368 components: component_sources.clone(),
369 })
370 .map_err(|e| Error::Build(format!("{}: {e}", page.path)))?;
371
372 total_bytes.fetch_add(
373 build_result.protocol_bytes.len(),
374 std::sync::atomic::Ordering::Relaxed,
375 );
376
377 if !build_result.css_files.is_empty() {
382 let mut css_map = component_css
383 .lock()
384 .unwrap_or_else(std::sync::PoisonError::into_inner);
385 for (name, content) in build_result.css_files {
386 css_map.entry(name).or_insert(content);
387 }
388 }
389
390 let mut writer = StringWriter::with_capacity(8192);
391 handler
392 .render(
393 &build_result.protocol,
394 &page.state,
395 &RenderOptions::new("index.html", &page.path),
396 &mut writer,
397 )
398 .map_err(|e| Error::Render(format!("{}: {e}", page.path)))?;
399
400 let html = restore_pre_blocks(&writer.buf, &pre_blocks);
402
403 fs::write(&target, &html)
405 .map_err(|e| Error::Io(format!("Cannot write {}: {e}", page.path)))?;
406
407 fs::remove_dir_all(&page_tmp).ok();
408 Ok(())
409 })?;
410
411 print_success(cache, &format!("Rendered {} pages", pages.len()));
412
413 let search_path = site_dir.join("search-index.json");
416 let search_entries: Vec<Value> = pages
417 .iter()
418 .filter(|p| !p.is_home)
419 .map(|p| {
420 let html = p.state["page"]["content"].as_str().unwrap_or("");
421 let title = p.state["page"]["title"].as_str().unwrap_or("");
422 build_search_entry(title, &p.path, html)
423 })
424 .collect();
425 fs::write(
426 &search_path,
427 serde_json::to_string(&search_entries)
428 .map_err(|e| Error::Build(format!("JSON error: {e}")))?,
429 )
430 .map_err(|e| Error::Io(e.to_string()))?;
431 print_success(
432 cache,
433 &format!("Indexed {} pages for search", search_entries.len()),
434 );
435
436 let public_dir = Path::new(&config.public_dir);
438 if public_dir.exists() {
439 copy_dir(public_dir, &site_dir)?;
440 }
441
442 let nav_val = pages
444 .first()
445 .map(|p| p.state["navigation"].clone())
446 .unwrap_or_default();
447
448 let footer_val = config
449 .footer
450 .as_ref()
451 .map(|f| json_obj([("html", Value::String(f.html.clone()))]))
452 .unwrap_or(Value::Null);
453
454 let not_found_content = format!(
455 "<h1>404 — Page Not Found</h1>\
456 <p>The page you're looking for doesn't exist or has been moved.</p>\
457 <p><a href=\"{base_path}\">← Back to Home</a></p>"
458 );
459 let mut not_found_state = json_obj([
460 (
461 "site",
462 json_obj([
463 ("title", Value::String(config.site.title.clone())),
464 ("base", Value::String(base_path.to_string())),
465 ]),
466 ),
467 ("navigation", nav_val),
468 ("sidebar", json_obj([("sections", Value::Array(vec![]))])),
469 (
470 "page",
471 json_obj([
472 ("title", Value::String("Page Not Found".to_string())),
473 (
474 "description",
475 Value::String("The page you're looking for doesn't exist.".to_string()),
476 ),
477 ("content", Value::String(not_found_content.clone())),
478 ("isHome", Value::Bool(false)),
479 ("layout", Value::String("doc".to_string())),
480 ]),
481 ),
482 ("hero", Value::Null),
483 ("footer", footer_val),
484 ("prev", Value::Null),
485 ("next", Value::Null),
486 ]);
487 not_found_state["headTags"] = Value::String(head_injection);
488 not_found_state["label"] = Value::String("Copy".to_string());
489 not_found_state["icon"] = Value::String("🌙".to_string());
490
491 let not_found_html = template_html.replace("{{{page.content}}}", ¬_found_content);
492 let nf_tmp = std::env::temp_dir().join(format!(
493 "webui-press-404-{}-{:x}",
494 std::process::id(),
495 REBUILD_NONCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
496 ));
497 if nf_tmp.exists() {
498 fs::remove_dir_all(&nf_tmp).ok();
499 }
500 fs::create_dir_all(&nf_tmp).map_err(|e| Error::Io(e.to_string()))?;
501 fs::write(nf_tmp.join("index.html"), ¬_found_html).map_err(|e| Error::Io(e.to_string()))?;
502
503 let nf_build = webui::build(BuildOptions {
504 app_dir: nf_tmp.clone(),
505 entry: "index.html".to_string(),
506 css: webui::CssStrategy::Link,
507 dom: webui::DomStrategy::Shadow,
508 plugin: Some(webui::Plugin::WebUI),
509 components: component_sources.clone(),
510 })
511 .map_err(|e| Error::Build(format!("404 build failed: {e}")))?;
512
513 {
518 let mut css_map = component_css
519 .lock()
520 .unwrap_or_else(std::sync::PoisonError::into_inner);
521 for (name, content) in &nf_build.css_files {
522 css_map
523 .entry(name.clone())
524 .or_insert_with(|| content.clone());
525 }
526 }
527
528 let mut writer_404 = StringWriter::with_capacity(4096);
529 handler
530 .render(
531 &nf_build.protocol,
532 ¬_found_state,
533 &RenderOptions::new("index.html", &format!("{base_path}404/")),
534 &mut writer_404,
535 )
536 .map_err(|e| Error::Render(format!("404: {e}")))?;
537
538 fs::write(site_dir.join("404.html"), writer_404.buf).map_err(|e| Error::Io(e.to_string()))?;
539 fs::remove_dir_all(&nf_tmp).ok();
540 print_success(cache, "Generated 404 page");
541
542 let css_map_local = component_css
546 .into_inner()
547 .unwrap_or_else(std::sync::PoisonError::into_inner);
548 let mut css_names: Vec<&String> = css_map_local.keys().collect();
549 css_names.sort();
550 let css_count = css_names.len();
551 for name in css_names {
552 fs::write(site_dir.join(name), &css_map_local[name])
553 .map_err(|e| Error::Io(format!("Cannot write {name}: {e}")))?;
554 }
555 if css_count > 0 {
556 print_success(cache, &format!("Wrote {css_count} component stylesheets"));
557 }
558
559 let component_count = bundle_handle
561 .join()
562 .map_err(|_| Error::Build("Bundle thread panicked".to_string()))??;
563 print_success(cache, &format!("Bundled {component_count} components"));
564
565 let elapsed = start.elapsed();
566 let total_bytes = total_bytes.load(std::sync::atomic::Ordering::Relaxed);
567 print_finish(cache, &format!("Built in {:.1}s", elapsed.as_secs_f64()));
568
569 Ok(BuildStats {
570 pages: pages.len(),
571 protocol_bytes: total_bytes,
572 })
573}
574
575fn remove_dir_all_retry(path: &Path) -> std::io::Result<()> {
594 const MAX_ATTEMPTS: u32 = 5;
595 let mut delay = std::time::Duration::from_millis(20);
596 for attempt in 1..=MAX_ATTEMPTS {
597 match fs::remove_dir_all(path) {
598 Ok(()) => return Ok(()),
599 Err(e) if attempt < MAX_ATTEMPTS && is_dir_not_empty(&e) => {
600 std::thread::sleep(delay);
601 delay = delay.saturating_mul(2);
602 }
603 Err(e) => return Err(e),
604 }
605 }
606 Ok(())
609}
610
611fn is_dir_not_empty(e: &std::io::Error) -> bool {
615 match e.raw_os_error() {
616 Some(66) => true, Some(39) => true, Some(145) => true, _ => false,
620 }
621}
622
623fn copy_dir(src: &Path, dest: &Path) -> Result<()> {
625 if !src.exists() {
626 return Ok(());
627 }
628 fs::create_dir_all(dest).map_err(|e| Error::Io(e.to_string()))?;
629 let mut stack: Vec<(std::path::PathBuf, std::path::PathBuf)> =
630 vec![(src.to_path_buf(), dest.to_path_buf())];
631 while let Some((s, d)) = stack.pop() {
632 for entry in fs::read_dir(&s).map_err(|e| Error::Io(e.to_string()))? {
633 let entry = entry.map_err(|e| Error::Io(e.to_string()))?;
634 let dest_path = d.join(entry.file_name());
635 let ft = entry.file_type().map_err(|e| Error::Io(e.to_string()))?;
636 if ft.is_dir() {
637 fs::create_dir_all(&dest_path).map_err(|e| Error::Io(e.to_string()))?;
638 stack.push((entry.path(), dest_path));
639 } else {
640 fs::copy(entry.path(), &dest_path).map_err(|e| Error::Io(e.to_string()))?;
641 }
642 }
643 }
644 Ok(())
645}
646
647pub(crate) fn truncate_utf8(s: &str, max: usize) -> &str {
649 if s.len() <= max {
650 return s;
651 }
652 let mut end = max;
653 while end > 0 && !s.is_char_boundary(end) {
654 end -= 1;
655 }
656 &s[..end]
657}
658
659pub(crate) fn build_search_entry(title: &str, path: &str, html: &str) -> Value {
664 let mut clean = String::with_capacity(html.len() / 2);
665 let mut in_tag = false;
666 for ch in html.chars() {
667 match ch {
668 '<' => in_tag = true,
669 '>' => {
670 in_tag = false;
671 clean.push(' ');
672 }
673 _ if !in_tag => clean.push(ch),
674 _ => {}
675 }
676 }
677 let mut content = String::with_capacity(clean.len());
681 let mut first = true;
682 for tok in clean.split_whitespace() {
683 if !first {
684 content.push(' ');
685 }
686 content.push_str(tok);
687 first = false;
688 }
689 let truncated = truncate_utf8(&content, 500);
690 json_obj([
691 ("title", Value::String(title.to_string())),
692 ("path", Value::String(path.to_string())),
693 ("content", Value::String(truncated.to_string())),
694 ])
695}
696
697fn collect_ts_files(dir: &Path) -> Vec<std::path::PathBuf> {
699 let mut files = Vec::new();
700 let mut stack = vec![dir.to_path_buf()];
701 while let Some(d) = stack.pop() {
702 let Ok(entries) = fs::read_dir(&d) else {
703 continue;
704 };
705 for entry in entries.flatten() {
706 let path = entry.path();
707 if path.is_dir() {
708 stack.push(path);
709 } else if path.extension().is_some_and(|e| e == "ts") {
710 files.push(path);
711 }
712 }
713 }
714 files.sort();
715 files
716}
717
718fn bundle_components(
721 template_dir: &Path,
722 component_sources: &[String],
723 site_dir: &Path,
724 node_modules: &Path,
725) -> Result<usize> {
726 let mut ts_files = Vec::new();
727
728 for dir in component_sources {
730 let p = Path::new(dir);
731 if p.exists() {
732 ts_files.extend(collect_ts_files(p));
733 }
734 }
735
736 if let Ok(entries) = fs::read_dir(template_dir) {
738 for entry in entries.flatten() {
739 if entry.path().is_dir() {
740 ts_files.extend(collect_ts_files(&entry.path()));
741 }
742 }
743 }
744
745 if ts_files.is_empty() {
746 return Ok(0);
747 }
748
749 let imports: String = ts_files
751 .iter()
752 .map(|f| format!("import \"{}\";", f.to_string_lossy().replace('\\', "/")))
753 .collect::<Vec<_>>()
754 .join("\n");
755
756 let out_file = site_dir.join("components.js");
757
758 let status = std::process::Command::new("npx")
759 .arg("esbuild")
760 .arg("--bundle")
761 .arg("--format=esm")
762 .arg("--minify")
763 .arg("--loader:.html=text")
764 .arg("--loader:.css=text")
765 .arg(format!("--outfile={}", out_file.to_string_lossy()))
766 .env("NODE_PATH", node_modules)
767 .stdin(std::process::Stdio::piped())
768 .stdout(std::process::Stdio::piped())
769 .stderr(std::process::Stdio::piped())
770 .spawn()
771 .and_then(|mut child| {
772 use std::io::Write;
773 if let Some(ref mut stdin) = child.stdin {
774 let _ = stdin.write_all(imports.as_bytes());
775 }
776 child.wait_with_output()
777 })
778 .map_err(|e| Error::Build(format!("esbuild failed: {e}")))?;
779
780 if !status.status.success() {
781 let stderr = String::from_utf8_lossy(&status.stderr);
782 return Err(Error::Build(format!("esbuild error: {stderr}")));
783 }
784
785 Ok(ts_files.len())
786}
787
788fn protect_pre_blocks(content: &str) -> (String, Vec<String>) {
793 use std::fmt::Write as _;
794 let mut blocks: Vec<String> = Vec::new();
795 let mut out = String::with_capacity(content.len());
796 let mut cursor = 0;
797 while let Some(rel_start) = find_pre_open(&content[cursor..]) {
798 let start = cursor + rel_start;
799 if let Some(rel_end) = content[start..].find("</pre>") {
800 let end = start + rel_end + "</pre>".len();
801 out.push_str(&content[cursor..start]);
802 out.push_str("<!--PRE_BLOCK_");
803 let _ = write!(&mut out, "{}", blocks.len());
805 out.push_str("-->");
806 blocks.push(content[start..end].to_string());
807 cursor = end;
808 } else {
809 break;
810 }
811 }
812 out.push_str(&content[cursor..]);
813 (out, blocks)
814}
815
816fn find_pre_open(s: &str) -> Option<usize> {
819 let bytes = s.as_bytes();
820 let mut i = 0;
821 while let Some(rel) = s[i..].find("<pre") {
822 let pos = i + rel;
823 let after = pos + 4;
824 match bytes.get(after) {
825 Some(b'>' | b' ' | b'\t' | b'\n' | b'\r') => return Some(pos),
826 _ => i = after,
827 }
828 }
829 None
830}
831
832fn restore_pre_blocks(html: &str, blocks: &[String]) -> String {
835 if blocks.is_empty() {
836 return html.to_string();
837 }
838 const PREFIX: &str = "<!--PRE_BLOCK_";
839 let extra: usize = blocks.iter().map(|b| b.len()).sum();
840 let mut out = String::with_capacity(html.len() + extra);
841 let mut cursor = 0;
842 while let Some(rel) = html[cursor..].find(PREFIX) {
843 let p = cursor + rel;
844 out.push_str(&html[cursor..p]);
845 let after = p + PREFIX.len();
846 if let Some(end_rel) = html[after..].find("-->") {
847 let num_str = &html[after..after + end_rel];
848 if let Ok(idx) = num_str.parse::<usize>() {
849 if let Some(block) = blocks.get(idx) {
850 out.push_str(block);
851 cursor = after + end_rel + 3;
852 continue;
853 }
854 }
855 out.push_str(&html[p..after + end_rel + 3]);
857 cursor = after + end_rel + 3;
858 } else {
859 out.push_str(&html[p..]);
860 return out;
861 }
862 }
863 out.push_str(&html[cursor..]);
864 out
865}
866
867fn fxhash(s: &str) -> u64 {
869 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
870 for b in s.bytes() {
871 hash ^= u64::from(b);
872 hash = hash.wrapping_mul(0x100_0000_01b3);
873 }
874 hash
875}
876
877#[cfg(test)]
878mod tests {
879 use super::*;
880
881 #[test]
884 fn truncate_utf8_short_string_unchanged() {
885 assert_eq!(truncate_utf8("hello", 100), "hello");
886 }
887
888 #[test]
889 fn truncate_utf8_ascii_at_exact_boundary() {
890 assert_eq!(truncate_utf8("hello world", 5), "hello");
891 }
892
893 #[test]
894 fn truncate_utf8_steps_back_off_multibyte_boundary() {
895 let s = "é-suffix";
897 let out = truncate_utf8(s, 1);
898 assert!(s.is_char_boundary(out.len()), "out.len()={}", out.len());
899 assert_eq!(out, "");
900 }
901
902 #[test]
903 fn truncate_utf8_keeps_multibyte_when_room_allows() {
904 let s = "café";
905 let out = truncate_utf8(s, 5); assert_eq!(out, "café");
907 }
908
909 #[test]
912 fn protect_pre_blocks_with_attrs() {
913 let input = r#"<p>before</p><pre class="hljs">code</pre><p>after</p>"#;
914 let (out, blocks) = protect_pre_blocks(input);
915 assert_eq!(blocks.len(), 1);
916 assert!(out.contains("<!--PRE_BLOCK_0-->"));
917 assert!(!out.contains("<pre"));
918 assert_eq!(blocks[0], r#"<pre class="hljs">code</pre>"#);
919 }
920
921 #[test]
922 fn protect_pre_blocks_bare_open_tag() {
923 let input = "<pre>code</pre>";
925 let (out, blocks) = protect_pre_blocks(input);
926 assert_eq!(blocks.len(), 1, "got out={out:?} blocks={blocks:?}");
927 assert_eq!(blocks[0], "<pre>code</pre>");
928 }
929
930 #[test]
931 fn protect_pre_blocks_does_not_match_presentation_tag() {
932 let input = "<presentation>x</presentation>";
934 let (_out, blocks) = protect_pre_blocks(input);
935 assert_eq!(blocks.len(), 0);
936 }
937
938 #[test]
939 fn protect_then_restore_round_trip() {
940 let input = "before <pre>a</pre> mid <pre class=\"x\">b</pre> end";
941 let (protected, blocks) = protect_pre_blocks(input);
942 let restored = restore_pre_blocks(&protected, &blocks);
943 assert_eq!(restored, input);
944 }
945
946 #[test]
947 fn restore_pre_blocks_no_blocks_returns_input() {
948 assert_eq!(restore_pre_blocks("plain html", &[]), "plain html");
949 }
950
951 #[test]
954 fn fxhash_deterministic() {
955 assert_eq!(fxhash("abc"), fxhash("abc"));
956 assert_ne!(fxhash("abc"), fxhash("abd"));
957 }
958}