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