1use std::collections::HashMap;
7use std::fs;
8use std::path::{Path, PathBuf};
9use std::sync::atomic::AtomicU64;
10use std::time::Instant;
11
12use console::style;
13use rayon::prelude::*;
14use serde_json::{Map, Value};
15use webui::BuildOptions;
16use webui_handler::{Protocol, RenderOptions, ResponseWriter, WebUIHandler};
17use webui_tokens::TokenFile;
18
19use crate::bundler::{
20 bundle_assets, collect_component_scripts_for_html, discover_component_scripts,
21 extract_bundle_scripts, inject_module_script_tags, inject_script_tag, module_script_tag,
22 page_bundle_signature, resolve_config_component_source, resolve_node_modules, BundleOptions,
23 BundleResult, BundleThread, PageBundleEntry, RootBundleEntry, ScriptSource,
24};
25use crate::content::process_content_with_states;
26use crate::error::{Error, Result};
27use crate::markdown::Highlighter;
28use crate::state::{load_render_states, merge_page_state};
29use crate::types::{BuildStats, DocsConfig};
30
31#[derive(Default)]
41pub struct BuildCache {
42 pub(crate) highlighter: Option<Highlighter>,
45 pub quiet: bool,
49 pub skip_clean: bool,
54 pub dev_mode: bool,
57}
58
59impl BuildCache {
60 #[must_use]
64 pub fn new() -> Self {
65 Self::default()
66 }
67
68 pub fn set_dev_rebuild(&mut self) {
71 self.quiet = true;
72 self.skip_clean = true;
73 self.dev_mode = true;
74 }
75}
76
77fn load_theme(theme: &str, config_dir: &Path) -> Result<TokenFile> {
78 let config_relative = config_dir.join(theme);
79 let resolved = if config_relative.exists() {
80 config_relative
81 .canonicalize()
82 .map_err(|e| Error::Build(format!("Failed to canonicalize {theme}: {e}")))?
83 } else {
84 webui_tokens::resolve_theme_path(theme, config_dir)
85 .map_err(|e| Error::Build(format!("Failed to resolve theme {theme}: {e}")))?
86 };
87 webui_tokens::load_token_file(&resolved)
88 .map_err(|e| Error::Build(format!("Failed to load theme {}: {e}", resolved.display())))
89}
90
91fn inject_theme_tokens(
92 state: &mut Value,
93 token_file: &TokenFile,
94 protocol_tokens: &[String],
95) -> Result<()> {
96 let resolved = webui_tokens::resolve_tokens(protocol_tokens, token_file)
97 .map_err(|e| Error::Build(format!("Failed to resolve theme tokens: {e}")))?;
98 webui_tokens::inject_into_state(state, &resolved);
99 Ok(())
100}
101
102struct NotFoundStateInput<'a> {
103 config: &'a DocsConfig,
104 base_path: &'a str,
105 nav: Value,
106 footer: Value,
107 content: &'a str,
108 head_injection: &'a str,
109 global_state: Option<&'a Value>,
110}
111
112fn build_not_found_state(input: NotFoundStateInput<'_>) -> Value {
113 let state = json_obj([
114 (
115 "site",
116 json_obj([
117 ("title", Value::String(input.config.site.title.clone())),
118 ("base", Value::String(input.base_path.to_string())),
119 ]),
120 ),
121 ("navigation", input.nav),
122 ("sidebar", json_obj([("sections", Value::Array(vec![]))])),
123 (
124 "page",
125 json_obj([
126 ("title", Value::String("Page Not Found".to_string())),
127 (
128 "description",
129 Value::String("The page you're looking for doesn't exist.".to_string()),
130 ),
131 ("content", Value::String(input.content.to_string())),
132 ("isHome", Value::Bool(false)),
133 ("layout", Value::String("doc".to_string())),
134 ]),
135 ),
136 ("hero", Value::Null),
137 ("footer", input.footer),
138 ("prev", Value::Null),
139 ("next", Value::Null),
140 ("pageData", Value::Null),
141 ("headTags", Value::String(input.head_injection.to_string())),
142 ("label", Value::String("Copy".to_string())),
143 ("icon", Value::String("🌙".to_string())),
144 ]);
145
146 merge_page_state(state, input.global_state, None)
147}
148
149static REBUILD_NONCE: AtomicU64 = AtomicU64::new(0);
160
161fn print_header(cache: &BuildCache, title: &str) {
162 if cache.quiet {
163 return;
164 }
165 eprintln!(
166 "\n {} {}",
167 style("⚡").cyan().bold(),
168 style(title).cyan().bold()
169 );
170}
171
172fn print_success(cache: &BuildCache, message: &str) {
173 if cache.quiet {
174 return;
175 }
176 eprintln!(" {} {message}", style("✔").green());
177}
178
179fn print_bundle_summary(cache: &BuildCache, result: &BundleResult) {
180 print_success(
181 cache,
182 &format!(
183 "Bundled {} component script{} into {} root script{} and {} page script group{} in {:.0}ms",
184 result.component_count,
185 if result.component_count == 1 { "" } else { "s" },
186 usize::from(result.root_script.is_some()),
187 if result.root_script.is_some() {
188 ""
189 } else {
190 "s"
191 },
192 result.page_entry_count,
193 if result.page_entry_count == 1 {
194 ""
195 } else {
196 "s"
197 },
198 result.duration.as_secs_f64() * 1000.0
199 ),
200 );
201}
202
203fn print_finish(cache: &BuildCache, message: &str) {
204 if cache.quiet {
205 return;
206 }
207 eprintln!("\n {} {message}\n", style("✨").green());
208}
209
210fn json_obj<const N: usize>(entries: [(&str, Value); N]) -> Value {
212 let mut map = Map::with_capacity(N);
213 for (k, v) in entries {
214 map.insert(k.to_string(), v);
215 }
216 Value::Object(map)
217}
218
219struct StringWriter {
221 buf: String,
222}
223
224impl StringWriter {
225 fn with_capacity(cap: usize) -> Self {
226 Self {
227 buf: String::with_capacity(cap),
228 }
229 }
230}
231
232impl ResponseWriter for StringWriter {
233 fn write(&mut self, content: &str) -> webui_handler::Result<()> {
234 self.buf.push_str(content);
235 Ok(())
236 }
237
238 fn end(&mut self) -> webui_handler::Result<()> {
239 Ok(())
240 }
241}
242
243pub fn build_docs(
253 config: &DocsConfig,
254 config_dir: &Path,
255 template_dir: &Path,
256) -> Result<BuildStats> {
257 let mut cache = BuildCache::new();
258 build_docs_with_cache(config, config_dir, template_dir, &mut cache)
259}
260
261pub fn build_docs_with_cache(
274 config: &DocsConfig,
275 config_dir: &Path,
276 template_dir: &Path,
277 cache: &mut BuildCache,
278) -> Result<BuildStats> {
279 let start = Instant::now();
280 let base_path = &config.base_path;
281 let out_dir = Path::new(&config.out_dir);
282
283 let site_dir = out_dir.to_path_buf();
289
290 let custom_css = config
292 .css
293 .as_ref()
294 .and_then(|p| fs::read_to_string(p).ok())
295 .unwrap_or_default();
296 let token_file = match &config.theme {
297 Some(theme) => Some(load_theme(theme, config_dir)?),
298 None => None,
299 };
300
301 print_header(cache, &config.site.title);
302 eprintln!();
303
304 let base_css_src = template_dir.join("docs.css");
313 let has_base_css = base_css_src.exists();
314 let base_css_link = if has_base_css {
315 format!("<link rel=\"stylesheet\" href=\"{base_path}docs.css\">")
316 } else {
317 String::new()
318 };
319 let has_theme_css = !custom_css.is_empty();
320 let theme_css_link = if has_theme_css {
321 format!("<link rel=\"stylesheet\" href=\"{base_path}theme.css\">")
322 } else {
323 String::new()
324 };
325 let head_injection = {
326 let mut parts: Vec<String> = Vec::with_capacity(2 + config.head.len());
327 if !base_css_link.is_empty() {
328 parts.push(base_css_link.clone());
329 }
330 if !theme_css_link.is_empty() {
331 parts.push(theme_css_link.clone());
332 }
333 for tag in &config.head {
334 parts.push(tag.to_html());
335 }
336 parts.join("\n ")
337 };
338
339 let render_states = load_render_states(config, config_dir)?;
343 let highlighter = cache.highlighter.take().unwrap_or_default();
344 let pages = process_content_with_states(config, &highlighter, &head_injection, &render_states)?;
345
346 cache.highlighter = Some(highlighter);
348
349 let mut component_sources: Vec<String> = Vec::new();
351 let cwd = std::env::current_dir().unwrap_or_default();
352 let builtin_components = template_dir.parent().map(|p| p.join("components"));
354 if let Some(ref bc) = builtin_components {
355 if bc.exists() {
356 component_sources.push(bc.to_string_lossy().to_string());
357 }
358 }
359 if let Some(ref user_sources) = config.components {
363 for source in user_sources {
364 component_sources.push(resolve_config_component_source(source, &cwd));
365 }
366 }
367 component_sources.push(template_dir.to_string_lossy().to_string());
370
371 let template_html = fs::read_to_string(template_dir.join("index.html"))
372 .map_err(|e| Error::Build(format!("Failed to read template: {e}")))?;
373 let component_script_index = discover_component_scripts(&component_sources)?;
374
375 if out_dir.exists() && !cache.skip_clean {
380 remove_dir_all_retry(out_dir)
381 .map_err(|e| Error::Io(format!("Cannot clean output dir: {e}")))?;
382 }
383 fs::create_dir_all(&site_dir).map_err(|e| Error::Io(format!("Cannot create site dir: {e}")))?;
384
385 if has_base_css {
391 fs::copy(&base_css_src, site_dir.join("docs.css"))
392 .map_err(|e| Error::Io(format!("Cannot copy docs.css: {e}")))?;
393 }
394 if has_theme_css {
395 fs::write(site_dir.join("theme.css"), &custom_css)
396 .map_err(|e| Error::Io(format!("Cannot write theme.css: {e}")))?;
397 }
398
399 let handler = WebUIHandler::with_plugin(|| {
400 Box::new(webui_handler::plugin::webui::WebUIHydrationPlugin::new())
401 });
402
403 for page in &pages {
406 let page_dir = site_dir.join(page.path.strip_prefix(base_path).unwrap_or(&page.path));
407 fs::create_dir_all(&page_dir)
408 .map_err(|e| Error::Io(format!("Cannot create dir {}: {e}", page_dir.display())))?;
409 }
410
411 let mut explicit_page_scripts: HashMap<String, Vec<ScriptSource>> = HashMap::new();
418 let mut page_contents_with_scripts: HashMap<String, String> = HashMap::new();
419
420 for page in &pages {
421 let content = page.state["page"]["content"].as_str().unwrap_or("");
422 if content.contains("<script") && content.contains("bundle") {
423 let (modified, scripts) = extract_bundle_scripts(content);
424 if !scripts.is_empty() {
425 page_contents_with_scripts.insert(page.path.clone(), modified);
426 explicit_page_scripts
427 .entry(page.path.clone())
428 .or_default()
429 .extend(scripts);
430 }
431 }
432 }
433
434 for (link, custom_page) in &config.custom_pages {
436 if let Some(script_path) = custom_page.script_file() {
437 let normalized = if link.ends_with('/') {
440 link.clone()
441 } else {
442 format!("{link}/")
443 };
444 let page_path = format!("{}{}", base_path, &normalized[1..]);
445 explicit_page_scripts
446 .entry(page_path)
447 .or_default()
448 .push(ScriptSource::File(script_path.to_string()));
449 }
450 }
451
452 let not_found_content = format!(
453 "<h1>404 — Page Not Found</h1>\
454 <p>The page you're looking for doesn't exist or has been moved.</p>\
455 <p><a href=\"{base_path}\">← Back to Home</a></p>"
456 );
457
458 let template_script_path = template_dir.join("index.ts");
459 let template_script = if template_script_path.exists() {
460 Some(
461 template_script_path
462 .canonicalize()
463 .unwrap_or(template_script_path),
464 )
465 } else {
466 None
467 };
468 let root_component_scripts =
469 collect_component_scripts_for_html(&[template_html.as_str()], &component_script_index)?;
470 let root_bundle = if template_script.is_some() || !root_component_scripts.is_empty() {
471 Some(RootBundleEntry {
472 script_path: template_script,
473 component_scripts: root_component_scripts,
474 })
475 } else {
476 None
477 };
478
479 let mut page_bundles: Vec<PageBundleEntry> = Vec::new();
483 let mut page_bundle_ids: HashMap<String, usize> = HashMap::with_capacity(pages.len());
484 let mut page_bundle_signatures: HashMap<String, usize> = HashMap::new();
485 for page in &pages {
486 let content = page_contents_with_scripts
487 .get(&page.path)
488 .map(String::as_str)
489 .unwrap_or_else(|| page.state["page"]["content"].as_str().unwrap_or(""));
490 let mut component_scripts =
491 collect_component_scripts_for_html(&[content], &component_script_index)?;
492 if let Some(root) = &root_bundle {
493 component_scripts.retain(|path| !root.component_scripts.contains(path));
494 }
495 let explicit_scripts = explicit_page_scripts.remove(&page.path).unwrap_or_default();
496 if component_scripts.is_empty() && explicit_scripts.is_empty() {
497 continue;
498 }
499
500 let signature = page_bundle_signature(&component_scripts, &explicit_scripts, config_dir);
501 if let Some(&id) = page_bundle_signatures.get(&signature) {
502 page_bundle_ids.insert(page.path.clone(), id);
503 continue;
504 }
505
506 let id = page_bundles.len();
507 page_bundle_signatures.insert(signature, id);
508 page_bundle_ids.insert(page.path.clone(), id);
509 page_bundles.push(PageBundleEntry {
510 id,
511 page_path: page.path.clone(),
512 component_scripts,
513 explicit_scripts,
514 });
515 }
516
517 let not_found_component_scripts =
518 collect_component_scripts_for_html(&[not_found_content.as_str()], &component_script_index)?;
519 let not_found_bundle_id = if not_found_component_scripts.is_empty() {
520 None
521 } else {
522 let signature = page_bundle_signature(¬_found_component_scripts, &[], config_dir);
523 if let Some(&id) = page_bundle_signatures.get(&signature) {
524 Some(id)
525 } else {
526 let id = page_bundles.len();
527 page_bundle_signatures.insert(signature, id);
528 page_bundles.push(PageBundleEntry {
529 id,
530 page_path: format!("{base_path}404/"),
531 component_scripts: not_found_component_scripts,
532 explicit_scripts: Vec::new(),
533 });
534 Some(id)
535 }
536 };
537
538 let node_modules = if root_bundle.is_some() || !page_bundles.is_empty() {
541 Some(resolve_node_modules(config_dir)?)
542 } else {
543 None
544 };
545 let (projection_source, projection_completer) = webui::projection_manifest_barrier();
546 let site_dir_for_bundle = site_dir.clone();
547 let root_bundle_for_bundle = root_bundle.clone();
548 let page_bundles_for_bundle = page_bundles.clone();
549 let bundler_config = config.bundler.clone();
550 let config_dir_for_bundle = config_dir.to_path_buf();
551 let content_dir_for_bundle = PathBuf::from(&config.content_dir);
552 let dev_mode = cache.dev_mode;
553 let bundle_thread = BundleThread::spawn(move || {
554 match bundle_assets(&BundleOptions {
555 site_dir: &site_dir_for_bundle,
556 node_modules: node_modules.as_deref(),
557 root_bundle: root_bundle_for_bundle.as_ref(),
558 page_bundles: &page_bundles_for_bundle,
559 bundler_config: bundler_config.as_ref(),
560 dev_mode,
561 config_dir: &config_dir_for_bundle,
562 content_dir: &content_dir_for_bundle,
563 }) {
564 Ok(result) => {
565 projection_completer.complete(Ok(result.projection.clone()));
566 Ok(result)
567 }
568 Err(error) => {
569 projection_completer.complete(Err(error.to_string()));
570 Err(error)
571 }
572 }
573 });
574
575 let total_bytes = std::sync::atomic::AtomicUsize::new(0);
585 let component_css: std::sync::Mutex<HashMap<String, String>> =
586 std::sync::Mutex::new(HashMap::new());
587
588 let page_start = Instant::now();
589 pages.par_iter().try_for_each(|page| -> Result<()> {
590 let page_dir = site_dir.join(page.path.strip_prefix(base_path).unwrap_or(&page.path));
591 let target = page_dir.join("index.html");
592
593 let content = page_contents_with_scripts
595 .get(&page.path)
596 .map(|s| s.as_str())
597 .unwrap_or_else(|| page.state["page"]["content"].as_str().unwrap_or(""));
598
599 let (protected, pre_blocks) = protect_pre_blocks(content);
601
602 let page_html = template_html.replace("{{{page.content}}}", &protected);
604
605 let nonce = REBUILD_NONCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
614 let page_tmp = std::env::temp_dir().join(format!(
615 "webui-press-page-{}-{:x}-{nonce:x}",
616 std::process::id(),
617 fxhash(&page.path),
618 ));
619 if page_tmp.exists() {
620 fs::remove_dir_all(&page_tmp).ok();
621 }
622 fs::create_dir_all(&page_tmp)
623 .map_err(|e| Error::Io(format!("Cannot create page temp: {e}")))?;
624 fs::write(page_tmp.join("index.html"), &page_html)
625 .map_err(|e| Error::Io(format!("Cannot write page temp: {e}")))?;
626
627 let build_result = webui::build(BuildOptions {
628 app_dir: page_tmp.clone(),
629 entry: "index.html".to_string(),
630 plugin: Some(webui::Plugin::WebUI),
631 components: component_sources.clone(),
632 theme: token_file.clone(),
633 projection_manifests: vec![projection_source.clone()],
634 ..BuildOptions::default()
635 })
636 .map_err(|e| Error::Build(format!("{}: {e}", page.path)))?;
637
638 total_bytes.fetch_add(
639 build_result.protocol_bytes.len(),
640 std::sync::atomic::Ordering::Relaxed,
641 );
642
643 if !build_result.css_files.is_empty() {
648 let mut css_map = component_css
649 .lock()
650 .unwrap_or_else(std::sync::PoisonError::into_inner);
651 for (name, content) in build_result.css_files {
652 css_map.entry(name).or_insert(content);
653 }
654 }
655
656 let mut themed_state;
657 let render_state = if let Some(token_file) = token_file.as_ref() {
658 themed_state = page.state.clone();
659 inject_theme_tokens(&mut themed_state, token_file, &build_result.protocol.tokens)?;
660 &themed_state
661 } else {
662 &page.state
663 };
664
665 let protocol = Protocol::new(build_result.protocol);
666 let mut writer = StringWriter::with_capacity(8192);
667 handler
668 .render(
669 &protocol,
670 render_state,
671 &RenderOptions::new("index.html", &page.path),
672 &mut writer,
673 )
674 .map_err(|e| Error::Render(format!("{}: {e}", page.path)))?;
675
676 let html = restore_pre_blocks(&writer.buf, &pre_blocks);
678
679 fs::write(&target, &html)
681 .map_err(|e| Error::Io(format!("Cannot write {}: {e}", page.path)))?;
682
683 fs::remove_dir_all(&page_tmp).ok();
684 Ok(())
685 })?;
686
687 print_success(
688 cache,
689 &format!(
690 "Rendered {} pages in {:.0}ms",
691 pages.len(),
692 page_start.elapsed().as_secs_f64() * 1000.0
693 ),
694 );
695 let bundle_result = bundle_thread.join()?;
696 print_bundle_summary(cache, &bundle_result);
697
698 let search_path = site_dir.join("search-index.json");
701 let search_entries: Vec<Value> = pages
702 .iter()
703 .filter(|p| !p.is_home)
704 .map(|p| {
705 let html = p.state["page"]["content"].as_str().unwrap_or("");
706 let title = p.state["page"]["title"].as_str().unwrap_or("");
707 build_search_entry(title, &p.path, html)
708 })
709 .collect();
710 fs::write(
711 &search_path,
712 serde_json::to_string(&search_entries)
713 .map_err(|e| Error::Build(format!("JSON error: {e}")))?,
714 )
715 .map_err(|e| Error::Io(e.to_string()))?;
716 print_success(
717 cache,
718 &format!("Indexed {} pages for search", search_entries.len()),
719 );
720
721 let public_dir = Path::new(&config.public_dir);
723 if public_dir.exists() {
724 copy_dir(public_dir, &site_dir)?;
725 }
726
727 let nav_val = pages
729 .first()
730 .map(|p| p.state["navigation"].clone())
731 .unwrap_or_default();
732
733 let footer_val = config
734 .footer
735 .as_ref()
736 .map(|f| json_obj([("html", Value::String(f.html.clone()))]))
737 .unwrap_or(Value::Null);
738
739 let mut not_found_state = build_not_found_state(NotFoundStateInput {
740 config,
741 base_path,
742 nav: nav_val,
743 footer: footer_val,
744 content: ¬_found_content,
745 head_injection: &head_injection,
746 global_state: render_states.global(),
747 });
748
749 let not_found_html = template_html.replace("{{{page.content}}}", ¬_found_content);
750 let nf_tmp = std::env::temp_dir().join(format!(
751 "webui-press-404-{}-{:x}",
752 std::process::id(),
753 REBUILD_NONCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
754 ));
755 if nf_tmp.exists() {
756 fs::remove_dir_all(&nf_tmp).ok();
757 }
758 fs::create_dir_all(&nf_tmp).map_err(|e| Error::Io(e.to_string()))?;
759 fs::write(nf_tmp.join("index.html"), ¬_found_html).map_err(|e| Error::Io(e.to_string()))?;
760
761 let nf_build = webui::build(BuildOptions {
762 app_dir: nf_tmp.clone(),
763 entry: "index.html".to_string(),
764 plugin: Some(webui::Plugin::WebUI),
765 components: component_sources.clone(),
766 theme: token_file.clone(),
767 projection_manifests: vec![projection_source],
768 ..BuildOptions::default()
769 })
770 .map_err(|e| Error::Build(format!("404 build failed: {e}")))?;
771
772 if let Some(token_file) = token_file.as_ref() {
773 inject_theme_tokens(&mut not_found_state, token_file, &nf_build.protocol.tokens)?;
774 }
775
776 {
781 let mut css_map = component_css
782 .lock()
783 .unwrap_or_else(std::sync::PoisonError::into_inner);
784 for (name, content) in &nf_build.css_files {
785 css_map
786 .entry(name.clone())
787 .or_insert_with(|| content.clone());
788 }
789 }
790
791 let nf_protocol = Protocol::new(nf_build.protocol);
792 let mut writer_404 = StringWriter::with_capacity(4096);
793 handler
794 .render(
795 &nf_protocol,
796 ¬_found_state,
797 &RenderOptions::new("index.html", &format!("{base_path}404/")),
798 &mut writer_404,
799 )
800 .map_err(|e| Error::Render(format!("404: {e}")))?;
801
802 fs::write(site_dir.join("404.html"), writer_404.buf).map_err(|e| Error::Io(e.to_string()))?;
803 fs::remove_dir_all(&nf_tmp).ok();
804 print_success(cache, "Generated 404 page");
805
806 let css_map_local = component_css
810 .into_inner()
811 .unwrap_or_else(std::sync::PoisonError::into_inner);
812 let mut css_names: Vec<&String> = css_map_local.keys().collect();
813 css_names.sort();
814 let css_count = css_names.len();
815 for name in css_names {
816 fs::write(site_dir.join(name), &css_map_local[name])
817 .map_err(|e| Error::Io(format!("Cannot write {name}: {e}")))?;
818 }
819 if css_count > 0 {
820 print_success(cache, &format!("Wrote {css_count} component stylesheets"));
821 }
822
823 if bundle_result.root_script.is_some() || !bundle_result.script_map.is_empty() {
825 let mut linked_count = 0usize;
826 for page in &pages {
827 let page_rel_paths = if let Some(bundle_id) = page_bundle_ids.get(&page.path) {
828 let rel_paths = bundle_result.script_map.get(bundle_id).ok_or_else(|| {
829 Error::Build(format!(
830 "Missing bundled script for page {} entry {}",
831 page.path, bundle_id
832 ))
833 })?;
834 if rel_paths.is_empty() {
835 None
836 } else {
837 Some(rel_paths)
838 }
839 } else {
840 None
841 };
842 if bundle_result.root_script.is_none() && page_rel_paths.is_none() {
843 continue;
844 }
845 let page_dir = site_dir.join(page.path.strip_prefix(base_path).unwrap_or(&page.path));
846 let target = page_dir.join("index.html");
847 let mut html = fs::read_to_string(&target)
848 .map_err(|e| Error::Io(format!("Cannot read {}: {e}", target.display())))?;
849
850 if let Some(rel_path) = bundle_result.root_script.as_ref() {
851 let tag = module_script_tag(base_path, rel_path);
852 inject_script_tag(&mut html, &tag);
853 linked_count += 1;
854 }
855 if let Some(rel_paths) = page_rel_paths {
856 linked_count += inject_module_script_tags(&mut html, base_path, rel_paths);
857 }
858
859 fs::write(&target, &html)
860 .map_err(|e| Error::Io(format!("Cannot rewrite {}: {e}", page.path)))?;
861 }
862 if let Some(bundle_id) = not_found_bundle_id {
863 let rel_paths = bundle_result.script_map.get(&bundle_id).ok_or_else(|| {
864 Error::Build(format!(
865 "Missing bundled script for 404 page entry {bundle_id}"
866 ))
867 })?;
868 let target = site_dir.join("404.html");
869 let mut html = fs::read_to_string(&target)
870 .map_err(|e| Error::Io(format!("Cannot read {}: {e}", target.display())))?;
871 if let Some(rel_path) = bundle_result.root_script.as_ref() {
872 let tag = module_script_tag(base_path, rel_path);
873 inject_script_tag(&mut html, &tag);
874 linked_count += 1;
875 }
876 linked_count += inject_module_script_tags(&mut html, base_path, rel_paths);
877 fs::write(&target, &html)
878 .map_err(|e| Error::Io(format!("Cannot rewrite 404.html: {e}")))?;
879 } else if let Some(rel_path) = bundle_result.root_script.as_ref() {
880 let target = site_dir.join("404.html");
881 let mut html = fs::read_to_string(&target)
882 .map_err(|e| Error::Io(format!("Cannot read {}: {e}", target.display())))?;
883 let tag = module_script_tag(base_path, rel_path);
884 inject_script_tag(&mut html, &tag);
885 fs::write(&target, &html)
886 .map_err(|e| Error::Io(format!("Cannot rewrite 404.html: {e}")))?;
887 linked_count += 1;
888 }
889 if linked_count > 0 {
890 print_success(
891 cache,
892 &format!(
893 "Linked {} script tag{}",
894 linked_count,
895 if linked_count == 1 { "" } else { "s" }
896 ),
897 );
898 }
899 }
900
901 let elapsed = start.elapsed();
902 let total_bytes = total_bytes.load(std::sync::atomic::Ordering::Relaxed);
903 print_finish(cache, &format!("Built in {:.1}s", elapsed.as_secs_f64()));
904
905 Ok(BuildStats {
906 pages: pages.len(),
907 protocol_bytes: total_bytes,
908 })
909}
910
911fn remove_dir_all_retry(path: &Path) -> std::io::Result<()> {
930 const MAX_ATTEMPTS: u32 = 5;
931 let mut delay = std::time::Duration::from_millis(20);
932 for attempt in 1..=MAX_ATTEMPTS {
933 match fs::remove_dir_all(path) {
934 Ok(()) => return Ok(()),
935 Err(e) if attempt < MAX_ATTEMPTS && is_dir_not_empty(&e) => {
936 std::thread::sleep(delay);
937 delay = delay.saturating_mul(2);
938 }
939 Err(e) => return Err(e),
940 }
941 }
942 Ok(())
945}
946
947fn is_dir_not_empty(e: &std::io::Error) -> bool {
951 match e.raw_os_error() {
952 Some(66) => true, Some(39) => true, Some(145) => true, _ => false,
956 }
957}
958
959fn copy_dir(src: &Path, dest: &Path) -> Result<()> {
961 if !src.exists() {
962 return Ok(());
963 }
964 fs::create_dir_all(dest).map_err(|e| Error::Io(e.to_string()))?;
965 let mut stack: Vec<(std::path::PathBuf, std::path::PathBuf)> =
966 vec![(src.to_path_buf(), dest.to_path_buf())];
967 while let Some((s, d)) = stack.pop() {
968 for entry in fs::read_dir(&s).map_err(|e| Error::Io(e.to_string()))? {
969 let entry = entry.map_err(|e| Error::Io(e.to_string()))?;
970 let dest_path = d.join(entry.file_name());
971 let ft = entry.file_type().map_err(|e| Error::Io(e.to_string()))?;
972 if ft.is_dir() {
973 fs::create_dir_all(&dest_path).map_err(|e| Error::Io(e.to_string()))?;
974 stack.push((entry.path(), dest_path));
975 } else {
976 fs::copy(entry.path(), &dest_path).map_err(|e| Error::Io(e.to_string()))?;
977 }
978 }
979 }
980 Ok(())
981}
982
983pub(crate) fn truncate_utf8(s: &str, max: usize) -> &str {
985 if s.len() <= max {
986 return s;
987 }
988 let mut end = max;
989 while end > 0 && !s.is_char_boundary(end) {
990 end -= 1;
991 }
992 &s[..end]
993}
994
995#[derive(Default)]
996struct SearchText {
997 content: String,
998 headings: Vec<SearchHeading>,
999}
1000
1001struct SearchHeading {
1006 text: String,
1007 anchor: String,
1008 level: u8,
1009}
1010
1011struct CurrentHeading {
1013 text: String,
1014 anchor: String,
1015 level: u8,
1016}
1017
1018struct HtmlTag<'a> {
1019 name: &'a str,
1020 is_end: bool,
1021}
1022
1023pub(crate) fn build_search_entry(title: &str, path: &str, html: &str) -> Value {
1027 let search_text = extract_search_text(html);
1028 let content = normalize_search_text(&search_text.content);
1029 let truncated = truncate_utf8(&content, 500);
1030 let headings: Vec<Value> = search_text
1031 .headings
1032 .into_iter()
1033 .map(|heading| {
1034 json_obj([
1035 ("text", Value::String(heading.text)),
1036 ("anchor", Value::String(heading.anchor)),
1037 (
1038 "level",
1039 Value::Number(serde_json::Number::from(u64::from(heading.level))),
1040 ),
1041 ])
1042 })
1043 .collect();
1044 json_obj([
1045 ("title", Value::String(title.to_string())),
1046 ("path", Value::String(path.to_string())),
1047 ("content", Value::String(truncated.to_string())),
1048 ("headings", Value::Array(headings)),
1049 ])
1050}
1051
1052fn extract_search_text(html: &str) -> SearchText {
1053 let mut text = SearchText {
1054 content: String::with_capacity(html.len() / 2),
1055 headings: Vec::with_capacity(8),
1056 };
1057 let mut current_heading: Option<CurrentHeading> = None;
1058 let mut skip_header_anchor = 0usize;
1059 let mut cursor = 0;
1060
1061 while cursor < html.len() {
1062 let remaining = &html[cursor..];
1063 let Some(tag_offset) = remaining.find('<') else {
1064 push_search_text(
1065 &mut text,
1066 current_heading.as_mut(),
1067 skip_header_anchor,
1068 remaining,
1069 );
1070 break;
1071 };
1072 let text_end = cursor + tag_offset;
1073 push_search_text(
1074 &mut text,
1075 current_heading.as_mut(),
1076 skip_header_anchor,
1077 &html[cursor..text_end],
1078 );
1079 let tag_start = text_end + 1;
1080 let Some(tag_len) = html[tag_start..].find('>') else {
1081 push_search_text(
1082 &mut text,
1083 current_heading.as_mut(),
1084 skip_header_anchor,
1085 &html[text_end..],
1086 );
1087 break;
1088 };
1089 let tag_end = tag_start + tag_len;
1090 handle_search_tag(
1091 &html[tag_start..tag_end],
1092 &mut text,
1093 &mut current_heading,
1094 &mut skip_header_anchor,
1095 );
1096 cursor = tag_end + 1;
1097 }
1098
1099 text
1100}
1101
1102fn handle_search_tag(
1103 raw_tag: &str,
1104 text: &mut SearchText,
1105 current_heading: &mut Option<CurrentHeading>,
1106 skip_header_anchor: &mut usize,
1107) {
1108 if let Some(tag) = parse_html_tag(raw_tag) {
1109 if tag.is_end {
1110 handle_end_tag(tag.name, text, current_heading, skip_header_anchor);
1111 } else {
1112 handle_start_tag(tag.name, raw_tag, current_heading, skip_header_anchor);
1113 }
1114 }
1115 push_search_space(text, current_heading.as_mut(), *skip_header_anchor);
1116}
1117
1118fn handle_start_tag(
1119 name: &str,
1120 raw_tag: &str,
1121 current_heading: &mut Option<CurrentHeading>,
1122 skip_header_anchor: &mut usize,
1123) {
1124 if let Some(level) = heading_level(name) {
1125 *current_heading = Some(CurrentHeading {
1126 text: String::with_capacity(64),
1127 anchor: attr_value(raw_tag, "id").unwrap_or("").to_string(),
1128 level,
1129 });
1130 } else if name.eq_ignore_ascii_case("a") && raw_tag.contains("header-anchor") {
1131 *skip_header_anchor += 1;
1132 }
1133}
1134
1135fn handle_end_tag(
1136 name: &str,
1137 text: &mut SearchText,
1138 current_heading: &mut Option<CurrentHeading>,
1139 skip_header_anchor: &mut usize,
1140) {
1141 if name.eq_ignore_ascii_case("a") && *skip_header_anchor > 0 {
1142 *skip_header_anchor -= 1;
1143 }
1144 if heading_level(name).is_some() {
1145 if let Some(raw_heading) = current_heading.take() {
1146 let heading_text = normalize_search_text(&raw_heading.text);
1147 if !heading_text.is_empty() {
1148 text.headings.push(SearchHeading {
1149 text: heading_text,
1150 anchor: raw_heading.anchor,
1151 level: raw_heading.level,
1152 });
1153 }
1154 }
1155 }
1156}
1157
1158fn parse_html_tag(raw: &str) -> Option<HtmlTag<'_>> {
1159 let mut tag = raw.trim_start();
1160 if tag.starts_with('!') || tag.starts_with('?') {
1161 return None;
1162 }
1163 let is_end = tag.starts_with('/');
1164 if is_end {
1165 tag = tag[1..].trim_start();
1166 }
1167
1168 let mut name_end = 0;
1169 for (idx, byte) in tag.bytes().enumerate() {
1170 if byte.is_ascii_alphanumeric() || byte == b'-' {
1171 name_end = idx + 1;
1172 } else {
1173 break;
1174 }
1175 }
1176 if name_end == 0 {
1177 return None;
1178 }
1179 Some(HtmlTag {
1180 name: &tag[..name_end],
1181 is_end,
1182 })
1183}
1184
1185fn heading_level(name: &str) -> Option<u8> {
1190 let bytes = name.as_bytes();
1191 if bytes.len() == 2 && bytes[0].eq_ignore_ascii_case(&b'h') && (b'1'..=b'6').contains(&bytes[1])
1192 {
1193 Some(bytes[1] - b'0')
1194 } else {
1195 None
1196 }
1197}
1198
1199fn attr_value<'a>(raw_tag: &'a str, attr_name: &str) -> Option<&'a str> {
1206 let bytes = raw_tag.as_bytes();
1207 let mut cursor = 0;
1208
1209 while cursor < bytes.len() {
1210 while bytes.get(cursor).is_some_and(u8::is_ascii_whitespace) {
1211 cursor += 1;
1212 }
1213
1214 let name_start = cursor;
1215 while bytes.get(cursor).is_some_and(|b| is_attr_name_byte(*b)) {
1216 cursor += 1;
1217 }
1218 if name_start == cursor {
1219 cursor += 1;
1220 continue;
1221 }
1222 let name = &raw_tag[name_start..cursor];
1223
1224 while bytes.get(cursor).is_some_and(u8::is_ascii_whitespace) {
1225 cursor += 1;
1226 }
1227 if bytes.get(cursor) != Some(&b'=') {
1228 continue;
1229 }
1230 cursor += 1;
1231 while bytes.get(cursor).is_some_and(u8::is_ascii_whitespace) {
1232 cursor += 1;
1233 }
1234
1235 let (value_start, value_end) = attr_value_range(raw_tag, cursor);
1236 if name.eq_ignore_ascii_case(attr_name) {
1237 return Some(&raw_tag[value_start..value_end]);
1238 }
1239 cursor = value_end.saturating_add(1);
1240 }
1241
1242 None
1243}
1244
1245fn is_attr_name_byte(byte: u8) -> bool {
1246 byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b':')
1247}
1248
1249fn attr_value_range(raw_tag: &str, cursor: usize) -> (usize, usize) {
1250 let bytes = raw_tag.as_bytes();
1251 match bytes.get(cursor).copied() {
1252 Some(b'"' | b'\'') => {
1253 let quote = bytes[cursor];
1254 let value_start = cursor + 1;
1255 let value_end = raw_tag[value_start..]
1256 .find(char::from(quote))
1257 .map_or(raw_tag.len(), |offset| value_start + offset);
1258 (value_start, value_end)
1259 }
1260 Some(_) => {
1261 let value_start = cursor;
1262 let value_end = raw_tag[value_start..]
1263 .find(char::is_whitespace)
1264 .map_or(raw_tag.len(), |offset| value_start + offset);
1265 (value_start, value_end)
1266 }
1267 None => (raw_tag.len(), raw_tag.len()),
1268 }
1269}
1270
1271fn push_search_text(
1272 text: &mut SearchText,
1273 current_heading: Option<&mut CurrentHeading>,
1274 skip_header_anchor: usize,
1275 value: &str,
1276) {
1277 if skip_header_anchor > 0 {
1278 return;
1279 }
1280 text.content.push_str(value);
1281 if let Some(heading) = current_heading {
1282 heading.text.push_str(value);
1283 }
1284}
1285
1286fn push_search_space(
1287 text: &mut SearchText,
1288 current_heading: Option<&mut CurrentHeading>,
1289 skip_header_anchor: usize,
1290) {
1291 if skip_header_anchor > 0 {
1292 return;
1293 }
1294 text.content.push(' ');
1295 if let Some(heading) = current_heading {
1296 heading.text.push(' ');
1297 }
1298}
1299
1300fn normalize_search_text(raw: &str) -> String {
1301 let decoded = html_escape::decode_html_entities(raw);
1302 let mut normalized = String::with_capacity(decoded.len());
1303 let mut first = true;
1304 for token in decoded.split_whitespace() {
1305 if !first {
1306 normalized.push(' ');
1307 }
1308 normalized.push_str(token);
1309 first = false;
1310 }
1311 normalized
1312}
1313
1314const PRE_BLOCK_MARKER_PREFIX: &str = "<span data-webui-press-pre-block=\"";
1315const PRE_BLOCK_MARKER_SUFFIX: &str = "\"></span>";
1316
1317fn protect_pre_blocks(content: &str) -> (String, Vec<String>) {
1322 use std::fmt::Write as _;
1323 let mut blocks: Vec<String> = Vec::new();
1324 let mut out = String::with_capacity(content.len());
1325 let mut cursor = 0;
1326 while let Some(rel_start) = find_pre_open(&content[cursor..]) {
1327 let start = cursor + rel_start;
1328 if let Some(rel_end) = content[start..].find("</pre>") {
1329 let end = start + rel_end + "</pre>".len();
1330 out.push_str(&content[cursor..start]);
1331 out.push_str(PRE_BLOCK_MARKER_PREFIX);
1332 let _ = write!(&mut out, "{}", blocks.len());
1334 out.push_str(PRE_BLOCK_MARKER_SUFFIX);
1335 blocks.push(content[start..end].to_string());
1336 cursor = end;
1337 } else {
1338 break;
1339 }
1340 }
1341 out.push_str(&content[cursor..]);
1342 (out, blocks)
1343}
1344
1345fn find_pre_open(s: &str) -> Option<usize> {
1348 let bytes = s.as_bytes();
1349 let mut i = 0;
1350 while let Some(rel) = s[i..].find("<pre") {
1351 let pos = i + rel;
1352 let after = pos + 4;
1353 match bytes.get(after) {
1354 Some(b'>' | b' ' | b'\t' | b'\n' | b'\r') => return Some(pos),
1355 _ => i = after,
1356 }
1357 }
1358 None
1359}
1360
1361fn restore_pre_blocks(html: &str, blocks: &[String]) -> String {
1364 if blocks.is_empty() {
1365 return html.to_string();
1366 }
1367 let extra: usize = blocks.iter().map(|b| b.len()).sum();
1368 let mut out = String::with_capacity(html.len() + extra);
1369 let mut cursor = 0;
1370 while let Some(rel) = html[cursor..].find(PRE_BLOCK_MARKER_PREFIX) {
1371 let p = cursor + rel;
1372 out.push_str(&html[cursor..p]);
1373 let after = p + PRE_BLOCK_MARKER_PREFIX.len();
1374 if let Some(end_rel) = html[after..].find(PRE_BLOCK_MARKER_SUFFIX) {
1375 let num_str = &html[after..after + end_rel];
1376 if let Ok(idx) = num_str.parse::<usize>() {
1377 if let Some(block) = blocks.get(idx) {
1378 out.push_str(block);
1379 cursor = after + end_rel + PRE_BLOCK_MARKER_SUFFIX.len();
1380 continue;
1381 }
1382 }
1383 out.push_str(&html[p..after + end_rel + PRE_BLOCK_MARKER_SUFFIX.len()]);
1385 cursor = after + end_rel + PRE_BLOCK_MARKER_SUFFIX.len();
1386 } else {
1387 out.push_str(&html[p..]);
1388 return out;
1389 }
1390 }
1391 out.push_str(&html[cursor..]);
1392 out
1393}
1394
1395fn fxhash(s: &str) -> u64 {
1397 fxhash_bytes(s.as_bytes())
1398}
1399
1400fn fxhash_bytes(bytes: &[u8]) -> u64 {
1401 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
1402 for &b in bytes {
1403 hash ^= u64::from(b);
1404 hash = hash.wrapping_mul(0x100_0000_01b3);
1405 }
1406 hash
1407}
1408
1409#[cfg(test)]
1410mod tests {
1411 use super::*;
1412
1413 type TestResult<T = ()> = std::result::Result<T, Box<dyn std::error::Error>>;
1414
1415 fn test_obj<const N: usize>(entries: [(&str, Value); N]) -> Value {
1416 let mut map = Map::with_capacity(N);
1417 for (key, value) in entries {
1418 map.insert(key.to_string(), value);
1419 }
1420 Value::Object(map)
1421 }
1422
1423 fn string_value(value: &str) -> Value {
1424 Value::String(value.to_string())
1425 }
1426
1427 fn docs_config() -> TestResult<DocsConfig> {
1428 let config = serde_json::from_str(
1429 r#"{
1430 "site": { "title": "Docs" },
1431 "basePath": "/",
1432 "contentDir": ".",
1433 "nav": [],
1434 "sidebar": []
1435 }"#,
1436 )?;
1437 Ok(config)
1438 }
1439
1440 #[test]
1441 fn not_found_state_includes_global_state() -> TestResult {
1442 let config = docs_config()?;
1443 let global = test_obj([("release", test_obj([("version", string_value("v1.2.3"))]))]);
1444
1445 let state = build_not_found_state(NotFoundStateInput {
1446 config: &config,
1447 base_path: "/",
1448 nav: Value::Array(vec![]),
1449 footer: Value::Null,
1450 content: "<h1>404</h1>",
1451 head_injection: "<meta name=\"docs\">",
1452 global_state: Some(&global),
1453 });
1454
1455 assert_eq!(state["release"]["version"], "v1.2.3");
1456 assert_eq!(state["page"]["title"], "Page Not Found");
1457 assert_eq!(state["pageData"], Value::Null);
1458 assert_eq!(state["headTags"], "<meta name=\"docs\">");
1459 Ok(())
1460 }
1461
1462 #[test]
1465 fn truncate_utf8_short_string_unchanged() {
1466 assert_eq!(truncate_utf8("hello", 100), "hello");
1467 }
1468
1469 #[test]
1470 fn truncate_utf8_ascii_at_exact_boundary() {
1471 assert_eq!(truncate_utf8("hello world", 5), "hello");
1472 }
1473
1474 #[test]
1475 fn truncate_utf8_steps_back_off_multibyte_boundary() {
1476 let s = "é-suffix";
1478 let out = truncate_utf8(s, 1);
1479 assert!(s.is_char_boundary(out.len()), "out.len()={}", out.len());
1480 assert_eq!(out, "");
1481 }
1482
1483 #[test]
1484 fn truncate_utf8_keeps_multibyte_when_room_allows() {
1485 let s = "café";
1486 let out = truncate_utf8(s, 5); assert_eq!(out, "café");
1488 }
1489
1490 #[test]
1493 fn protect_pre_blocks_with_attrs() {
1494 let input = r#"<p>before</p><pre class="hljs">code</pre><p>after</p>"#;
1495 let (out, blocks) = protect_pre_blocks(input);
1496 assert_eq!(blocks.len(), 1);
1497 assert!(out.contains(r#"<span data-webui-press-pre-block="0"></span>"#));
1498 assert!(!out.contains("<pre"));
1499 assert_eq!(blocks[0], r#"<pre class="hljs">code</pre>"#);
1500 }
1501
1502 #[test]
1503 fn protect_pre_blocks_bare_open_tag() {
1504 let input = "<pre>code</pre>";
1506 let (out, blocks) = protect_pre_blocks(input);
1507 assert_eq!(blocks.len(), 1, "got out={out:?} blocks={blocks:?}");
1508 assert_eq!(blocks[0], "<pre>code</pre>");
1509 }
1510
1511 #[test]
1512 fn protect_pre_blocks_does_not_match_presentation_tag() {
1513 let input = "<presentation>x</presentation>";
1515 let (_out, blocks) = protect_pre_blocks(input);
1516 assert_eq!(blocks.len(), 0);
1517 }
1518
1519 #[test]
1520 fn protect_then_restore_round_trip() {
1521 let input = "before <pre>a</pre> mid <pre class=\"x\">b</pre> end";
1522 let (protected, blocks) = protect_pre_blocks(input);
1523 let restored = restore_pre_blocks(&protected, &blocks);
1524 assert_eq!(restored, input);
1525 }
1526
1527 #[test]
1528 fn restore_pre_blocks_no_blocks_returns_input() {
1529 assert_eq!(restore_pre_blocks("plain html", &[]), "plain html");
1530 }
1531
1532 #[test]
1533 fn pre_block_placeholder_survives_webui_component_slot_render() -> TestResult {
1534 let input = "<code-block><pre><code>let x = 1;\n</code></pre></code-block>";
1535 let (protected, blocks) = protect_pre_blocks(input);
1536 let page_html = format!("<!DOCTYPE html><html><body>{protected}</body></html>");
1537
1538 let tmp = std::env::temp_dir().join(format!(
1539 "webui-press-slot-test-{}-{:x}",
1540 std::process::id(),
1541 fxhash(input)
1542 ));
1543 if tmp.exists() {
1544 fs::remove_dir_all(&tmp)?;
1545 }
1546 fs::create_dir_all(&tmp)?;
1547 fs::write(tmp.join("index.html"), page_html)?;
1548
1549 let components = vec![Path::new(env!("CARGO_MANIFEST_DIR"))
1550 .join("components")
1551 .to_string_lossy()
1552 .into_owned()];
1553 let build_result = webui::build(BuildOptions {
1554 app_dir: tmp.clone(),
1555 entry: "index.html".to_string(),
1556 plugin: Some(webui::Plugin::WebUI),
1557 components,
1558 ..BuildOptions::default()
1559 })?;
1560
1561 let mut writer = StringWriter::with_capacity(4096);
1562 let handler = WebUIHandler::with_plugin(|| {
1563 Box::new(webui_handler::plugin::webui::WebUIHydrationPlugin::new())
1564 });
1565 let protocol = Protocol::new(build_result.protocol);
1566 handler.render(
1567 &protocol,
1568 &Value::Object(Map::new()),
1569 &RenderOptions::new("index.html", "/"),
1570 &mut writer,
1571 )?;
1572
1573 fs::remove_dir_all(&tmp)?;
1574
1575 let html = restore_pre_blocks(&writer.buf, &blocks);
1576 assert!(
1577 html.contains("<pre><code>let x = 1;\n</code></pre>"),
1578 "slotted pre block should be restored after WebUI render: {html}"
1579 );
1580 assert!(
1581 !html.contains(PRE_BLOCK_MARKER_PREFIX),
1582 "placeholder marker must not survive output: {html}"
1583 );
1584
1585 Ok(())
1586 }
1587
1588 #[test]
1589 fn build_search_entry_decodes_code_text_and_captures_headings() {
1590 let entry = build_search_entry(
1591 "`<for>` Loop Directive",
1592 "/guide/concepts/directives/for/",
1593 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>"##,
1594 );
1595
1596 assert_eq!(entry["title"].as_str(), Some("`<for>` Loop Directive"));
1597 assert_eq!(
1598 entry["content"].as_str(),
1599 Some("<for> Loop Directive Use <for> loops. Syntax Nested Search")
1600 );
1601 let Some(headings) = entry["headings"].as_array() else {
1602 panic!("headings should be an array: {entry:?}");
1603 };
1604 assert_eq!(headings.len(), 3);
1605 assert_eq!(headings[0]["text"].as_str(), Some("<for> Loop Directive"));
1606 assert_eq!(headings[0]["anchor"].as_str(), Some("for"));
1607 assert_eq!(headings[0]["level"].as_u64(), Some(1));
1608 assert_eq!(headings[1]["text"].as_str(), Some("Syntax"));
1609 assert_eq!(headings[1]["anchor"].as_str(), Some("syntax"));
1610 assert_eq!(headings[1]["level"].as_u64(), Some(2));
1611 assert_eq!(headings[2]["text"].as_str(), Some("Nested Search"));
1612 assert_eq!(headings[2]["anchor"].as_str(), Some("nested"));
1613 assert_eq!(headings[2]["level"].as_u64(), Some(3));
1614 }
1615
1616 #[test]
1619 fn fxhash_deterministic() {
1620 assert_eq!(fxhash("abc"), fxhash("abc"));
1621 assert_ne!(fxhash("abc"), fxhash("abd"));
1622 }
1623}