1use std::collections::HashMap;
7use std::fs;
8use std::path::{Path, PathBuf};
9use std::sync::atomic::AtomicU64;
10use std::sync::{Arc, OnceLock};
11use std::time::Instant;
12
13use console::style;
14use rayon::prelude::*;
15use serde_json::{Map, Value};
16use webui::BuildOptions;
17use webui_handler::{Protocol, RenderOptions, ResponseWriter, WebUIHandler};
18use webui_tokens::TokenFile;
19
20use crate::bundler::{
21 bundle_assets, collect_component_scripts_for_html, discover_component_scripts,
22 extract_bundle_scripts, inject_module_script_tags, inject_script_tag, module_script_tag,
23 page_bundle_signature, resolve_config_component_source, resolve_node_modules, BundleOptions,
24 BundleResult, BundleThread, PageBundleEntry, RootBundleEntry, ScriptSource,
25};
26use crate::content::process_content_with_states;
27use crate::error::{Error, Result};
28use crate::markdown::Highlighter;
29use crate::state::{load_render_states, merge_page_state};
30use crate::types::{BuildStats, DocsConfig};
31
32#[derive(Default)]
42pub struct BuildCache {
43 pub(crate) highlighter: Option<Highlighter>,
46 pub quiet: bool,
50 pub skip_clean: bool,
55 pub dev_mode: bool,
58}
59
60impl BuildCache {
61 #[must_use]
65 pub fn new() -> Self {
66 Self::default()
67 }
68
69 pub fn set_dev_rebuild(&mut self) {
72 self.quiet = true;
73 self.skip_clean = true;
74 self.dev_mode = true;
75 }
76}
77
78fn load_theme(theme: &str, config_dir: &Path) -> Result<TokenFile> {
79 let config_relative = config_dir.join(theme);
80 let resolved = if config_relative.exists() {
81 config_relative
82 .canonicalize()
83 .map_err(|e| Error::Build(format!("Failed to canonicalize {theme}: {e}")))?
84 } else {
85 webui_tokens::resolve_theme_path(theme, config_dir)
86 .map_err(|e| Error::Build(format!("Failed to resolve theme {theme}: {e}")))?
87 };
88 webui_tokens::load_token_file(&resolved)
89 .map_err(|e| Error::Build(format!("Failed to load theme {}: {e}", resolved.display())))
90}
91
92fn inject_theme_tokens(
93 state: &mut Value,
94 token_file: &TokenFile,
95 protocol_tokens: &[String],
96) -> Result<()> {
97 let resolved = webui_tokens::resolve_tokens(protocol_tokens, token_file)
98 .map_err(|e| Error::Build(format!("Failed to resolve theme tokens: {e}")))?;
99 webui_tokens::inject_into_state(state, &resolved);
100 Ok(())
101}
102
103struct NotFoundStateInput<'a> {
104 config: &'a DocsConfig,
105 base_path: &'a str,
106 nav: Value,
107 footer: Value,
108 content: &'a str,
109 head_injection: &'a str,
110 global_state: Option<&'a Value>,
111}
112
113fn build_not_found_state(input: NotFoundStateInput<'_>) -> Value {
114 let state = json_obj([
115 (
116 "site",
117 json_obj([
118 ("title", Value::String(input.config.site.title.clone())),
119 ("base", Value::String(input.base_path.to_string())),
120 ]),
121 ),
122 ("navigation", input.nav),
123 ("sidebar", json_obj([("sections", Value::Array(vec![]))])),
124 (
125 "page",
126 json_obj([
127 ("title", Value::String("Page Not Found".to_string())),
128 (
129 "description",
130 Value::String("The page you're looking for doesn't exist.".to_string()),
131 ),
132 ("content", Value::String(input.content.to_string())),
133 ("isHome", Value::Bool(false)),
134 ("layout", Value::String("doc".to_string())),
135 ]),
136 ),
137 ("hero", Value::Null),
138 ("footer", input.footer),
139 ("prev", Value::Null),
140 ("next", Value::Null),
141 ("pageData", Value::Null),
142 ("headTags", Value::String(input.head_injection.to_string())),
143 ("label", Value::String("Copy".to_string())),
144 ("icon", Value::String("🌙".to_string())),
145 ]);
146
147 merge_page_state(state, input.global_state, None)
148}
149
150static REBUILD_NONCE: AtomicU64 = AtomicU64::new(0);
161
162fn print_header(cache: &BuildCache, title: &str) {
163 if cache.quiet {
164 return;
165 }
166 eprintln!(
167 "\n {} {}",
168 style("⚡").cyan().bold(),
169 style(title).cyan().bold()
170 );
171}
172
173fn print_success(cache: &BuildCache, message: &str) {
174 if cache.quiet {
175 return;
176 }
177 eprintln!(" {} {message}", style("✔").green());
178}
179
180fn print_bundle_summary(cache: &BuildCache, result: &BundleResult) {
181 print_success(
182 cache,
183 &format!(
184 "Bundled {} component script{} into {} root script{} and {} page script group{} in {:.0}ms",
185 result.component_count,
186 if result.component_count == 1 { "" } else { "s" },
187 usize::from(result.root_script.is_some()),
188 if result.root_script.is_some() {
189 ""
190 } else {
191 "s"
192 },
193 result.page_entry_count,
194 if result.page_entry_count == 1 {
195 ""
196 } else {
197 "s"
198 },
199 result.duration.as_secs_f64() * 1000.0
200 ),
201 );
202}
203
204fn print_finish(cache: &BuildCache, message: &str) {
205 if cache.quiet {
206 return;
207 }
208 eprintln!("\n {} {message}\n", style("✨").green());
209}
210
211fn json_obj<const N: usize>(entries: [(&str, Value); N]) -> Value {
213 let mut map = Map::with_capacity(N);
214 for (k, v) in entries {
215 map.insert(k.to_string(), v);
216 }
217 Value::Object(map)
218}
219
220struct StringWriter {
222 buf: String,
223}
224
225impl StringWriter {
226 fn with_capacity(cap: usize) -> Self {
227 Self {
228 buf: String::with_capacity(cap),
229 }
230 }
231}
232
233impl ResponseWriter for StringWriter {
234 fn write(&mut self, content: &str) -> webui_handler::Result<()> {
235 self.buf.push_str(content);
236 Ok(())
237 }
238
239 fn end(&mut self) -> webui_handler::Result<()> {
240 Ok(())
241 }
242}
243
244pub fn build_docs(
254 config: &DocsConfig,
255 config_dir: &Path,
256 template_dir: &Path,
257) -> Result<BuildStats> {
258 let mut cache = BuildCache::new();
259 build_docs_with_cache(config, config_dir, template_dir, &mut cache)
260}
261
262pub fn build_docs_with_cache(
275 config: &DocsConfig,
276 config_dir: &Path,
277 template_dir: &Path,
278 cache: &mut BuildCache,
279) -> Result<BuildStats> {
280 let start = Instant::now();
281 let base_path = &config.base_path;
282 let out_dir = Path::new(&config.out_dir);
283
284 let site_dir = out_dir.to_path_buf();
290
291 let custom_css = config
293 .css
294 .as_ref()
295 .and_then(|p| fs::read_to_string(p).ok())
296 .unwrap_or_default();
297 let token_file = match &config.theme {
298 Some(theme) => Some(load_theme(theme, config_dir)?),
299 None => None,
300 };
301
302 print_header(cache, &config.site.title);
303 eprintln!();
304
305 let base_css_src = template_dir.join("docs.css");
314 let has_base_css = base_css_src.exists();
315 let base_css_link = if has_base_css {
316 format!("<link rel=\"stylesheet\" href=\"{base_path}docs.css\">")
317 } else {
318 String::new()
319 };
320 let has_theme_css = !custom_css.is_empty();
321 let theme_css_link = if has_theme_css {
322 format!("<link rel=\"stylesheet\" href=\"{base_path}theme.css\">")
323 } else {
324 String::new()
325 };
326 let head_injection = {
327 let mut parts: Vec<String> = Vec::with_capacity(2 + config.head.len());
328 if !base_css_link.is_empty() {
329 parts.push(base_css_link.clone());
330 }
331 if !theme_css_link.is_empty() {
332 parts.push(theme_css_link.clone());
333 }
334 for tag in &config.head {
335 parts.push(tag.to_html());
336 }
337 parts.join("\n ")
338 };
339
340 let render_states = load_render_states(config, config_dir)?;
344 let highlighter = cache.highlighter.take().unwrap_or_default();
345 let pages = process_content_with_states(config, &highlighter, &head_injection, &render_states)?;
346
347 cache.highlighter = Some(highlighter);
349
350 let mut component_sources: Vec<String> = Vec::new();
352 let cwd = std::env::current_dir().unwrap_or_default();
353 let builtin_components = template_dir.parent().map(|p| p.join("components"));
355 if let Some(ref bc) = builtin_components {
356 if bc.exists() {
357 component_sources.push(bc.to_string_lossy().to_string());
358 }
359 }
360 if let Some(ref user_sources) = config.components {
364 for source in user_sources {
365 component_sources.push(resolve_config_component_source(source, &cwd));
366 }
367 }
368 component_sources.push(template_dir.to_string_lossy().to_string());
371
372 let template_html = fs::read_to_string(template_dir.join("index.html"))
373 .map_err(|e| Error::Build(format!("Failed to read template: {e}")))?;
374 let component_script_index = discover_component_scripts(&component_sources)?;
375
376 if out_dir.exists() && !cache.skip_clean {
381 remove_dir_all_retry(out_dir)
382 .map_err(|e| Error::Io(format!("Cannot clean output dir: {e}")))?;
383 }
384 fs::create_dir_all(&site_dir).map_err(|e| Error::Io(format!("Cannot create site dir: {e}")))?;
385
386 if has_base_css {
392 fs::copy(&base_css_src, site_dir.join("docs.css"))
393 .map_err(|e| Error::Io(format!("Cannot copy docs.css: {e}")))?;
394 }
395 if has_theme_css {
396 fs::write(site_dir.join("theme.css"), &custom_css)
397 .map_err(|e| Error::Io(format!("Cannot write theme.css: {e}")))?;
398 }
399
400 let handler = WebUIHandler::with_plugin(|| {
401 Box::new(webui_handler::plugin::webui::WebUIHydrationPlugin::new())
402 });
403
404 for page in &pages {
407 let page_dir = site_dir.join(page.path.strip_prefix(base_path).unwrap_or(&page.path));
408 fs::create_dir_all(&page_dir)
409 .map_err(|e| Error::Io(format!("Cannot create dir {}: {e}", page_dir.display())))?;
410 }
411
412 let mut explicit_page_scripts: HashMap<String, Vec<ScriptSource>> = HashMap::new();
419 let mut page_contents_with_scripts: HashMap<String, String> = HashMap::new();
420
421 for page in &pages {
422 let content = page.state["page"]["content"].as_str().unwrap_or("");
423 if content.contains("<script") && content.contains("bundle") {
424 let (modified, scripts) = extract_bundle_scripts(content);
425 if !scripts.is_empty() {
426 page_contents_with_scripts.insert(page.path.clone(), modified);
427 explicit_page_scripts
428 .entry(page.path.clone())
429 .or_default()
430 .extend(scripts);
431 }
432 }
433 }
434
435 for (link, custom_page) in &config.custom_pages {
437 if let Some(script_path) = custom_page.script_file() {
438 let normalized = if link.ends_with('/') {
441 link.clone()
442 } else {
443 format!("{link}/")
444 };
445 let page_path = format!("{}{}", base_path, &normalized[1..]);
446 explicit_page_scripts
447 .entry(page_path)
448 .or_default()
449 .push(ScriptSource::File(script_path.to_string()));
450 }
451 }
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
459 let template_script_path = template_dir.join("index.ts");
460 let template_script = if template_script_path.exists() {
461 Some(
462 template_script_path
463 .canonicalize()
464 .unwrap_or(template_script_path),
465 )
466 } else {
467 None
468 };
469 let root_component_scripts =
470 collect_component_scripts_for_html(&[template_html.as_str()], &component_script_index)?;
471 let root_bundle = if template_script.is_some() || !root_component_scripts.is_empty() {
472 Some(RootBundleEntry {
473 script_path: template_script,
474 component_scripts: root_component_scripts,
475 })
476 } else {
477 None
478 };
479
480 let mut page_bundles: Vec<PageBundleEntry> = Vec::new();
484 let mut page_bundle_ids: HashMap<String, usize> = HashMap::with_capacity(pages.len());
485 let mut page_bundle_signatures: HashMap<String, usize> = HashMap::new();
486 for page in &pages {
487 let content = page_contents_with_scripts
488 .get(&page.path)
489 .map(String::as_str)
490 .unwrap_or_else(|| page.state["page"]["content"].as_str().unwrap_or(""));
491 let mut component_scripts =
492 collect_component_scripts_for_html(&[content], &component_script_index)?;
493 if let Some(root) = &root_bundle {
494 component_scripts.retain(|path| !root.component_scripts.contains(path));
495 }
496 let explicit_scripts = explicit_page_scripts.remove(&page.path).unwrap_or_default();
497 if component_scripts.is_empty() && explicit_scripts.is_empty() {
498 continue;
499 }
500
501 let signature = page_bundle_signature(&component_scripts, &explicit_scripts, config_dir);
502 if let Some(&id) = page_bundle_signatures.get(&signature) {
503 page_bundle_ids.insert(page.path.clone(), id);
504 continue;
505 }
506
507 let id = page_bundles.len();
508 page_bundle_signatures.insert(signature, id);
509 page_bundle_ids.insert(page.path.clone(), id);
510 page_bundles.push(PageBundleEntry {
511 id,
512 page_path: page.path.clone(),
513 component_scripts,
514 explicit_scripts,
515 });
516 }
517
518 let not_found_component_scripts =
519 collect_component_scripts_for_html(&[not_found_content.as_str()], &component_script_index)?;
520 let not_found_bundle_id = if not_found_component_scripts.is_empty() {
521 None
522 } else {
523 let signature = page_bundle_signature(¬_found_component_scripts, &[], config_dir);
524 if let Some(&id) = page_bundle_signatures.get(&signature) {
525 Some(id)
526 } else {
527 let id = page_bundles.len();
528 page_bundle_signatures.insert(signature, id);
529 page_bundles.push(PageBundleEntry {
530 id,
531 page_path: format!("{base_path}404/"),
532 component_scripts: not_found_component_scripts,
533 explicit_scripts: Vec::new(),
534 });
535 Some(id)
536 }
537 };
538
539 let node_modules = if root_bundle.is_some() || !page_bundles.is_empty() {
542 Some(resolve_node_modules(config_dir)?)
543 } else {
544 None
545 };
546 let (projection_source, projection_completer) = webui::projection_manifest_barrier();
547 let site_dir_for_bundle = site_dir.clone();
548 let root_bundle_for_bundle = root_bundle.clone();
549 let page_bundles_for_bundle = page_bundles.clone();
550 let bundler_config = config.bundler.clone();
551 let config_dir_for_bundle = config_dir.to_path_buf();
552 let content_dir_for_bundle = PathBuf::from(&config.content_dir);
553 let base_path_for_bundle = base_path.to_string();
554 let dev_mode = cache.dev_mode;
555 let generated_preloads = Arc::new(OnceLock::new());
559 let generated_preloads_for_bundle = Arc::clone(&generated_preloads);
560 let bundle_thread = BundleThread::spawn(move || {
561 match bundle_assets(&BundleOptions {
562 site_dir: &site_dir_for_bundle,
563 base_path: &base_path_for_bundle,
564 node_modules: node_modules.as_deref(),
565 root_bundle: root_bundle_for_bundle.as_ref(),
566 page_bundles: &page_bundles_for_bundle,
567 bundler_config: bundler_config.as_ref(),
568 dev_mode,
569 config_dir: &config_dir_for_bundle,
570 content_dir: &content_dir_for_bundle,
571 }) {
572 Ok(result) => {
573 if generated_preloads_for_bundle
574 .set(result.preloads.clone())
575 .is_err()
576 {
577 let message = "Generated preload metadata was published twice".to_string();
578 projection_completer.complete(Err(message.clone()));
579 return Err(Error::Build(message));
580 }
581 projection_completer.complete(Ok(result.projection.clone()));
582 Ok(result)
583 }
584 Err(error) => {
585 projection_completer.complete(Err(error.to_string()));
586 Err(error)
587 }
588 }
589 });
590
591 let total_bytes = std::sync::atomic::AtomicUsize::new(0);
601 let component_css: std::sync::Mutex<HashMap<String, String>> =
602 std::sync::Mutex::new(HashMap::new());
603
604 let page_start = Instant::now();
605 pages.par_iter().try_for_each(|page| -> Result<()> {
606 let page_dir = site_dir.join(page.path.strip_prefix(base_path).unwrap_or(&page.path));
607 let target = page_dir.join("index.html");
608
609 let content = page_contents_with_scripts
611 .get(&page.path)
612 .map(|s| s.as_str())
613 .unwrap_or_else(|| page.state["page"]["content"].as_str().unwrap_or(""));
614
615 let (protected, pre_blocks) = protect_pre_blocks(content);
617
618 let page_html = template_html.replace("{{{page.content}}}", &protected);
620
621 let nonce = REBUILD_NONCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
630 let page_tmp = std::env::temp_dir().join(format!(
631 "webui-press-page-{}-{:x}-{nonce:x}",
632 std::process::id(),
633 fxhash(&page.path),
634 ));
635 if page_tmp.exists() {
636 fs::remove_dir_all(&page_tmp).ok();
637 }
638 fs::create_dir_all(&page_tmp)
639 .map_err(|e| Error::Io(format!("Cannot create page temp: {e}")))?;
640 fs::write(page_tmp.join("index.html"), &page_html)
641 .map_err(|e| Error::Io(format!("Cannot write page temp: {e}")))?;
642
643 let mut build_result = webui::build(BuildOptions {
644 app_dir: page_tmp.clone(),
645 entry: "index.html".to_string(),
646 plugin: Some(webui::Plugin::WebUI),
647 components: component_sources.clone(),
648 theme: token_file.clone(),
649 projection_manifests: vec![projection_source.clone()],
650 ..BuildOptions::default()
651 })
652 .map_err(|e| Error::Build(format!("{}: {e}", page.path)))?;
653 let preloads = generated_preloads.get().ok_or_else(|| {
654 Error::Build("Generated preload metadata was not published".to_string())
655 })?;
656 let (hrefs, mut preload_warnings) = preloads.resolve(
657 &build_result.protocol.module_preloads,
658 page_bundle_ids.get(&page.path).copied(),
659 );
660 build_result.protocol.module_preloads = hrefs;
661 build_result.warnings.append(&mut preload_warnings);
662
663 total_bytes.fetch_add(
664 build_result.protocol_bytes.len(),
665 std::sync::atomic::Ordering::Relaxed,
666 );
667
668 if !build_result.css_files.is_empty() {
673 let mut css_map = component_css
674 .lock()
675 .unwrap_or_else(std::sync::PoisonError::into_inner);
676 for (name, content) in build_result.css_files {
677 css_map.entry(name).or_insert(content);
678 }
679 }
680
681 let mut themed_state;
682 let render_state = if let Some(token_file) = token_file.as_ref() {
683 themed_state = page.state.clone();
684 inject_theme_tokens(&mut themed_state, token_file, &build_result.protocol.tokens)?;
685 &themed_state
686 } else {
687 &page.state
688 };
689
690 let protocol = Protocol::new(build_result.protocol);
691 let mut writer = StringWriter::with_capacity(8192);
692 handler
693 .render(
694 &protocol,
695 render_state,
696 &RenderOptions::new("index.html", &page.path),
697 &mut writer,
698 )
699 .map_err(|e| Error::Render(format!("{}: {e}", page.path)))?;
700
701 let html = restore_pre_blocks(&writer.buf, &pre_blocks);
703
704 fs::write(&target, &html)
706 .map_err(|e| Error::Io(format!("Cannot write {}: {e}", page.path)))?;
707
708 fs::remove_dir_all(&page_tmp).ok();
709 Ok(())
710 })?;
711
712 print_success(
713 cache,
714 &format!(
715 "Rendered {} pages in {:.0}ms",
716 pages.len(),
717 page_start.elapsed().as_secs_f64() * 1000.0
718 ),
719 );
720 let bundle_result = bundle_thread.join()?;
721 print_bundle_summary(cache, &bundle_result);
722
723 let search_path = site_dir.join("search-index.json");
726 let search_entries: Vec<Value> = pages
727 .iter()
728 .filter(|p| !p.is_home)
729 .map(|p| {
730 let html = p.state["page"]["content"].as_str().unwrap_or("");
731 let title = p.state["page"]["title"].as_str().unwrap_or("");
732 build_search_entry(title, &p.path, html)
733 })
734 .collect();
735 fs::write(
736 &search_path,
737 serde_json::to_string(&search_entries)
738 .map_err(|e| Error::Build(format!("JSON error: {e}")))?,
739 )
740 .map_err(|e| Error::Io(e.to_string()))?;
741 print_success(
742 cache,
743 &format!("Indexed {} pages for search", search_entries.len()),
744 );
745
746 let public_dir = Path::new(&config.public_dir);
748 if public_dir.exists() {
749 copy_dir(public_dir, &site_dir)?;
750 }
751
752 let nav_val = pages
754 .first()
755 .map(|p| p.state["navigation"].clone())
756 .unwrap_or_default();
757
758 let footer_val = config
759 .footer
760 .as_ref()
761 .map(|f| json_obj([("html", Value::String(f.html.clone()))]))
762 .unwrap_or(Value::Null);
763
764 let mut not_found_state = build_not_found_state(NotFoundStateInput {
765 config,
766 base_path,
767 nav: nav_val,
768 footer: footer_val,
769 content: ¬_found_content,
770 head_injection: &head_injection,
771 global_state: render_states.global(),
772 });
773
774 let not_found_html = template_html.replace("{{{page.content}}}", ¬_found_content);
775 let nf_tmp = std::env::temp_dir().join(format!(
776 "webui-press-404-{}-{:x}",
777 std::process::id(),
778 REBUILD_NONCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
779 ));
780 if nf_tmp.exists() {
781 fs::remove_dir_all(&nf_tmp).ok();
782 }
783 fs::create_dir_all(&nf_tmp).map_err(|e| Error::Io(e.to_string()))?;
784 fs::write(nf_tmp.join("index.html"), ¬_found_html).map_err(|e| Error::Io(e.to_string()))?;
785
786 let mut nf_build = webui::build(BuildOptions {
787 app_dir: nf_tmp.clone(),
788 entry: "index.html".to_string(),
789 plugin: Some(webui::Plugin::WebUI),
790 components: component_sources.clone(),
791 theme: token_file.clone(),
792 projection_manifests: vec![projection_source],
793 ..BuildOptions::default()
794 })
795 .map_err(|e| Error::Build(format!("404 build failed: {e}")))?;
796 let preloads = generated_preloads
797 .get()
798 .ok_or_else(|| Error::Build("Generated preload metadata was not published".to_string()))?;
799 let (hrefs, mut preload_warnings) =
800 preloads.resolve(&nf_build.protocol.module_preloads, not_found_bundle_id);
801 nf_build.protocol.module_preloads = hrefs;
802 nf_build.warnings.append(&mut preload_warnings);
803
804 if let Some(token_file) = token_file.as_ref() {
805 inject_theme_tokens(&mut not_found_state, token_file, &nf_build.protocol.tokens)?;
806 }
807
808 {
813 let mut css_map = component_css
814 .lock()
815 .unwrap_or_else(std::sync::PoisonError::into_inner);
816 for (name, content) in &nf_build.css_files {
817 css_map
818 .entry(name.clone())
819 .or_insert_with(|| content.clone());
820 }
821 }
822
823 let nf_protocol = Protocol::new(nf_build.protocol);
824 let mut writer_404 = StringWriter::with_capacity(4096);
825 handler
826 .render(
827 &nf_protocol,
828 ¬_found_state,
829 &RenderOptions::new("index.html", &format!("{base_path}404/")),
830 &mut writer_404,
831 )
832 .map_err(|e| Error::Render(format!("404: {e}")))?;
833
834 fs::write(site_dir.join("404.html"), writer_404.buf).map_err(|e| Error::Io(e.to_string()))?;
835 fs::remove_dir_all(&nf_tmp).ok();
836 print_success(cache, "Generated 404 page");
837
838 let css_map_local = component_css
842 .into_inner()
843 .unwrap_or_else(std::sync::PoisonError::into_inner);
844 let mut css_names: Vec<&String> = css_map_local.keys().collect();
845 css_names.sort();
846 let css_count = css_names.len();
847 for name in css_names {
848 fs::write(site_dir.join(name), &css_map_local[name])
849 .map_err(|e| Error::Io(format!("Cannot write {name}: {e}")))?;
850 }
851 if css_count > 0 {
852 print_success(cache, &format!("Wrote {css_count} component stylesheets"));
853 }
854
855 if bundle_result.root_script.is_some() || !bundle_result.script_map.is_empty() {
857 let mut linked_count = 0usize;
858 for page in &pages {
859 let page_rel_paths = if let Some(bundle_id) = page_bundle_ids.get(&page.path) {
860 let rel_paths = bundle_result.script_map.get(bundle_id).ok_or_else(|| {
861 Error::Build(format!(
862 "Missing bundled script for page {} entry {}",
863 page.path, bundle_id
864 ))
865 })?;
866 if rel_paths.is_empty() {
867 None
868 } else {
869 Some(rel_paths)
870 }
871 } else {
872 None
873 };
874 if bundle_result.root_script.is_none() && page_rel_paths.is_none() {
875 continue;
876 }
877 let page_dir = site_dir.join(page.path.strip_prefix(base_path).unwrap_or(&page.path));
878 let target = page_dir.join("index.html");
879 let mut html = fs::read_to_string(&target)
880 .map_err(|e| Error::Io(format!("Cannot read {}: {e}", target.display())))?;
881
882 if let Some(rel_path) = bundle_result.root_script.as_ref() {
883 let tag = module_script_tag(base_path, rel_path);
884 inject_script_tag(&mut html, &tag);
885 linked_count += 1;
886 }
887 if let Some(rel_paths) = page_rel_paths {
888 linked_count += inject_module_script_tags(&mut html, base_path, rel_paths);
889 }
890
891 fs::write(&target, &html)
892 .map_err(|e| Error::Io(format!("Cannot rewrite {}: {e}", page.path)))?;
893 }
894 if let Some(bundle_id) = not_found_bundle_id {
895 let rel_paths = bundle_result.script_map.get(&bundle_id).ok_or_else(|| {
896 Error::Build(format!(
897 "Missing bundled script for 404 page entry {bundle_id}"
898 ))
899 })?;
900 let target = site_dir.join("404.html");
901 let mut html = fs::read_to_string(&target)
902 .map_err(|e| Error::Io(format!("Cannot read {}: {e}", target.display())))?;
903 if let Some(rel_path) = bundle_result.root_script.as_ref() {
904 let tag = module_script_tag(base_path, rel_path);
905 inject_script_tag(&mut html, &tag);
906 linked_count += 1;
907 }
908 linked_count += inject_module_script_tags(&mut html, base_path, rel_paths);
909 fs::write(&target, &html)
910 .map_err(|e| Error::Io(format!("Cannot rewrite 404.html: {e}")))?;
911 } else if let Some(rel_path) = bundle_result.root_script.as_ref() {
912 let target = site_dir.join("404.html");
913 let mut html = fs::read_to_string(&target)
914 .map_err(|e| Error::Io(format!("Cannot read {}: {e}", target.display())))?;
915 let tag = module_script_tag(base_path, rel_path);
916 inject_script_tag(&mut html, &tag);
917 fs::write(&target, &html)
918 .map_err(|e| Error::Io(format!("Cannot rewrite 404.html: {e}")))?;
919 linked_count += 1;
920 }
921 if linked_count > 0 {
922 print_success(
923 cache,
924 &format!(
925 "Linked {} script tag{}",
926 linked_count,
927 if linked_count == 1 { "" } else { "s" }
928 ),
929 );
930 }
931 }
932
933 let elapsed = start.elapsed();
934 let total_bytes = total_bytes.load(std::sync::atomic::Ordering::Relaxed);
935 print_finish(cache, &format!("Built in {:.1}s", elapsed.as_secs_f64()));
936
937 Ok(BuildStats {
938 pages: pages.len(),
939 protocol_bytes: total_bytes,
940 })
941}
942
943fn remove_dir_all_retry(path: &Path) -> std::io::Result<()> {
962 const MAX_ATTEMPTS: u32 = 5;
963 let mut delay = std::time::Duration::from_millis(20);
964 for attempt in 1..=MAX_ATTEMPTS {
965 match fs::remove_dir_all(path) {
966 Ok(()) => return Ok(()),
967 Err(e) if attempt < MAX_ATTEMPTS && is_dir_not_empty(&e) => {
968 std::thread::sleep(delay);
969 delay = delay.saturating_mul(2);
970 }
971 Err(e) => return Err(e),
972 }
973 }
974 Ok(())
977}
978
979fn is_dir_not_empty(e: &std::io::Error) -> bool {
983 match e.raw_os_error() {
984 Some(66) => true, Some(39) => true, Some(145) => true, _ => false,
988 }
989}
990
991fn copy_dir(src: &Path, dest: &Path) -> Result<()> {
993 if !src.exists() {
994 return Ok(());
995 }
996 fs::create_dir_all(dest).map_err(|e| Error::Io(e.to_string()))?;
997 let mut stack: Vec<(std::path::PathBuf, std::path::PathBuf)> =
998 vec![(src.to_path_buf(), dest.to_path_buf())];
999 while let Some((s, d)) = stack.pop() {
1000 for entry in fs::read_dir(&s).map_err(|e| Error::Io(e.to_string()))? {
1001 let entry = entry.map_err(|e| Error::Io(e.to_string()))?;
1002 let dest_path = d.join(entry.file_name());
1003 let ft = entry.file_type().map_err(|e| Error::Io(e.to_string()))?;
1004 if ft.is_dir() {
1005 fs::create_dir_all(&dest_path).map_err(|e| Error::Io(e.to_string()))?;
1006 stack.push((entry.path(), dest_path));
1007 } else {
1008 fs::copy(entry.path(), &dest_path).map_err(|e| Error::Io(e.to_string()))?;
1009 }
1010 }
1011 }
1012 Ok(())
1013}
1014
1015pub(crate) fn truncate_utf8(s: &str, max: usize) -> &str {
1017 if s.len() <= max {
1018 return s;
1019 }
1020 let mut end = max;
1021 while end > 0 && !s.is_char_boundary(end) {
1022 end -= 1;
1023 }
1024 &s[..end]
1025}
1026
1027#[derive(Default)]
1028struct SearchText {
1029 content: String,
1030 headings: Vec<SearchHeading>,
1031}
1032
1033struct SearchHeading {
1038 text: String,
1039 anchor: String,
1040 level: u8,
1041}
1042
1043struct CurrentHeading {
1045 text: String,
1046 anchor: String,
1047 level: u8,
1048}
1049
1050struct HtmlTag<'a> {
1051 name: &'a str,
1052 is_end: bool,
1053}
1054
1055pub(crate) fn build_search_entry(title: &str, path: &str, html: &str) -> Value {
1059 let search_text = extract_search_text(html);
1060 let content = normalize_search_text(&search_text.content);
1061 let truncated = truncate_utf8(&content, 500);
1062 let headings: Vec<Value> = search_text
1063 .headings
1064 .into_iter()
1065 .map(|heading| {
1066 json_obj([
1067 ("text", Value::String(heading.text)),
1068 ("anchor", Value::String(heading.anchor)),
1069 (
1070 "level",
1071 Value::Number(serde_json::Number::from(u64::from(heading.level))),
1072 ),
1073 ])
1074 })
1075 .collect();
1076 json_obj([
1077 ("title", Value::String(title.to_string())),
1078 ("path", Value::String(path.to_string())),
1079 ("content", Value::String(truncated.to_string())),
1080 ("headings", Value::Array(headings)),
1081 ])
1082}
1083
1084fn extract_search_text(html: &str) -> SearchText {
1085 let mut text = SearchText {
1086 content: String::with_capacity(html.len() / 2),
1087 headings: Vec::with_capacity(8),
1088 };
1089 let mut current_heading: Option<CurrentHeading> = None;
1090 let mut skip_header_anchor = 0usize;
1091 let mut cursor = 0;
1092
1093 while cursor < html.len() {
1094 let remaining = &html[cursor..];
1095 let Some(tag_offset) = remaining.find('<') else {
1096 push_search_text(
1097 &mut text,
1098 current_heading.as_mut(),
1099 skip_header_anchor,
1100 remaining,
1101 );
1102 break;
1103 };
1104 let text_end = cursor + tag_offset;
1105 push_search_text(
1106 &mut text,
1107 current_heading.as_mut(),
1108 skip_header_anchor,
1109 &html[cursor..text_end],
1110 );
1111 let tag_start = text_end + 1;
1112 let Some(tag_len) = html[tag_start..].find('>') else {
1113 push_search_text(
1114 &mut text,
1115 current_heading.as_mut(),
1116 skip_header_anchor,
1117 &html[text_end..],
1118 );
1119 break;
1120 };
1121 let tag_end = tag_start + tag_len;
1122 handle_search_tag(
1123 &html[tag_start..tag_end],
1124 &mut text,
1125 &mut current_heading,
1126 &mut skip_header_anchor,
1127 );
1128 cursor = tag_end + 1;
1129 }
1130
1131 text
1132}
1133
1134fn handle_search_tag(
1135 raw_tag: &str,
1136 text: &mut SearchText,
1137 current_heading: &mut Option<CurrentHeading>,
1138 skip_header_anchor: &mut usize,
1139) {
1140 if let Some(tag) = parse_html_tag(raw_tag) {
1141 if tag.is_end {
1142 handle_end_tag(tag.name, text, current_heading, skip_header_anchor);
1143 } else {
1144 handle_start_tag(tag.name, raw_tag, current_heading, skip_header_anchor);
1145 }
1146 }
1147 push_search_space(text, current_heading.as_mut(), *skip_header_anchor);
1148}
1149
1150fn handle_start_tag(
1151 name: &str,
1152 raw_tag: &str,
1153 current_heading: &mut Option<CurrentHeading>,
1154 skip_header_anchor: &mut usize,
1155) {
1156 if let Some(level) = heading_level(name) {
1157 *current_heading = Some(CurrentHeading {
1158 text: String::with_capacity(64),
1159 anchor: attr_value(raw_tag, "id").unwrap_or("").to_string(),
1160 level,
1161 });
1162 } else if name.eq_ignore_ascii_case("a") && raw_tag.contains("header-anchor") {
1163 *skip_header_anchor += 1;
1164 }
1165}
1166
1167fn handle_end_tag(
1168 name: &str,
1169 text: &mut SearchText,
1170 current_heading: &mut Option<CurrentHeading>,
1171 skip_header_anchor: &mut usize,
1172) {
1173 if name.eq_ignore_ascii_case("a") && *skip_header_anchor > 0 {
1174 *skip_header_anchor -= 1;
1175 }
1176 if heading_level(name).is_some() {
1177 if let Some(raw_heading) = current_heading.take() {
1178 let heading_text = normalize_search_text(&raw_heading.text);
1179 if !heading_text.is_empty() {
1180 text.headings.push(SearchHeading {
1181 text: heading_text,
1182 anchor: raw_heading.anchor,
1183 level: raw_heading.level,
1184 });
1185 }
1186 }
1187 }
1188}
1189
1190fn parse_html_tag(raw: &str) -> Option<HtmlTag<'_>> {
1191 let mut tag = raw.trim_start();
1192 if tag.starts_with('!') || tag.starts_with('?') {
1193 return None;
1194 }
1195 let is_end = tag.starts_with('/');
1196 if is_end {
1197 tag = tag[1..].trim_start();
1198 }
1199
1200 let mut name_end = 0;
1201 for (idx, byte) in tag.bytes().enumerate() {
1202 if byte.is_ascii_alphanumeric() || byte == b'-' {
1203 name_end = idx + 1;
1204 } else {
1205 break;
1206 }
1207 }
1208 if name_end == 0 {
1209 return None;
1210 }
1211 Some(HtmlTag {
1212 name: &tag[..name_end],
1213 is_end,
1214 })
1215}
1216
1217fn heading_level(name: &str) -> Option<u8> {
1222 let bytes = name.as_bytes();
1223 if bytes.len() == 2 && bytes[0].eq_ignore_ascii_case(&b'h') && (b'1'..=b'6').contains(&bytes[1])
1224 {
1225 Some(bytes[1] - b'0')
1226 } else {
1227 None
1228 }
1229}
1230
1231fn attr_value<'a>(raw_tag: &'a str, attr_name: &str) -> Option<&'a str> {
1238 let bytes = raw_tag.as_bytes();
1239 let mut cursor = 0;
1240
1241 while cursor < bytes.len() {
1242 while bytes.get(cursor).is_some_and(u8::is_ascii_whitespace) {
1243 cursor += 1;
1244 }
1245
1246 let name_start = cursor;
1247 while bytes.get(cursor).is_some_and(|b| is_attr_name_byte(*b)) {
1248 cursor += 1;
1249 }
1250 if name_start == cursor {
1251 cursor += 1;
1252 continue;
1253 }
1254 let name = &raw_tag[name_start..cursor];
1255
1256 while bytes.get(cursor).is_some_and(u8::is_ascii_whitespace) {
1257 cursor += 1;
1258 }
1259 if bytes.get(cursor) != Some(&b'=') {
1260 continue;
1261 }
1262 cursor += 1;
1263 while bytes.get(cursor).is_some_and(u8::is_ascii_whitespace) {
1264 cursor += 1;
1265 }
1266
1267 let (value_start, value_end) = attr_value_range(raw_tag, cursor);
1268 if name.eq_ignore_ascii_case(attr_name) {
1269 return Some(&raw_tag[value_start..value_end]);
1270 }
1271 cursor = value_end.saturating_add(1);
1272 }
1273
1274 None
1275}
1276
1277fn is_attr_name_byte(byte: u8) -> bool {
1278 byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b':')
1279}
1280
1281fn attr_value_range(raw_tag: &str, cursor: usize) -> (usize, usize) {
1282 let bytes = raw_tag.as_bytes();
1283 match bytes.get(cursor).copied() {
1284 Some(b'"' | b'\'') => {
1285 let quote = bytes[cursor];
1286 let value_start = cursor + 1;
1287 let value_end = raw_tag[value_start..]
1288 .find(char::from(quote))
1289 .map_or(raw_tag.len(), |offset| value_start + offset);
1290 (value_start, value_end)
1291 }
1292 Some(_) => {
1293 let value_start = cursor;
1294 let value_end = raw_tag[value_start..]
1295 .find(char::is_whitespace)
1296 .map_or(raw_tag.len(), |offset| value_start + offset);
1297 (value_start, value_end)
1298 }
1299 None => (raw_tag.len(), raw_tag.len()),
1300 }
1301}
1302
1303fn push_search_text(
1304 text: &mut SearchText,
1305 current_heading: Option<&mut CurrentHeading>,
1306 skip_header_anchor: usize,
1307 value: &str,
1308) {
1309 if skip_header_anchor > 0 {
1310 return;
1311 }
1312 text.content.push_str(value);
1313 if let Some(heading) = current_heading {
1314 heading.text.push_str(value);
1315 }
1316}
1317
1318fn push_search_space(
1319 text: &mut SearchText,
1320 current_heading: Option<&mut CurrentHeading>,
1321 skip_header_anchor: usize,
1322) {
1323 if skip_header_anchor > 0 {
1324 return;
1325 }
1326 text.content.push(' ');
1327 if let Some(heading) = current_heading {
1328 heading.text.push(' ');
1329 }
1330}
1331
1332fn normalize_search_text(raw: &str) -> String {
1333 let decoded = html_escape::decode_html_entities(raw);
1334 let mut normalized = String::with_capacity(decoded.len());
1335 let mut first = true;
1336 for token in decoded.split_whitespace() {
1337 if !first {
1338 normalized.push(' ');
1339 }
1340 normalized.push_str(token);
1341 first = false;
1342 }
1343 normalized
1344}
1345
1346const PRE_BLOCK_MARKER_PREFIX: &str = "<span data-webui-press-pre-block=\"";
1347const PRE_BLOCK_MARKER_SUFFIX: &str = "\"></span>";
1348
1349fn protect_pre_blocks(content: &str) -> (String, Vec<String>) {
1354 use std::fmt::Write as _;
1355 let mut blocks: Vec<String> = Vec::new();
1356 let mut out = String::with_capacity(content.len());
1357 let mut cursor = 0;
1358 while let Some(rel_start) = find_pre_open(&content[cursor..]) {
1359 let start = cursor + rel_start;
1360 if let Some(rel_end) = content[start..].find("</pre>") {
1361 let end = start + rel_end + "</pre>".len();
1362 out.push_str(&content[cursor..start]);
1363 out.push_str(PRE_BLOCK_MARKER_PREFIX);
1364 let _ = write!(&mut out, "{}", blocks.len());
1366 out.push_str(PRE_BLOCK_MARKER_SUFFIX);
1367 blocks.push(content[start..end].to_string());
1368 cursor = end;
1369 } else {
1370 break;
1371 }
1372 }
1373 out.push_str(&content[cursor..]);
1374 (out, blocks)
1375}
1376
1377fn find_pre_open(s: &str) -> Option<usize> {
1380 let bytes = s.as_bytes();
1381 let mut i = 0;
1382 while let Some(rel) = s[i..].find("<pre") {
1383 let pos = i + rel;
1384 let after = pos + 4;
1385 match bytes.get(after) {
1386 Some(b'>' | b' ' | b'\t' | b'\n' | b'\r') => return Some(pos),
1387 _ => i = after,
1388 }
1389 }
1390 None
1391}
1392
1393fn restore_pre_blocks(html: &str, blocks: &[String]) -> String {
1396 if blocks.is_empty() {
1397 return html.to_string();
1398 }
1399 let extra: usize = blocks.iter().map(|b| b.len()).sum();
1400 let mut out = String::with_capacity(html.len() + extra);
1401 let mut cursor = 0;
1402 while let Some(rel) = html[cursor..].find(PRE_BLOCK_MARKER_PREFIX) {
1403 let p = cursor + rel;
1404 out.push_str(&html[cursor..p]);
1405 let after = p + PRE_BLOCK_MARKER_PREFIX.len();
1406 if let Some(end_rel) = html[after..].find(PRE_BLOCK_MARKER_SUFFIX) {
1407 let num_str = &html[after..after + end_rel];
1408 if let Ok(idx) = num_str.parse::<usize>() {
1409 if let Some(block) = blocks.get(idx) {
1410 out.push_str(block);
1411 cursor = after + end_rel + PRE_BLOCK_MARKER_SUFFIX.len();
1412 continue;
1413 }
1414 }
1415 out.push_str(&html[p..after + end_rel + PRE_BLOCK_MARKER_SUFFIX.len()]);
1417 cursor = after + end_rel + PRE_BLOCK_MARKER_SUFFIX.len();
1418 } else {
1419 out.push_str(&html[p..]);
1420 return out;
1421 }
1422 }
1423 out.push_str(&html[cursor..]);
1424 out
1425}
1426
1427fn fxhash(s: &str) -> u64 {
1429 fxhash_bytes(s.as_bytes())
1430}
1431
1432fn fxhash_bytes(bytes: &[u8]) -> u64 {
1433 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
1434 for &b in bytes {
1435 hash ^= u64::from(b);
1436 hash = hash.wrapping_mul(0x100_0000_01b3);
1437 }
1438 hash
1439}
1440
1441#[cfg(test)]
1442mod tests {
1443 use super::*;
1444
1445 type TestResult<T = ()> = std::result::Result<T, Box<dyn std::error::Error>>;
1446
1447 fn test_obj<const N: usize>(entries: [(&str, Value); N]) -> Value {
1448 let mut map = Map::with_capacity(N);
1449 for (key, value) in entries {
1450 map.insert(key.to_string(), value);
1451 }
1452 Value::Object(map)
1453 }
1454
1455 fn string_value(value: &str) -> Value {
1456 Value::String(value.to_string())
1457 }
1458
1459 fn docs_config() -> TestResult<DocsConfig> {
1460 let config = serde_json::from_str(
1461 r#"{
1462 "site": { "title": "Docs" },
1463 "basePath": "/",
1464 "contentDir": ".",
1465 "nav": [],
1466 "sidebar": []
1467 }"#,
1468 )?;
1469 Ok(config)
1470 }
1471
1472 #[test]
1473 fn not_found_state_includes_global_state() -> TestResult {
1474 let config = docs_config()?;
1475 let global = test_obj([("release", test_obj([("version", string_value("v1.2.3"))]))]);
1476
1477 let state = build_not_found_state(NotFoundStateInput {
1478 config: &config,
1479 base_path: "/",
1480 nav: Value::Array(vec![]),
1481 footer: Value::Null,
1482 content: "<h1>404</h1>",
1483 head_injection: "<meta name=\"docs\">",
1484 global_state: Some(&global),
1485 });
1486
1487 assert_eq!(state["release"]["version"], "v1.2.3");
1488 assert_eq!(state["page"]["title"], "Page Not Found");
1489 assert_eq!(state["pageData"], Value::Null);
1490 assert_eq!(state["headTags"], "<meta name=\"docs\">");
1491 Ok(())
1492 }
1493
1494 #[test]
1497 fn truncate_utf8_short_string_unchanged() {
1498 assert_eq!(truncate_utf8("hello", 100), "hello");
1499 }
1500
1501 #[test]
1502 fn truncate_utf8_ascii_at_exact_boundary() {
1503 assert_eq!(truncate_utf8("hello world", 5), "hello");
1504 }
1505
1506 #[test]
1507 fn truncate_utf8_steps_back_off_multibyte_boundary() {
1508 let s = "é-suffix";
1510 let out = truncate_utf8(s, 1);
1511 assert!(s.is_char_boundary(out.len()), "out.len()={}", out.len());
1512 assert_eq!(out, "");
1513 }
1514
1515 #[test]
1516 fn truncate_utf8_keeps_multibyte_when_room_allows() {
1517 let s = "café";
1518 let out = truncate_utf8(s, 5); assert_eq!(out, "café");
1520 }
1521
1522 #[test]
1525 fn protect_pre_blocks_with_attrs() {
1526 let input = r#"<p>before</p><pre class="hljs">code</pre><p>after</p>"#;
1527 let (out, blocks) = protect_pre_blocks(input);
1528 assert_eq!(blocks.len(), 1);
1529 assert!(out.contains(r#"<span data-webui-press-pre-block="0"></span>"#));
1530 assert!(!out.contains("<pre"));
1531 assert_eq!(blocks[0], r#"<pre class="hljs">code</pre>"#);
1532 }
1533
1534 #[test]
1535 fn protect_pre_blocks_bare_open_tag() {
1536 let input = "<pre>code</pre>";
1538 let (out, blocks) = protect_pre_blocks(input);
1539 assert_eq!(blocks.len(), 1, "got out={out:?} blocks={blocks:?}");
1540 assert_eq!(blocks[0], "<pre>code</pre>");
1541 }
1542
1543 #[test]
1544 fn protect_pre_blocks_does_not_match_presentation_tag() {
1545 let input = "<presentation>x</presentation>";
1547 let (_out, blocks) = protect_pre_blocks(input);
1548 assert_eq!(blocks.len(), 0);
1549 }
1550
1551 #[test]
1552 fn protect_then_restore_round_trip() {
1553 let input = "before <pre>a</pre> mid <pre class=\"x\">b</pre> end";
1554 let (protected, blocks) = protect_pre_blocks(input);
1555 let restored = restore_pre_blocks(&protected, &blocks);
1556 assert_eq!(restored, input);
1557 }
1558
1559 #[test]
1560 fn restore_pre_blocks_no_blocks_returns_input() {
1561 assert_eq!(restore_pre_blocks("plain html", &[]), "plain html");
1562 }
1563
1564 #[test]
1565 fn pre_block_placeholder_survives_webui_component_slot_render() -> TestResult {
1566 let input = "<code-block><pre><code>let x = 1;\n</code></pre></code-block>";
1567 let (protected, blocks) = protect_pre_blocks(input);
1568 let page_html = format!("<!DOCTYPE html><html><body>{protected}</body></html>");
1569
1570 let tmp = std::env::temp_dir().join(format!(
1571 "webui-press-slot-test-{}-{:x}",
1572 std::process::id(),
1573 fxhash(input)
1574 ));
1575 if tmp.exists() {
1576 fs::remove_dir_all(&tmp)?;
1577 }
1578 fs::create_dir_all(&tmp)?;
1579 fs::write(tmp.join("index.html"), page_html)?;
1580
1581 let components = vec![Path::new(env!("CARGO_MANIFEST_DIR"))
1582 .join("components")
1583 .to_string_lossy()
1584 .into_owned()];
1585 let build_result = webui::build(BuildOptions {
1586 app_dir: tmp.clone(),
1587 entry: "index.html".to_string(),
1588 plugin: Some(webui::Plugin::WebUI),
1589 components,
1590 ..BuildOptions::default()
1591 })?;
1592
1593 let mut writer = StringWriter::with_capacity(4096);
1594 let handler = WebUIHandler::with_plugin(|| {
1595 Box::new(webui_handler::plugin::webui::WebUIHydrationPlugin::new())
1596 });
1597 let protocol = Protocol::new(build_result.protocol);
1598 handler.render(
1599 &protocol,
1600 &Value::Object(Map::new()),
1601 &RenderOptions::new("index.html", "/"),
1602 &mut writer,
1603 )?;
1604
1605 fs::remove_dir_all(&tmp)?;
1606
1607 let html = restore_pre_blocks(&writer.buf, &blocks);
1608 assert!(
1609 html.contains("<pre><code>let x = 1;\n</code></pre>"),
1610 "slotted pre block should be restored after WebUI render: {html}"
1611 );
1612 assert!(
1613 !html.contains(PRE_BLOCK_MARKER_PREFIX),
1614 "placeholder marker must not survive output: {html}"
1615 );
1616
1617 Ok(())
1618 }
1619
1620 #[test]
1621 fn build_search_entry_decodes_code_text_and_captures_headings() {
1622 let entry = build_search_entry(
1623 "`<for>` Loop Directive",
1624 "/guide/concepts/directives/for/",
1625 r##"<h1 id="for"><code><for></code> Loop Directive <a class="header-anchor" href="#for">#</a></h1><p>Use <code><for></code> loops.</p><h2 id="syntax">Syntax <a class="header-anchor" href="#syntax">#</a></h2><h3 id="nested">Nested Search <a class="header-anchor" href="#nested">#</a></h3>"##,
1626 );
1627
1628 assert_eq!(entry["title"].as_str(), Some("`<for>` Loop Directive"));
1629 assert_eq!(
1630 entry["content"].as_str(),
1631 Some("<for> Loop Directive Use <for> loops. Syntax Nested Search")
1632 );
1633 let Some(headings) = entry["headings"].as_array() else {
1634 panic!("headings should be an array: {entry:?}");
1635 };
1636 assert_eq!(headings.len(), 3);
1637 assert_eq!(headings[0]["text"].as_str(), Some("<for> Loop Directive"));
1638 assert_eq!(headings[0]["anchor"].as_str(), Some("for"));
1639 assert_eq!(headings[0]["level"].as_u64(), Some(1));
1640 assert_eq!(headings[1]["text"].as_str(), Some("Syntax"));
1641 assert_eq!(headings[1]["anchor"].as_str(), Some("syntax"));
1642 assert_eq!(headings[1]["level"].as_u64(), Some(2));
1643 assert_eq!(headings[2]["text"].as_str(), Some("Nested Search"));
1644 assert_eq!(headings[2]["anchor"].as_str(), Some("nested"));
1645 assert_eq!(headings[2]["level"].as_u64(), Some(3));
1646 }
1647
1648 #[test]
1651 fn fxhash_deterministic() {
1652 assert_eq!(fxhash("abc"), fxhash("abc"));
1653 assert_ne!(fxhash("abc"), fxhash("abd"));
1654 }
1655}