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