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 scratch_root = crate::scratch::scratch_base(out_dir, config_dir).map_err(|e| {
303 Error::Io(format!(
304 "Cannot prepare the Press scratch directory for {}: {e}",
305 out_dir.display()
306 ))
307 })?;
308
309 let custom_css = config
311 .css
312 .as_ref()
313 .and_then(|p| fs::read_to_string(p).ok())
314 .unwrap_or_default();
315 let token_file = match &config.theme {
316 Some(theme) => Some(load_theme(theme, config_dir)?),
317 None => None,
318 };
319
320 print_header(cache, &config.site.title);
321 eprintln!();
322
323 let base_css = template_css(template_dir, config.show)?;
332 let has_base_css = !base_css.is_empty();
333 let base_css_link = if has_base_css {
334 format!("<link rel=\"stylesheet\" href=\"{base_path}docs.css\">")
335 } else {
336 String::new()
337 };
338 let has_theme_css = !custom_css.is_empty();
339 let theme_css_link = if has_theme_css {
340 format!("<link rel=\"stylesheet\" href=\"{base_path}theme.css\">")
341 } else {
342 String::new()
343 };
344 let head_injection = {
345 let mut parts: Vec<String> = Vec::with_capacity(2 + config.head.len());
346 if !base_css_link.is_empty() {
347 parts.push(base_css_link.clone());
348 }
349 if !theme_css_link.is_empty() {
350 parts.push(theme_css_link.clone());
351 }
352 for tag in &config.head {
353 parts.push(tag.to_html());
354 }
355 parts.join("\n ")
356 };
357
358 let render_states = load_render_states(config, config_dir)?;
362 let highlighter = cache.highlighter.take().unwrap_or_default();
363 let mut pages =
364 process_content_with_states(config, &highlighter, &head_injection, &render_states)?;
365
366 cache.highlighter = Some(highlighter);
368
369 let mut component_sources: Vec<String> = Vec::new();
371 let cwd = std::env::current_dir().unwrap_or_default();
372 let builtin_components = template_dir.parent().map(|p| p.join("components"));
374 if let Some(ref bc) = builtin_components {
375 if bc.exists() {
376 component_sources.push(bc.to_string_lossy().to_string());
377 }
378 }
379 if let Some(ref user_sources) = config.components {
383 for source in user_sources {
384 component_sources.push(resolve_config_component_source(source, &cwd));
385 }
386 }
387 component_sources.push(template_dir.to_string_lossy().to_string());
390
391 let template_html = fs::read_to_string(template_dir.join("index.html"))
392 .map_err(|e| Error::Build(format!("Failed to read template: {e}")))?;
393 let regions = RegionSet::load(&config.regions, config_dir, template_html)?;
394 let regions = if config.show == ShowMode::Content {
397 RegionSet::load(
398 &Default::default(),
399 config_dir,
400 include_str!("../template/content.html").to_string(),
401 )?
402 } else {
403 regions
404 };
405 let component_script_index = discover_component_scripts(&component_sources)?;
406
407 if out_dir.exists() && !cache.skip_clean {
412 remove_dir_all_retry(out_dir)
413 .map_err(|e| Error::Io(format!("Cannot clean output dir: {e}")))?;
414 }
415 fs::create_dir_all(&site_dir).map_err(|e| Error::Io(format!("Cannot create site dir: {e}")))?;
416
417 if has_base_css {
423 fs::write(site_dir.join("docs.css"), &base_css)
424 .map_err(|e| Error::Io(format!("Cannot write docs.css: {e}")))?;
425 }
426 if has_theme_css {
427 fs::write(site_dir.join("theme.css"), &custom_css)
428 .map_err(|e| Error::Io(format!("Cannot write theme.css: {e}")))?;
429 }
430
431 let handler = WebUIHandler::with_plugin(|| {
432 Box::new(webui_handler::plugin::webui::WebUIHydrationPlugin::new())
433 });
434
435 for page in &pages {
438 let page_dir = site_dir.join(page.path.strip_prefix(base_path).unwrap_or(&page.path));
439 fs::create_dir_all(&page_dir)
440 .map_err(|e| Error::Io(format!("Cannot create dir {}: {e}", page_dir.display())))?;
441 }
442
443 let mut explicit_page_scripts: HashMap<String, Vec<ScriptSource>> = HashMap::new();
450 let mut page_contents_with_scripts: HashMap<String, String> = HashMap::new();
451
452 for page in &pages {
453 let content = page.state["page"]["content"].as_str().unwrap_or("");
454 if content.contains("<script") && content.contains("bundle") {
455 let (modified, scripts) = extract_bundle_scripts(content);
456 if !scripts.is_empty() {
457 page_contents_with_scripts.insert(page.path.clone(), modified);
458 explicit_page_scripts
459 .entry(page.path.clone())
460 .or_default()
461 .extend(scripts);
462 }
463 }
464 for script_file in regions.script_files(region_layout(page)) {
465 explicit_page_scripts
466 .entry(page.path.clone())
467 .or_default()
468 .push(ScriptSource::File(script_file.to_string()));
469 }
470 }
471
472 for (link, custom_page) in &config.custom_pages {
474 if let Some(script_path) = custom_page.script_file() {
475 let normalized = if link.ends_with('/') {
478 link.clone()
479 } else {
480 format!("{link}/")
481 };
482 let page_path = format!("{}{}", base_path, &normalized[1..]);
483 explicit_page_scripts
484 .entry(page_path)
485 .or_default()
486 .push(ScriptSource::File(script_path.to_string()));
487 }
488 }
489
490 let not_found_content = format!(
491 "<h1>404 — Page Not Found</h1>\
492 <p>The page you're looking for doesn't exist or has been moved.</p>\
493 <p><a href=\"{base_path}\">← Back to Home</a></p>"
494 );
495
496 let template_script_path = template_dir.join("index.ts");
497 let template_script = if config.show == ShowMode::All && template_script_path.exists() {
498 Some(
499 template_script_path
500 .canonicalize()
501 .unwrap_or(template_script_path),
502 )
503 } else {
504 None
505 };
506 let template_shell = regions.template_shell();
507 let root_component_scripts =
508 collect_component_scripts_for_html(&[template_shell.as_str()], &component_script_index)?;
509 let root_bundle = if template_script.is_some() || !root_component_scripts.is_empty() {
510 Some(RootBundleEntry {
511 script_path: template_script,
512 component_scripts: root_component_scripts,
513 })
514 } else {
515 None
516 };
517
518 let mut page_bundles: Vec<PageBundleEntry> = Vec::new();
522 let mut page_bundle_ids: HashMap<String, usize> = HashMap::with_capacity(pages.len());
523 let mut page_bundle_signatures: HashMap<String, usize> = HashMap::new();
524 for page in &pages {
525 let content = page_contents_with_scripts
526 .get(&page.path)
527 .map(String::as_str)
528 .unwrap_or_else(|| page.state["page"]["content"].as_str().unwrap_or(""));
529 let region_fragments = regions.html_fragments(region_layout(page));
530 let mut html_sources = Vec::with_capacity(region_fragments.len() + 1);
531 html_sources.push(content);
532 html_sources.extend(region_fragments);
533 let mut component_scripts =
534 collect_component_scripts_for_html(&html_sources, &component_script_index)?;
535 if let Some(root) = &root_bundle {
536 component_scripts.retain(|path| !root.component_scripts.contains(path));
537 }
538 let explicit_scripts = explicit_page_scripts.remove(&page.path).unwrap_or_default();
539 if component_scripts.is_empty() && explicit_scripts.is_empty() {
540 continue;
541 }
542 let signature = page_bundle_signature(&component_scripts, &explicit_scripts, config_dir);
543 if let Some(&id) = page_bundle_signatures.get(&signature) {
544 page_bundle_ids.insert(page.path.clone(), id);
545 continue;
546 }
547
548 let id = page_bundles.len();
549 page_bundle_signatures.insert(signature, id);
550 page_bundle_ids.insert(page.path.clone(), id);
551 page_bundles.push(PageBundleEntry {
552 id,
553 page_path: page.path.clone(),
554 component_scripts,
555 explicit_scripts,
556 });
557 }
558
559 let not_found_region_fragments = regions.html_fragments("doc");
560 let mut not_found_sources = Vec::with_capacity(not_found_region_fragments.len() + 1);
561 not_found_sources.push(not_found_content.as_str());
562 not_found_sources.extend(not_found_region_fragments);
563 let mut not_found_component_scripts =
564 collect_component_scripts_for_html(¬_found_sources, &component_script_index)?;
565 if let Some(root) = &root_bundle {
566 not_found_component_scripts.retain(|path| !root.component_scripts.contains(path));
567 }
568 let not_found_scripts: Vec<ScriptSource> = regions
569 .script_files("doc")
570 .map(|path| ScriptSource::File(path.to_string()))
571 .collect();
572 let not_found_bundle_id =
573 if not_found_component_scripts.is_empty() && not_found_scripts.is_empty() {
574 None
575 } else {
576 let signature =
577 page_bundle_signature(¬_found_component_scripts, ¬_found_scripts, config_dir);
578 if let Some(&id) = page_bundle_signatures.get(&signature) {
579 Some(id)
580 } else {
581 let id = page_bundles.len();
582 page_bundle_signatures.insert(signature, id);
583 page_bundles.push(PageBundleEntry {
584 id,
585 page_path: format!("{base_path}404/"),
586 component_scripts: not_found_component_scripts,
587 explicit_scripts: not_found_scripts,
588 });
589 Some(id)
590 }
591 };
592
593 let node_modules = if root_bundle.is_some() || !page_bundles.is_empty() {
596 Some(resolve_node_modules(config_dir)?)
597 } else {
598 None
599 };
600 let (projection_source, projection_completer) = webui::projection_manifest_barrier();
601 let site_dir_for_bundle = site_dir.clone();
602 let root_bundle_for_bundle = root_bundle.clone();
603 let page_bundles_for_bundle = page_bundles.clone();
604 let bundler_config = config.bundler.clone();
605 let config_dir_for_bundle = config_dir.to_path_buf();
606 let content_dir_for_bundle = PathBuf::from(&config.content_dir);
607 let base_path_for_bundle = base_path.to_string();
608 let dev_mode = cache.dev_mode;
609 let generated_preloads = Arc::new(OnceLock::new());
613 let generated_preloads_for_bundle = Arc::clone(&generated_preloads);
614 let bundle_thread = BundleThread::spawn(move || {
615 match bundle_assets(&BundleOptions {
616 site_dir: &site_dir_for_bundle,
617 base_path: &base_path_for_bundle,
618 node_modules: node_modules.as_deref(),
619 root_bundle: root_bundle_for_bundle.as_ref(),
620 page_bundles: &page_bundles_for_bundle,
621 bundler_config: bundler_config.as_ref(),
622 dev_mode,
623 config_dir: &config_dir_for_bundle,
624 content_dir: &content_dir_for_bundle,
625 }) {
626 Ok(result) => {
627 if generated_preloads_for_bundle
628 .set(result.preloads.clone())
629 .is_err()
630 {
631 let message = "Generated preload metadata was published twice".to_string();
632 projection_completer.complete(Err(message.clone()));
633 return Err(Error::Build(message));
634 }
635 projection_completer.complete(Ok(result.projection.clone()));
636 Ok(result)
637 }
638 Err(error) => {
639 projection_completer.complete(Err(error.to_string()));
640 Err(error)
641 }
642 }
643 });
644
645 let total_bytes = std::sync::atomic::AtomicUsize::new(0);
655 let component_css: std::sync::Mutex<HashMap<String, String>> =
656 std::sync::Mutex::new(HashMap::new());
657
658 let page_start = Instant::now();
659 pages.par_iter_mut().try_for_each(|page| -> Result<()> {
660 let page_dir = site_dir.join(page.path.strip_prefix(base_path).unwrap_or(&page.path));
661 let target = page_dir.join("index.html");
662
663 let content = page_contents_with_scripts
665 .get(&page.path)
666 .map(|s| s.as_str())
667 .unwrap_or_else(|| page.state["page"]["content"].as_str().unwrap_or(""));
668
669 let (protected, pre_blocks) = protect_pre_blocks(content);
672 let page_html = regions
673 .render(region_layout(page))
674 .replace("{{{page.content}}}", &protected);
675
676 let nonce = REBUILD_NONCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
685 let page_tmp = scratch_root.join(format!(
686 "webui-press-page-{}-{:x}-{nonce:x}",
687 std::process::id(),
688 fxhash(&page.path),
689 ));
690 if page_tmp.exists() {
691 fs::remove_dir_all(&page_tmp).ok();
692 }
693 fs::create_dir_all(&page_tmp)
694 .map_err(|e| Error::Io(format!("Cannot create page temp: {e}")))?;
695 fs::write(page_tmp.join("index.html"), &page_html)
696 .map_err(|e| Error::Io(format!("Cannot write page temp: {e}")))?;
697
698 let mut build_result = webui::build(BuildOptions {
699 app_dir: page_tmp.clone(),
700 entry: "index.html".to_string(),
701 plugin: Some(webui::Plugin::WebUI),
702 components: component_sources.clone(),
703 theme: token_file.clone(),
704 projection_manifests: vec![projection_source.clone()],
705 ..BuildOptions::default()
706 })
707 .map_err(|e| Error::Build(format!("{}: {}", page.path, e.chain_message())))?;
708 let preloads = generated_preloads.get().ok_or_else(|| {
709 Error::Build("Generated preload metadata was not published".to_string())
710 })?;
711 let (hrefs, mut preload_warnings) = preloads.resolve(
712 &build_result.protocol.module_preloads,
713 page_bundle_ids.get(&page.path).copied(),
714 );
715 build_result.protocol.module_preloads = hrefs;
716 build_result.warnings.append(&mut preload_warnings);
717
718 total_bytes.fetch_add(
719 build_result.protocol_bytes.len(),
720 std::sync::atomic::Ordering::Relaxed,
721 );
722
723 if !build_result.css_files.is_empty() {
728 let mut css_map = component_css
729 .lock()
730 .unwrap_or_else(std::sync::PoisonError::into_inner);
731 for (name, content) in build_result.css_files {
732 css_map.entry(name).or_insert(content);
733 }
734 }
735
736 let layout = region_layout(page).to_string();
737 regions.apply_state(&layout, &mut page.state)?;
738 if let Some(token_file) = token_file.as_ref() {
739 inject_theme_tokens(&mut page.state, token_file, &build_result.protocol.tokens)?;
740 }
741
742 let protocol = Protocol::new(build_result.protocol);
743 let mut writer = StringWriter::with_capacity(8192);
744 handler
745 .render(
746 &protocol,
747 &page.state,
748 &RenderOptions::new("index.html", &page.path),
749 &mut writer,
750 )
751 .map_err(|e| Error::Render(format!("{}: {e}", page.path)))?;
752
753 let html = restore_pre_blocks(&writer.buf, &pre_blocks);
755
756 fs::write(&target, &html)
758 .map_err(|e| Error::Io(format!("Cannot write {}: {e}", page.path)))?;
759
760 fs::remove_dir_all(&page_tmp).ok();
761 Ok(())
762 })?;
763
764 print_success(
765 cache,
766 &format!(
767 "Rendered {} pages in {:.0}ms",
768 pages.len(),
769 page_start.elapsed().as_secs_f64() * 1000.0
770 ),
771 );
772 let bundle_result = bundle_thread.join()?;
773 print_bundle_summary(cache, &bundle_result);
774
775 let search_path = site_dir.join("search-index.json");
778 let search_entries: Vec<Value> = pages
779 .iter()
780 .filter(|p| !p.is_home)
781 .map(|p| {
782 let html = p.state["page"]["content"].as_str().unwrap_or("");
783 let title = p.state["page"]["title"].as_str().unwrap_or("");
784 build_search_entry(title, &p.path, html)
785 })
786 .collect();
787 fs::write(
788 &search_path,
789 serde_json::to_string(&search_entries)
790 .map_err(|e| Error::Build(format!("JSON error: {e}")))?,
791 )
792 .map_err(|e| Error::Io(e.to_string()))?;
793 print_success(
794 cache,
795 &format!("Indexed {} pages for search", search_entries.len()),
796 );
797
798 let public_dir = Path::new(&config.public_dir);
800 if public_dir.exists() {
801 copy_dir(public_dir, &site_dir)?;
802 }
803
804 let nav_val = pages
806 .first()
807 .map(|p| p.state["navigation"].clone())
808 .unwrap_or_default();
809
810 let footer_val = config
811 .footer
812 .as_ref()
813 .map(|f| json_obj([("html", Value::String(f.html.clone()))]))
814 .unwrap_or(Value::Null);
815
816 let mut not_found_state = build_not_found_state(NotFoundStateInput {
817 config,
818 base_path,
819 nav: nav_val,
820 footer: footer_val,
821 content: ¬_found_content,
822 head_injection: &head_injection,
823 global_state: render_states.global(),
824 });
825 regions.apply_state("doc", &mut not_found_state)?;
826
827 let (protected_not_found, not_found_pre_blocks) = protect_pre_blocks(¬_found_content);
828 let not_found_html = regions
829 .render("doc")
830 .replace("{{{page.content}}}", &protected_not_found);
831 let nf_tmp = scratch_root.join(format!(
832 "webui-press-404-{}-{:x}",
833 std::process::id(),
834 REBUILD_NONCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
835 ));
836 if nf_tmp.exists() {
837 fs::remove_dir_all(&nf_tmp).ok();
838 }
839 fs::create_dir_all(&nf_tmp).map_err(|e| Error::Io(e.to_string()))?;
840 fs::write(nf_tmp.join("index.html"), ¬_found_html).map_err(|e| Error::Io(e.to_string()))?;
841
842 let mut nf_build = webui::build(BuildOptions {
843 app_dir: nf_tmp.clone(),
844 entry: "index.html".to_string(),
845 plugin: Some(webui::Plugin::WebUI),
846 components: component_sources.clone(),
847 theme: token_file.clone(),
848 projection_manifests: vec![projection_source],
849 ..BuildOptions::default()
850 })
851 .map_err(|e| Error::Build(format!("404 build failed: {}", e.chain_message())))?;
852 let preloads = generated_preloads
853 .get()
854 .ok_or_else(|| Error::Build("Generated preload metadata was not published".to_string()))?;
855 let (hrefs, mut preload_warnings) =
856 preloads.resolve(&nf_build.protocol.module_preloads, not_found_bundle_id);
857 nf_build.protocol.module_preloads = hrefs;
858 nf_build.warnings.append(&mut preload_warnings);
859
860 if let Some(token_file) = token_file.as_ref() {
861 inject_theme_tokens(&mut not_found_state, token_file, &nf_build.protocol.tokens)?;
862 }
863
864 {
869 let mut css_map = component_css
870 .lock()
871 .unwrap_or_else(std::sync::PoisonError::into_inner);
872 for (name, content) in &nf_build.css_files {
873 css_map
874 .entry(name.clone())
875 .or_insert_with(|| content.clone());
876 }
877 }
878
879 let nf_protocol = Protocol::new(nf_build.protocol);
880 let mut writer_404 = StringWriter::with_capacity(4096);
881 handler
882 .render(
883 &nf_protocol,
884 ¬_found_state,
885 &RenderOptions::new("index.html", &format!("{base_path}404/")),
886 &mut writer_404,
887 )
888 .map_err(|e| Error::Render(format!("404: {e}")))?;
889
890 let not_found_output = restore_pre_blocks(&writer_404.buf, ¬_found_pre_blocks);
891 fs::write(site_dir.join("404.html"), not_found_output).map_err(|e| Error::Io(e.to_string()))?;
892 fs::remove_dir_all(&nf_tmp).ok();
893 print_success(cache, "Generated 404 page");
894
895 let css_map_local = component_css
899 .into_inner()
900 .unwrap_or_else(std::sync::PoisonError::into_inner);
901 let mut css_names: Vec<&String> = css_map_local.keys().collect();
902 css_names.sort();
903 let css_count = css_names.len();
904 for name in css_names {
905 fs::write(site_dir.join(name), &css_map_local[name])
906 .map_err(|e| Error::Io(format!("Cannot write {name}: {e}")))?;
907 }
908 if css_count > 0 {
909 print_success(cache, &format!("Wrote {css_count} component stylesheets"));
910 }
911
912 if bundle_result.root_script.is_some() || !bundle_result.script_map.is_empty() {
914 let mut linked_count = 0usize;
915 for page in &pages {
916 let page_rel_paths = if let Some(bundle_id) = page_bundle_ids.get(&page.path) {
917 let rel_paths = bundle_result.script_map.get(bundle_id).ok_or_else(|| {
918 Error::Build(format!(
919 "Missing bundled script for page {} entry {}",
920 page.path, bundle_id
921 ))
922 })?;
923 if rel_paths.is_empty() {
924 None
925 } else {
926 Some(rel_paths)
927 }
928 } else {
929 None
930 };
931 if bundle_result.root_script.is_none() && page_rel_paths.is_none() {
932 continue;
933 }
934 let page_dir = site_dir.join(page.path.strip_prefix(base_path).unwrap_or(&page.path));
935 let target = page_dir.join("index.html");
936 let mut html = fs::read_to_string(&target)
937 .map_err(|e| Error::Io(format!("Cannot read {}: {e}", target.display())))?;
938
939 if let Some(rel_path) = bundle_result.root_script.as_ref() {
940 let tag = module_script_tag(base_path, rel_path);
941 inject_script_tag(&mut html, &tag);
942 linked_count += 1;
943 }
944 if let Some(rel_paths) = page_rel_paths {
945 linked_count += inject_module_script_tags(&mut html, base_path, rel_paths);
946 }
947
948 fs::write(&target, &html)
949 .map_err(|e| Error::Io(format!("Cannot rewrite {}: {e}", page.path)))?;
950 }
951 if let Some(bundle_id) = not_found_bundle_id {
952 let rel_paths = bundle_result.script_map.get(&bundle_id).ok_or_else(|| {
953 Error::Build(format!(
954 "Missing bundled script for 404 page entry {bundle_id}"
955 ))
956 })?;
957 let target = site_dir.join("404.html");
958 let mut html = fs::read_to_string(&target)
959 .map_err(|e| Error::Io(format!("Cannot read {}: {e}", target.display())))?;
960 if let Some(rel_path) = bundle_result.root_script.as_ref() {
961 let tag = module_script_tag(base_path, rel_path);
962 inject_script_tag(&mut html, &tag);
963 linked_count += 1;
964 }
965 linked_count += inject_module_script_tags(&mut html, base_path, rel_paths);
966 fs::write(&target, &html)
967 .map_err(|e| Error::Io(format!("Cannot rewrite 404.html: {e}")))?;
968 } else if let Some(rel_path) = bundle_result.root_script.as_ref() {
969 let target = site_dir.join("404.html");
970 let mut html = fs::read_to_string(&target)
971 .map_err(|e| Error::Io(format!("Cannot read {}: {e}", target.display())))?;
972 let tag = module_script_tag(base_path, rel_path);
973 inject_script_tag(&mut html, &tag);
974 fs::write(&target, &html)
975 .map_err(|e| Error::Io(format!("Cannot rewrite 404.html: {e}")))?;
976 linked_count += 1;
977 }
978 if linked_count > 0 {
979 print_success(
980 cache,
981 &format!(
982 "Linked {} script tag{}",
983 linked_count,
984 if linked_count == 1 { "" } else { "s" }
985 ),
986 );
987 }
988 }
989
990 let elapsed = start.elapsed();
991 let total_bytes = total_bytes.load(std::sync::atomic::Ordering::Relaxed);
992 print_finish(cache, &format!("Built in {:.1}s", elapsed.as_secs_f64()));
993
994 Ok(BuildStats {
995 pages: pages.len(),
996 protocol_bytes: total_bytes,
997 })
998}
999
1000fn remove_dir_all_retry(path: &Path) -> std::io::Result<()> {
1019 const MAX_ATTEMPTS: u32 = 5;
1020 let mut delay = std::time::Duration::from_millis(20);
1021 for attempt in 1..=MAX_ATTEMPTS {
1022 match fs::remove_dir_all(path) {
1023 Ok(()) => return Ok(()),
1024 Err(e) if attempt < MAX_ATTEMPTS && is_dir_not_empty(&e) => {
1025 std::thread::sleep(delay);
1026 delay = delay.saturating_mul(2);
1027 }
1028 Err(e) => return Err(e),
1029 }
1030 }
1031 Ok(())
1034}
1035
1036fn is_dir_not_empty(e: &std::io::Error) -> bool {
1040 match e.raw_os_error() {
1041 Some(66) => true, Some(39) => true, Some(145) => true, _ => false,
1045 }
1046}
1047
1048fn copy_dir(src: &Path, dest: &Path) -> Result<()> {
1050 if !src.exists() {
1051 return Ok(());
1052 }
1053 fs::create_dir_all(dest).map_err(|e| Error::Io(e.to_string()))?;
1054 let mut stack: Vec<(std::path::PathBuf, std::path::PathBuf)> =
1055 vec![(src.to_path_buf(), dest.to_path_buf())];
1056 while let Some((s, d)) = stack.pop() {
1057 for entry in fs::read_dir(&s).map_err(|e| Error::Io(e.to_string()))? {
1058 let entry = entry.map_err(|e| Error::Io(e.to_string()))?;
1059 let dest_path = d.join(entry.file_name());
1060 let ft = entry.file_type().map_err(|e| Error::Io(e.to_string()))?;
1061 if ft.is_dir() {
1062 fs::create_dir_all(&dest_path).map_err(|e| Error::Io(e.to_string()))?;
1063 stack.push((entry.path(), dest_path));
1064 } else {
1065 fs::copy(entry.path(), &dest_path).map_err(|e| Error::Io(e.to_string()))?;
1066 }
1067 }
1068 }
1069 Ok(())
1070}
1071
1072pub(crate) fn truncate_utf8(s: &str, max: usize) -> &str {
1074 if s.len() <= max {
1075 return s;
1076 }
1077 let mut end = max;
1078 while end > 0 && !s.is_char_boundary(end) {
1079 end -= 1;
1080 }
1081 &s[..end]
1082}
1083
1084#[derive(Default)]
1085struct SearchText {
1086 content: String,
1087 headings: Vec<SearchHeading>,
1088}
1089
1090struct SearchHeading {
1095 text: String,
1096 anchor: String,
1097 level: u8,
1098}
1099
1100struct CurrentHeading {
1102 text: String,
1103 anchor: String,
1104 level: u8,
1105}
1106
1107struct HtmlTag<'a> {
1108 name: &'a str,
1109 is_end: bool,
1110}
1111
1112pub(crate) fn build_search_entry(title: &str, path: &str, html: &str) -> Value {
1116 let search_text = extract_search_text(html);
1117 let content = normalize_search_text(&search_text.content);
1118 let truncated = truncate_utf8(&content, 500);
1119 let headings: Vec<Value> = search_text
1120 .headings
1121 .into_iter()
1122 .map(|heading| {
1123 json_obj([
1124 ("text", Value::String(heading.text)),
1125 ("anchor", Value::String(heading.anchor)),
1126 (
1127 "level",
1128 Value::Number(serde_json::Number::from(u64::from(heading.level))),
1129 ),
1130 ])
1131 })
1132 .collect();
1133 json_obj([
1134 ("title", Value::String(title.to_string())),
1135 ("path", Value::String(path.to_string())),
1136 ("content", Value::String(truncated.to_string())),
1137 ("headings", Value::Array(headings)),
1138 ])
1139}
1140
1141fn extract_search_text(html: &str) -> SearchText {
1142 let mut text = SearchText {
1143 content: String::with_capacity(html.len() / 2),
1144 headings: Vec::with_capacity(8),
1145 };
1146 let mut current_heading: Option<CurrentHeading> = None;
1147 let mut skip_header_anchor = 0usize;
1148 let mut cursor = 0;
1149
1150 while cursor < html.len() {
1151 let remaining = &html[cursor..];
1152 let Some(tag_offset) = remaining.find('<') else {
1153 push_search_text(
1154 &mut text,
1155 current_heading.as_mut(),
1156 skip_header_anchor,
1157 remaining,
1158 );
1159 break;
1160 };
1161 let text_end = cursor + tag_offset;
1162 push_search_text(
1163 &mut text,
1164 current_heading.as_mut(),
1165 skip_header_anchor,
1166 &html[cursor..text_end],
1167 );
1168 let tag_start = text_end + 1;
1169 let Some(tag_len) = html[tag_start..].find('>') else {
1170 push_search_text(
1171 &mut text,
1172 current_heading.as_mut(),
1173 skip_header_anchor,
1174 &html[text_end..],
1175 );
1176 break;
1177 };
1178 let tag_end = tag_start + tag_len;
1179 handle_search_tag(
1180 &html[tag_start..tag_end],
1181 &mut text,
1182 &mut current_heading,
1183 &mut skip_header_anchor,
1184 );
1185 cursor = tag_end + 1;
1186 }
1187
1188 text
1189}
1190
1191fn handle_search_tag(
1192 raw_tag: &str,
1193 text: &mut SearchText,
1194 current_heading: &mut Option<CurrentHeading>,
1195 skip_header_anchor: &mut usize,
1196) {
1197 if let Some(tag) = parse_html_tag(raw_tag) {
1198 if tag.is_end {
1199 handle_end_tag(tag.name, text, current_heading, skip_header_anchor);
1200 } else {
1201 handle_start_tag(tag.name, raw_tag, current_heading, skip_header_anchor);
1202 }
1203 }
1204 push_search_space(text, current_heading.as_mut(), *skip_header_anchor);
1205}
1206
1207fn handle_start_tag(
1208 name: &str,
1209 raw_tag: &str,
1210 current_heading: &mut Option<CurrentHeading>,
1211 skip_header_anchor: &mut usize,
1212) {
1213 if let Some(level) = heading_level(name) {
1214 *current_heading = Some(CurrentHeading {
1215 text: String::with_capacity(64),
1216 anchor: attr_value(raw_tag, "id").unwrap_or("").to_string(),
1217 level,
1218 });
1219 } else if name.eq_ignore_ascii_case("a") && raw_tag.contains("header-anchor") {
1220 *skip_header_anchor += 1;
1221 }
1222}
1223
1224fn handle_end_tag(
1225 name: &str,
1226 text: &mut SearchText,
1227 current_heading: &mut Option<CurrentHeading>,
1228 skip_header_anchor: &mut usize,
1229) {
1230 if name.eq_ignore_ascii_case("a") && *skip_header_anchor > 0 {
1231 *skip_header_anchor -= 1;
1232 }
1233 if heading_level(name).is_some() {
1234 if let Some(raw_heading) = current_heading.take() {
1235 let heading_text = normalize_search_text(&raw_heading.text);
1236 if !heading_text.is_empty() {
1237 text.headings.push(SearchHeading {
1238 text: heading_text,
1239 anchor: raw_heading.anchor,
1240 level: raw_heading.level,
1241 });
1242 }
1243 }
1244 }
1245}
1246
1247fn parse_html_tag(raw: &str) -> Option<HtmlTag<'_>> {
1248 let mut tag = raw.trim_start();
1249 if tag.starts_with('!') || tag.starts_with('?') {
1250 return None;
1251 }
1252 let is_end = tag.starts_with('/');
1253 if is_end {
1254 tag = tag[1..].trim_start();
1255 }
1256
1257 let mut name_end = 0;
1258 for (idx, byte) in tag.bytes().enumerate() {
1259 if byte.is_ascii_alphanumeric() || byte == b'-' {
1260 name_end = idx + 1;
1261 } else {
1262 break;
1263 }
1264 }
1265 if name_end == 0 {
1266 return None;
1267 }
1268 Some(HtmlTag {
1269 name: &tag[..name_end],
1270 is_end,
1271 })
1272}
1273
1274fn heading_level(name: &str) -> Option<u8> {
1279 let bytes = name.as_bytes();
1280 if bytes.len() == 2 && bytes[0].eq_ignore_ascii_case(&b'h') && (b'1'..=b'6').contains(&bytes[1])
1281 {
1282 Some(bytes[1] - b'0')
1283 } else {
1284 None
1285 }
1286}
1287
1288fn attr_value<'a>(raw_tag: &'a str, attr_name: &str) -> Option<&'a str> {
1295 let bytes = raw_tag.as_bytes();
1296 let mut cursor = 0;
1297
1298 while cursor < bytes.len() {
1299 while bytes.get(cursor).is_some_and(u8::is_ascii_whitespace) {
1300 cursor += 1;
1301 }
1302
1303 let name_start = cursor;
1304 while bytes.get(cursor).is_some_and(|b| is_attr_name_byte(*b)) {
1305 cursor += 1;
1306 }
1307 if name_start == cursor {
1308 cursor += 1;
1309 continue;
1310 }
1311 let name = &raw_tag[name_start..cursor];
1312
1313 while bytes.get(cursor).is_some_and(u8::is_ascii_whitespace) {
1314 cursor += 1;
1315 }
1316 if bytes.get(cursor) != Some(&b'=') {
1317 continue;
1318 }
1319 cursor += 1;
1320 while bytes.get(cursor).is_some_and(u8::is_ascii_whitespace) {
1321 cursor += 1;
1322 }
1323
1324 let (value_start, value_end) = attr_value_range(raw_tag, cursor);
1325 if name.eq_ignore_ascii_case(attr_name) {
1326 return Some(&raw_tag[value_start..value_end]);
1327 }
1328 cursor = value_end.saturating_add(1);
1329 }
1330
1331 None
1332}
1333
1334fn is_attr_name_byte(byte: u8) -> bool {
1335 byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b':')
1336}
1337
1338fn attr_value_range(raw_tag: &str, cursor: usize) -> (usize, usize) {
1339 let bytes = raw_tag.as_bytes();
1340 match bytes.get(cursor).copied() {
1341 Some(b'"' | b'\'') => {
1342 let quote = bytes[cursor];
1343 let value_start = cursor + 1;
1344 let value_end = raw_tag[value_start..]
1345 .find(char::from(quote))
1346 .map_or(raw_tag.len(), |offset| value_start + offset);
1347 (value_start, value_end)
1348 }
1349 Some(_) => {
1350 let value_start = cursor;
1351 let value_end = raw_tag[value_start..]
1352 .find(char::is_whitespace)
1353 .map_or(raw_tag.len(), |offset| value_start + offset);
1354 (value_start, value_end)
1355 }
1356 None => (raw_tag.len(), raw_tag.len()),
1357 }
1358}
1359
1360fn push_search_text(
1361 text: &mut SearchText,
1362 current_heading: Option<&mut CurrentHeading>,
1363 skip_header_anchor: usize,
1364 value: &str,
1365) {
1366 if skip_header_anchor > 0 {
1367 return;
1368 }
1369 text.content.push_str(value);
1370 if let Some(heading) = current_heading {
1371 heading.text.push_str(value);
1372 }
1373}
1374
1375fn push_search_space(
1376 text: &mut SearchText,
1377 current_heading: Option<&mut CurrentHeading>,
1378 skip_header_anchor: usize,
1379) {
1380 if skip_header_anchor > 0 {
1381 return;
1382 }
1383 text.content.push(' ');
1384 if let Some(heading) = current_heading {
1385 heading.text.push(' ');
1386 }
1387}
1388
1389fn normalize_search_text(raw: &str) -> String {
1390 let decoded = html_escape::decode_html_entities(raw);
1391 let mut normalized = String::with_capacity(decoded.len());
1392 let mut first = true;
1393 for token in decoded.split_whitespace() {
1394 if !first {
1395 normalized.push(' ');
1396 }
1397 normalized.push_str(token);
1398 first = false;
1399 }
1400 normalized
1401}
1402
1403const PRE_BLOCK_MARKER_PREFIX: &str = "<span data-webui-press-pre-block=\"";
1404const PRE_BLOCK_MARKER_SUFFIX: &str = "\"></span>";
1405
1406fn protect_pre_blocks(content: &str) -> (String, Vec<String>) {
1411 use std::fmt::Write as _;
1412 let mut blocks: Vec<String> = Vec::new();
1413 let mut out = String::with_capacity(content.len());
1414 let mut cursor = 0;
1415 while let Some(rel_start) = find_pre_open(&content[cursor..]) {
1416 let start = cursor + rel_start;
1417 if let Some(rel_end) = content[start..].find("</pre>") {
1418 let end = start + rel_end + "</pre>".len();
1419 out.push_str(&content[cursor..start]);
1420 out.push_str(PRE_BLOCK_MARKER_PREFIX);
1421 let _ = write!(&mut out, "{}", blocks.len());
1423 out.push_str(PRE_BLOCK_MARKER_SUFFIX);
1424 blocks.push(content[start..end].to_string());
1425 cursor = end;
1426 } else {
1427 break;
1428 }
1429 }
1430 out.push_str(&content[cursor..]);
1431 (out, blocks)
1432}
1433
1434fn find_pre_open(s: &str) -> Option<usize> {
1437 let bytes = s.as_bytes();
1438 let mut i = 0;
1439 while let Some(rel) = s[i..].find("<pre") {
1440 let pos = i + rel;
1441 let after = pos + 4;
1442 match bytes.get(after) {
1443 Some(b'>' | b' ' | b'\t' | b'\n' | b'\r') => return Some(pos),
1444 _ => i = after,
1445 }
1446 }
1447 None
1448}
1449
1450fn restore_pre_blocks(html: &str, blocks: &[String]) -> String {
1453 if blocks.is_empty() {
1454 return html.to_string();
1455 }
1456 let extra: usize = blocks.iter().map(|b| b.len()).sum();
1457 let mut out = String::with_capacity(html.len() + extra);
1458 let mut cursor = 0;
1459 while let Some(rel) = html[cursor..].find(PRE_BLOCK_MARKER_PREFIX) {
1460 let p = cursor + rel;
1461 out.push_str(&html[cursor..p]);
1462 let after = p + PRE_BLOCK_MARKER_PREFIX.len();
1463 if let Some(end_rel) = html[after..].find(PRE_BLOCK_MARKER_SUFFIX) {
1464 let num_str = &html[after..after + end_rel];
1465 if let Ok(idx) = num_str.parse::<usize>() {
1466 if let Some(block) = blocks.get(idx) {
1467 out.push_str(block);
1468 cursor = after + end_rel + PRE_BLOCK_MARKER_SUFFIX.len();
1469 continue;
1470 }
1471 }
1472 out.push_str(&html[p..after + end_rel + PRE_BLOCK_MARKER_SUFFIX.len()]);
1474 cursor = after + end_rel + PRE_BLOCK_MARKER_SUFFIX.len();
1475 } else {
1476 out.push_str(&html[p..]);
1477 return out;
1478 }
1479 }
1480 out.push_str(&html[cursor..]);
1481 out
1482}
1483
1484fn fxhash(s: &str) -> u64 {
1486 fxhash_bytes(s.as_bytes())
1487}
1488
1489fn fxhash_bytes(bytes: &[u8]) -> u64 {
1490 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
1491 for &b in bytes {
1492 hash ^= u64::from(b);
1493 hash = hash.wrapping_mul(0x100_0000_01b3);
1494 }
1495 hash
1496}
1497
1498#[cfg(test)]
1499mod tests {
1500 use super::*;
1501
1502 type TestResult<T = ()> = std::result::Result<T, Box<dyn std::error::Error>>;
1503
1504 fn test_obj<const N: usize>(entries: [(&str, Value); N]) -> Value {
1505 let mut map = Map::with_capacity(N);
1506 for (key, value) in entries {
1507 map.insert(key.to_string(), value);
1508 }
1509 Value::Object(map)
1510 }
1511
1512 fn string_value(value: &str) -> Value {
1513 Value::String(value.to_string())
1514 }
1515
1516 fn docs_config() -> TestResult<DocsConfig> {
1517 let config = serde_json::from_str(
1518 r#"{
1519 "site": { "title": "Docs" },
1520 "basePath": "/",
1521 "contentDir": ".",
1522 "nav": [],
1523 "sidebar": []
1524 }"#,
1525 )?;
1526 Ok(config)
1527 }
1528
1529 #[test]
1530 fn not_found_state_includes_global_state() -> TestResult {
1531 let config = docs_config()?;
1532 let global = test_obj([("release", test_obj([("version", string_value("v1.2.3"))]))]);
1533
1534 let state = build_not_found_state(NotFoundStateInput {
1535 config: &config,
1536 base_path: "/",
1537 nav: Value::Array(vec![]),
1538 footer: Value::Null,
1539 content: "<h1>404</h1>",
1540 head_injection: "<meta name=\"docs\">",
1541 global_state: Some(&global),
1542 });
1543
1544 assert_eq!(state["release"]["version"], "v1.2.3");
1545 assert_eq!(state["page"]["title"], "Page Not Found");
1546 assert_eq!(state["pageData"], Value::Null);
1547 assert_eq!(state["headTags"], "<meta name=\"docs\">");
1548 Ok(())
1549 }
1550
1551 #[test]
1552 fn custom_home_layout_uses_non_home_regions() {
1553 let state = test_obj([("page", test_obj([("layout", string_value("home"))]))]);
1554 let custom_page = PageDescriptor {
1555 path: "/custom/".to_string(),
1556 is_home: false,
1557 state: state.clone(),
1558 };
1559 let home_page = PageDescriptor {
1560 path: "/".to_string(),
1561 is_home: true,
1562 state,
1563 };
1564
1565 assert_eq!(region_layout(&custom_page), "doc");
1566 assert_eq!(region_layout(&home_page), "home");
1567 }
1568
1569 #[test]
1572 fn truncate_utf8_short_string_unchanged() {
1573 assert_eq!(truncate_utf8("hello", 100), "hello");
1574 }
1575
1576 #[test]
1577 fn truncate_utf8_ascii_at_exact_boundary() {
1578 assert_eq!(truncate_utf8("hello world", 5), "hello");
1579 }
1580
1581 #[test]
1582 fn truncate_utf8_steps_back_off_multibyte_boundary() {
1583 let s = "é-suffix";
1585 let out = truncate_utf8(s, 1);
1586 assert!(s.is_char_boundary(out.len()), "out.len()={}", out.len());
1587 assert_eq!(out, "");
1588 }
1589
1590 #[test]
1591 fn truncate_utf8_keeps_multibyte_when_room_allows() {
1592 let s = "café";
1593 let out = truncate_utf8(s, 5); assert_eq!(out, "café");
1595 }
1596
1597 #[test]
1600 fn protect_pre_blocks_with_attrs() {
1601 let input = r#"<p>before</p><pre class="hljs">code</pre><p>after</p>"#;
1602 let (out, blocks) = protect_pre_blocks(input);
1603 assert_eq!(blocks.len(), 1);
1604 assert!(out.contains(r#"<span data-webui-press-pre-block="0"></span>"#));
1605 assert!(!out.contains("<pre"));
1606 assert_eq!(blocks[0], r#"<pre class="hljs">code</pre>"#);
1607 }
1608
1609 #[test]
1610 fn protect_pre_blocks_bare_open_tag() {
1611 let input = "<pre>code</pre>";
1613 let (out, blocks) = protect_pre_blocks(input);
1614 assert_eq!(blocks.len(), 1, "got out={out:?} blocks={blocks:?}");
1615 assert_eq!(blocks[0], "<pre>code</pre>");
1616 }
1617
1618 #[test]
1619 fn protect_pre_blocks_does_not_match_presentation_tag() {
1620 let input = "<presentation>x</presentation>";
1622 let (_out, blocks) = protect_pre_blocks(input);
1623 assert_eq!(blocks.len(), 0);
1624 }
1625
1626 #[test]
1627 fn protect_then_restore_round_trip() {
1628 let input = "before <pre>a</pre> mid <pre class=\"x\">b</pre> end";
1629 let (protected, blocks) = protect_pre_blocks(input);
1630 let restored = restore_pre_blocks(&protected, &blocks);
1631 assert_eq!(restored, input);
1632 }
1633
1634 #[test]
1635 fn restore_pre_blocks_no_blocks_returns_input() {
1636 assert_eq!(restore_pre_blocks("plain html", &[]), "plain html");
1637 }
1638
1639 #[test]
1640 fn pre_block_placeholder_survives_webui_component_slot_render() -> TestResult {
1641 let input = "<code-block><pre><code>let x = 1;\n</code></pre></code-block>";
1642 let (protected, blocks) = protect_pre_blocks(input);
1643 let page_html = format!("<!DOCTYPE html><html><body>{protected}</body></html>");
1644
1645 let tmp = std::env::temp_dir().join(format!(
1646 "webui-press-slot-test-{}-{:x}",
1647 std::process::id(),
1648 fxhash(input)
1649 ));
1650 if tmp.exists() {
1651 fs::remove_dir_all(&tmp)?;
1652 }
1653 fs::create_dir_all(&tmp)?;
1654 fs::write(tmp.join("index.html"), page_html)?;
1655
1656 let components = vec![Path::new(env!("CARGO_MANIFEST_DIR"))
1657 .join("components")
1658 .to_string_lossy()
1659 .into_owned()];
1660 let build_result = webui::build(BuildOptions {
1661 app_dir: tmp.clone(),
1662 entry: "index.html".to_string(),
1663 plugin: Some(webui::Plugin::WebUI),
1664 components,
1665 ..BuildOptions::default()
1666 })?;
1667
1668 let mut writer = StringWriter::with_capacity(4096);
1669 let handler = WebUIHandler::with_plugin(|| {
1670 Box::new(webui_handler::plugin::webui::WebUIHydrationPlugin::new())
1671 });
1672 let protocol = Protocol::new(build_result.protocol);
1673 handler.render(
1674 &protocol,
1675 &Value::Object(Map::new()),
1676 &RenderOptions::new("index.html", "/"),
1677 &mut writer,
1678 )?;
1679
1680 fs::remove_dir_all(&tmp)?;
1681
1682 let html = restore_pre_blocks(&writer.buf, &blocks);
1683 assert!(
1684 html.contains("<pre><code>let x = 1;\n</code></pre>"),
1685 "slotted pre block should be restored after WebUI render: {html}"
1686 );
1687 assert!(
1688 !html.contains(PRE_BLOCK_MARKER_PREFIX),
1689 "placeholder marker must not survive output: {html}"
1690 );
1691
1692 Ok(())
1693 }
1694
1695 #[test]
1696 fn build_search_entry_decodes_code_text_and_captures_headings() {
1697 let entry = build_search_entry(
1698 "`<for>` Loop Directive",
1699 "/guide/concepts/directives/for/",
1700 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>"##,
1701 );
1702
1703 assert_eq!(entry["title"].as_str(), Some("`<for>` Loop Directive"));
1704 assert_eq!(
1705 entry["content"].as_str(),
1706 Some("<for> Loop Directive Use <for> loops. Syntax Nested Search")
1707 );
1708 let Some(headings) = entry["headings"].as_array() else {
1709 panic!("headings should be an array: {entry:?}");
1710 };
1711 assert_eq!(headings.len(), 3);
1712 assert_eq!(headings[0]["text"].as_str(), Some("<for> Loop Directive"));
1713 assert_eq!(headings[0]["anchor"].as_str(), Some("for"));
1714 assert_eq!(headings[0]["level"].as_u64(), Some(1));
1715 assert_eq!(headings[1]["text"].as_str(), Some("Syntax"));
1716 assert_eq!(headings[1]["anchor"].as_str(), Some("syntax"));
1717 assert_eq!(headings[1]["level"].as_u64(), Some(2));
1718 assert_eq!(headings[2]["text"].as_str(), Some("Nested Search"));
1719 assert_eq!(headings[2]["anchor"].as_str(), Some("nested"));
1720 assert_eq!(headings[2]["level"].as_u64(), Some(3));
1721 }
1722
1723 #[test]
1726 fn fxhash_deterministic() {
1727 assert_eq!(fxhash("abc"), fxhash("abc"));
1728 assert_ne!(fxhash("abc"), fxhash("abd"));
1729 }
1730}