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