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