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::{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_finish(cache: &BuildCache, message: &str) {
180 if cache.quiet {
181 return;
182 }
183 eprintln!("\n {} {message}\n", style("✨").green());
184}
185
186fn json_obj<const N: usize>(entries: [(&str, Value); N]) -> Value {
188 let mut map = Map::with_capacity(N);
189 for (k, v) in entries {
190 map.insert(k.to_string(), v);
191 }
192 Value::Object(map)
193}
194
195struct StringWriter {
197 buf: String,
198}
199
200impl StringWriter {
201 fn with_capacity(cap: usize) -> Self {
202 Self {
203 buf: String::with_capacity(cap),
204 }
205 }
206}
207
208impl ResponseWriter for StringWriter {
209 fn write(&mut self, content: &str) -> webui_handler::Result<()> {
210 self.buf.push_str(content);
211 Ok(())
212 }
213
214 fn end(&mut self) -> webui_handler::Result<()> {
215 Ok(())
216 }
217}
218
219pub fn build_docs(
229 config: &DocsConfig,
230 config_dir: &Path,
231 template_dir: &Path,
232) -> Result<BuildStats> {
233 let mut cache = BuildCache::new();
234 build_docs_with_cache(config, config_dir, template_dir, &mut cache)
235}
236
237pub fn build_docs_with_cache(
250 config: &DocsConfig,
251 config_dir: &Path,
252 template_dir: &Path,
253 cache: &mut BuildCache,
254) -> Result<BuildStats> {
255 let start = Instant::now();
256 let base_path = &config.base_path;
257 let out_dir = Path::new(&config.out_dir);
258
259 let site_dir = out_dir.to_path_buf();
265
266 let custom_css = config
268 .css
269 .as_ref()
270 .and_then(|p| fs::read_to_string(p).ok())
271 .unwrap_or_default();
272 let token_file = match &config.theme {
273 Some(theme) => Some(load_theme(theme, config_dir)?),
274 None => None,
275 };
276
277 print_header(cache, &config.site.title);
278 eprintln!();
279
280 let base_css_src = template_dir.join("docs.css");
289 let has_base_css = base_css_src.exists();
290 let base_css_link = if has_base_css {
291 format!("<link rel=\"stylesheet\" href=\"{base_path}docs.css\">")
292 } else {
293 String::new()
294 };
295 let has_theme_css = !custom_css.is_empty();
296 let theme_css_link = if has_theme_css {
297 format!("<link rel=\"stylesheet\" href=\"{base_path}theme.css\">")
298 } else {
299 String::new()
300 };
301 let head_injection = {
302 let mut parts: Vec<String> = Vec::with_capacity(2 + config.head.len());
303 if !base_css_link.is_empty() {
304 parts.push(base_css_link.clone());
305 }
306 if !theme_css_link.is_empty() {
307 parts.push(theme_css_link.clone());
308 }
309 for tag in &config.head {
310 parts.push(tag.to_html());
311 }
312 parts.join("\n ")
313 };
314
315 let render_states = load_render_states(config, config_dir)?;
319 let highlighter = cache.highlighter.take().unwrap_or_default();
320 let pages = process_content_with_states(config, &highlighter, &head_injection, &render_states)?;
321
322 cache.highlighter = Some(highlighter);
324
325 let mut component_sources: Vec<String> = Vec::new();
327 let cwd = std::env::current_dir().unwrap_or_default();
328 let builtin_components = template_dir.parent().map(|p| p.join("components"));
330 if let Some(ref bc) = builtin_components {
331 if bc.exists() {
332 component_sources.push(bc.to_string_lossy().to_string());
333 }
334 }
335 if let Some(ref user_sources) = config.components {
339 for source in user_sources {
340 component_sources.push(resolve_config_component_source(source, &cwd));
341 }
342 }
343 component_sources.push(template_dir.to_string_lossy().to_string());
346
347 let template_html = fs::read_to_string(template_dir.join("index.html"))
348 .map_err(|e| Error::Build(format!("Failed to read template: {e}")))?;
349 let component_script_index = discover_component_scripts(&component_sources)?;
350
351 if out_dir.exists() && !cache.skip_clean {
356 remove_dir_all_retry(out_dir)
357 .map_err(|e| Error::Io(format!("Cannot clean output dir: {e}")))?;
358 }
359 fs::create_dir_all(&site_dir).map_err(|e| Error::Io(format!("Cannot create site dir: {e}")))?;
360
361 if has_base_css {
367 fs::copy(&base_css_src, site_dir.join("docs.css"))
368 .map_err(|e| Error::Io(format!("Cannot copy docs.css: {e}")))?;
369 }
370 if has_theme_css {
371 fs::write(site_dir.join("theme.css"), &custom_css)
372 .map_err(|e| Error::Io(format!("Cannot write theme.css: {e}")))?;
373 }
374
375 let handler = WebUIHandler::with_plugin(|| {
376 Box::new(webui_handler::plugin::webui::WebUIHydrationPlugin::new())
377 });
378
379 for page in &pages {
382 let page_dir = site_dir.join(page.path.strip_prefix(base_path).unwrap_or(&page.path));
383 fs::create_dir_all(&page_dir)
384 .map_err(|e| Error::Io(format!("Cannot create dir {}: {e}", page_dir.display())))?;
385 }
386
387 let mut explicit_page_scripts: HashMap<String, Vec<ScriptSource>> = HashMap::new();
394 let mut page_contents_with_scripts: HashMap<String, String> = HashMap::new();
395
396 for page in &pages {
397 let content = page.state["page"]["content"].as_str().unwrap_or("");
398 if content.contains("<script") && content.contains("bundle") {
399 let (modified, scripts) = extract_bundle_scripts(content);
400 if !scripts.is_empty() {
401 page_contents_with_scripts.insert(page.path.clone(), modified);
402 explicit_page_scripts
403 .entry(page.path.clone())
404 .or_default()
405 .extend(scripts);
406 }
407 }
408 }
409
410 for (link, custom_page) in &config.custom_pages {
412 if let Some(script_path) = custom_page.script_file() {
413 let normalized = if link.ends_with('/') {
416 link.clone()
417 } else {
418 format!("{link}/")
419 };
420 let page_path = format!("{}{}", base_path, &normalized[1..]);
421 explicit_page_scripts
422 .entry(page_path)
423 .or_default()
424 .push(ScriptSource::File(script_path.to_string()));
425 }
426 }
427
428 let not_found_content = format!(
429 "<h1>404 — Page Not Found</h1>\
430 <p>The page you're looking for doesn't exist or has been moved.</p>\
431 <p><a href=\"{base_path}\">← Back to Home</a></p>"
432 );
433
434 let template_script_path = template_dir.join("index.ts");
435 let template_script = if template_script_path.exists() {
436 Some(
437 template_script_path
438 .canonicalize()
439 .unwrap_or(template_script_path),
440 )
441 } else {
442 None
443 };
444 let root_component_scripts =
445 collect_component_scripts_for_html(&[template_html.as_str()], &component_script_index)?;
446 let root_bundle = if template_script.is_some() || !root_component_scripts.is_empty() {
447 Some(RootBundleEntry {
448 script_path: template_script,
449 component_scripts: root_component_scripts,
450 })
451 } else {
452 None
453 };
454
455 let mut page_bundles: Vec<PageBundleEntry> = Vec::new();
459 let mut page_bundle_ids: HashMap<String, usize> = HashMap::with_capacity(pages.len());
460 let mut page_bundle_signatures: HashMap<String, usize> = HashMap::new();
461 for page in &pages {
462 let content = page_contents_with_scripts
463 .get(&page.path)
464 .map(String::as_str)
465 .unwrap_or_else(|| page.state["page"]["content"].as_str().unwrap_or(""));
466 let mut component_scripts =
467 collect_component_scripts_for_html(&[content], &component_script_index)?;
468 if let Some(root) = &root_bundle {
469 component_scripts.retain(|path| !root.component_scripts.contains(path));
470 }
471 let explicit_scripts = explicit_page_scripts.remove(&page.path).unwrap_or_default();
472 if component_scripts.is_empty() && explicit_scripts.is_empty() {
473 continue;
474 }
475
476 let signature = page_bundle_signature(&component_scripts, &explicit_scripts, config_dir);
477 if let Some(&id) = page_bundle_signatures.get(&signature) {
478 page_bundle_ids.insert(page.path.clone(), id);
479 continue;
480 }
481
482 let id = page_bundles.len();
483 page_bundle_signatures.insert(signature, id);
484 page_bundle_ids.insert(page.path.clone(), id);
485 page_bundles.push(PageBundleEntry {
486 id,
487 page_path: page.path.clone(),
488 component_scripts,
489 explicit_scripts,
490 });
491 }
492
493 let not_found_component_scripts =
494 collect_component_scripts_for_html(&[not_found_content.as_str()], &component_script_index)?;
495 let not_found_bundle_id = if not_found_component_scripts.is_empty() {
496 None
497 } else {
498 let signature = page_bundle_signature(¬_found_component_scripts, &[], config_dir);
499 if let Some(&id) = page_bundle_signatures.get(&signature) {
500 Some(id)
501 } else {
502 let id = page_bundles.len();
503 page_bundle_signatures.insert(signature, id);
504 page_bundles.push(PageBundleEntry {
505 id,
506 page_path: format!("{base_path}404/"),
507 component_scripts: not_found_component_scripts,
508 explicit_scripts: Vec::new(),
509 });
510 Some(id)
511 }
512 };
513
514 let root_bundle_clone = root_bundle.clone();
515
516 let site_dir_clone = site_dir.clone();
521 let page_bundles_clone = page_bundles.clone();
522 let bundler_config = config.bundler.clone();
523 let dev_mode = cache.dev_mode;
524 let config_dir_owned = config_dir.to_path_buf();
525 let content_dir_owned = PathBuf::from(&config.content_dir);
526 let node_modules_owned = if root_bundle_clone.is_some() || !page_bundles_clone.is_empty() {
527 Some(resolve_node_modules(config_dir)?)
528 } else {
529 None
530 };
531 let bundle_thread = BundleThread::spawn(move || -> Result<BundleResult> {
532 bundle_assets(&BundleOptions {
533 site_dir: &site_dir_clone,
534 node_modules: node_modules_owned.as_deref(),
535 root_bundle: root_bundle_clone.as_ref(),
536 page_bundles: &page_bundles_clone,
537 bundler_config: bundler_config.as_ref(),
538 dev_mode,
539 config_dir: &config_dir_owned,
540 content_dir: &content_dir_owned,
541 })
542 });
543
544 let total_bytes = std::sync::atomic::AtomicUsize::new(0);
554 let component_css: std::sync::Mutex<HashMap<String, String>> =
555 std::sync::Mutex::new(HashMap::new());
556
557 pages.par_iter().try_for_each(|page| -> Result<()> {
558 let page_dir = site_dir.join(page.path.strip_prefix(base_path).unwrap_or(&page.path));
559 let target = page_dir.join("index.html");
560
561 let content = page_contents_with_scripts
563 .get(&page.path)
564 .map(|s| s.as_str())
565 .unwrap_or_else(|| page.state["page"]["content"].as_str().unwrap_or(""));
566
567 let (protected, pre_blocks) = protect_pre_blocks(content);
569
570 let page_html = template_html.replace("{{{page.content}}}", &protected);
572
573 let nonce = REBUILD_NONCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
582 let page_tmp = std::env::temp_dir().join(format!(
583 "webui-press-page-{}-{:x}-{nonce:x}",
584 std::process::id(),
585 fxhash(&page.path),
586 ));
587 if page_tmp.exists() {
588 fs::remove_dir_all(&page_tmp).ok();
589 }
590 fs::create_dir_all(&page_tmp)
591 .map_err(|e| Error::Io(format!("Cannot create page temp: {e}")))?;
592 fs::write(page_tmp.join("index.html"), &page_html)
593 .map_err(|e| Error::Io(format!("Cannot write page temp: {e}")))?;
594
595 let build_result = webui::build(BuildOptions {
596 app_dir: page_tmp.clone(),
597 entry: "index.html".to_string(),
598 plugin: Some(webui::Plugin::WebUI),
599 components: component_sources.clone(),
600 theme: token_file.clone(),
601 ..BuildOptions::default()
602 })
603 .map_err(|e| Error::Build(format!("{}: {e}", page.path)))?;
604
605 total_bytes.fetch_add(
606 build_result.protocol_bytes.len(),
607 std::sync::atomic::Ordering::Relaxed,
608 );
609
610 if !build_result.css_files.is_empty() {
615 let mut css_map = component_css
616 .lock()
617 .unwrap_or_else(std::sync::PoisonError::into_inner);
618 for (name, content) in build_result.css_files {
619 css_map.entry(name).or_insert(content);
620 }
621 }
622
623 let mut themed_state;
624 let render_state = if let Some(token_file) = token_file.as_ref() {
625 themed_state = page.state.clone();
626 inject_theme_tokens(&mut themed_state, token_file, &build_result.protocol.tokens)?;
627 &themed_state
628 } else {
629 &page.state
630 };
631
632 let mut writer = StringWriter::with_capacity(8192);
633 handler
634 .render(
635 &build_result.protocol,
636 render_state,
637 &RenderOptions::new("index.html", &page.path),
638 &mut writer,
639 )
640 .map_err(|e| Error::Render(format!("{}: {e}", page.path)))?;
641
642 let html = restore_pre_blocks(&writer.buf, &pre_blocks);
644
645 fs::write(&target, &html)
647 .map_err(|e| Error::Io(format!("Cannot write {}: {e}", page.path)))?;
648
649 fs::remove_dir_all(&page_tmp).ok();
650 Ok(())
651 })?;
652
653 print_success(cache, &format!("Rendered {} pages", pages.len()));
654
655 let search_path = site_dir.join("search-index.json");
658 let search_entries: Vec<Value> = pages
659 .iter()
660 .filter(|p| !p.is_home)
661 .map(|p| {
662 let html = p.state["page"]["content"].as_str().unwrap_or("");
663 let title = p.state["page"]["title"].as_str().unwrap_or("");
664 build_search_entry(title, &p.path, html)
665 })
666 .collect();
667 fs::write(
668 &search_path,
669 serde_json::to_string(&search_entries)
670 .map_err(|e| Error::Build(format!("JSON error: {e}")))?,
671 )
672 .map_err(|e| Error::Io(e.to_string()))?;
673 print_success(
674 cache,
675 &format!("Indexed {} pages for search", search_entries.len()),
676 );
677
678 let public_dir = Path::new(&config.public_dir);
680 if public_dir.exists() {
681 copy_dir(public_dir, &site_dir)?;
682 }
683
684 let nav_val = pages
686 .first()
687 .map(|p| p.state["navigation"].clone())
688 .unwrap_or_default();
689
690 let footer_val = config
691 .footer
692 .as_ref()
693 .map(|f| json_obj([("html", Value::String(f.html.clone()))]))
694 .unwrap_or(Value::Null);
695
696 let mut not_found_state = build_not_found_state(NotFoundStateInput {
697 config,
698 base_path,
699 nav: nav_val,
700 footer: footer_val,
701 content: ¬_found_content,
702 head_injection: &head_injection,
703 global_state: render_states.global(),
704 });
705
706 let not_found_html = template_html.replace("{{{page.content}}}", ¬_found_content);
707 let nf_tmp = std::env::temp_dir().join(format!(
708 "webui-press-404-{}-{:x}",
709 std::process::id(),
710 REBUILD_NONCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
711 ));
712 if nf_tmp.exists() {
713 fs::remove_dir_all(&nf_tmp).ok();
714 }
715 fs::create_dir_all(&nf_tmp).map_err(|e| Error::Io(e.to_string()))?;
716 fs::write(nf_tmp.join("index.html"), ¬_found_html).map_err(|e| Error::Io(e.to_string()))?;
717
718 let nf_build = webui::build(BuildOptions {
719 app_dir: nf_tmp.clone(),
720 entry: "index.html".to_string(),
721 plugin: Some(webui::Plugin::WebUI),
722 components: component_sources.clone(),
723 theme: token_file.clone(),
724 ..BuildOptions::default()
725 })
726 .map_err(|e| Error::Build(format!("404 build failed: {e}")))?;
727
728 if let Some(token_file) = token_file.as_ref() {
729 inject_theme_tokens(&mut not_found_state, token_file, &nf_build.protocol.tokens)?;
730 }
731
732 {
737 let mut css_map = component_css
738 .lock()
739 .unwrap_or_else(std::sync::PoisonError::into_inner);
740 for (name, content) in &nf_build.css_files {
741 css_map
742 .entry(name.clone())
743 .or_insert_with(|| content.clone());
744 }
745 }
746
747 let mut writer_404 = StringWriter::with_capacity(4096);
748 handler
749 .render(
750 &nf_build.protocol,
751 ¬_found_state,
752 &RenderOptions::new("index.html", &format!("{base_path}404/")),
753 &mut writer_404,
754 )
755 .map_err(|e| Error::Render(format!("404: {e}")))?;
756
757 fs::write(site_dir.join("404.html"), writer_404.buf).map_err(|e| Error::Io(e.to_string()))?;
758 fs::remove_dir_all(&nf_tmp).ok();
759 print_success(cache, "Generated 404 page");
760
761 let css_map_local = component_css
765 .into_inner()
766 .unwrap_or_else(std::sync::PoisonError::into_inner);
767 let mut css_names: Vec<&String> = css_map_local.keys().collect();
768 css_names.sort();
769 let css_count = css_names.len();
770 for name in css_names {
771 fs::write(site_dir.join(name), &css_map_local[name])
772 .map_err(|e| Error::Io(format!("Cannot write {name}: {e}")))?;
773 }
774 if css_count > 0 {
775 print_success(cache, &format!("Wrote {css_count} component stylesheets"));
776 }
777
778 let bundle_result = bundle_thread.join()?;
780 print_success(
781 cache,
782 &format!(
783 "Bundled {} component script{} into {} root script{} and {} page script group{}",
784 bundle_result.component_count,
785 if bundle_result.component_count == 1 {
786 ""
787 } else {
788 "s"
789 },
790 usize::from(bundle_result.root_script.is_some()),
791 if bundle_result.root_script.is_some() {
792 ""
793 } else {
794 "s"
795 },
796 bundle_result.page_entry_count,
797 if bundle_result.page_entry_count == 1 {
798 ""
799 } else {
800 "s"
801 }
802 ),
803 );
804
805 if bundle_result.root_script.is_some() || !bundle_result.script_map.is_empty() {
807 let mut linked_count = 0usize;
808 for page in &pages {
809 let page_rel_paths = if let Some(bundle_id) = page_bundle_ids.get(&page.path) {
810 let rel_paths = bundle_result.script_map.get(bundle_id).ok_or_else(|| {
811 Error::Build(format!(
812 "Missing bundled script for page {} entry {}",
813 page.path, bundle_id
814 ))
815 })?;
816 if rel_paths.is_empty() {
817 None
818 } else {
819 Some(rel_paths)
820 }
821 } else {
822 None
823 };
824 if bundle_result.root_script.is_none() && page_rel_paths.is_none() {
825 continue;
826 }
827 let page_dir = site_dir.join(page.path.strip_prefix(base_path).unwrap_or(&page.path));
828 let target = page_dir.join("index.html");
829 let mut html = fs::read_to_string(&target)
830 .map_err(|e| Error::Io(format!("Cannot read {}: {e}", target.display())))?;
831
832 if let Some(rel_path) = bundle_result.root_script.as_ref() {
833 let tag = module_script_tag(base_path, rel_path);
834 inject_script_tag(&mut html, &tag);
835 linked_count += 1;
836 }
837 if let Some(rel_paths) = page_rel_paths {
838 linked_count += inject_module_script_tags(&mut html, base_path, rel_paths);
839 }
840
841 fs::write(&target, &html)
842 .map_err(|e| Error::Io(format!("Cannot rewrite {}: {e}", page.path)))?;
843 }
844 if let Some(bundle_id) = not_found_bundle_id {
845 let rel_paths = bundle_result.script_map.get(&bundle_id).ok_or_else(|| {
846 Error::Build(format!(
847 "Missing bundled script for 404 page entry {bundle_id}"
848 ))
849 })?;
850 let target = site_dir.join("404.html");
851 let mut html = fs::read_to_string(&target)
852 .map_err(|e| Error::Io(format!("Cannot read {}: {e}", target.display())))?;
853 if let Some(rel_path) = bundle_result.root_script.as_ref() {
854 let tag = module_script_tag(base_path, rel_path);
855 inject_script_tag(&mut html, &tag);
856 linked_count += 1;
857 }
858 linked_count += inject_module_script_tags(&mut html, base_path, rel_paths);
859 fs::write(&target, &html)
860 .map_err(|e| Error::Io(format!("Cannot rewrite 404.html: {e}")))?;
861 } else if let Some(rel_path) = bundle_result.root_script.as_ref() {
862 let target = site_dir.join("404.html");
863 let mut html = fs::read_to_string(&target)
864 .map_err(|e| Error::Io(format!("Cannot read {}: {e}", target.display())))?;
865 let tag = module_script_tag(base_path, rel_path);
866 inject_script_tag(&mut html, &tag);
867 fs::write(&target, &html)
868 .map_err(|e| Error::Io(format!("Cannot rewrite 404.html: {e}")))?;
869 linked_count += 1;
870 }
871 if linked_count > 0 {
872 print_success(
873 cache,
874 &format!(
875 "Linked {} script tag{}",
876 linked_count,
877 if linked_count == 1 { "" } else { "s" }
878 ),
879 );
880 }
881 }
882
883 let elapsed = start.elapsed();
884 let total_bytes = total_bytes.load(std::sync::atomic::Ordering::Relaxed);
885 print_finish(cache, &format!("Built in {:.1}s", elapsed.as_secs_f64()));
886
887 Ok(BuildStats {
888 pages: pages.len(),
889 protocol_bytes: total_bytes,
890 })
891}
892
893fn remove_dir_all_retry(path: &Path) -> std::io::Result<()> {
912 const MAX_ATTEMPTS: u32 = 5;
913 let mut delay = std::time::Duration::from_millis(20);
914 for attempt in 1..=MAX_ATTEMPTS {
915 match fs::remove_dir_all(path) {
916 Ok(()) => return Ok(()),
917 Err(e) if attempt < MAX_ATTEMPTS && is_dir_not_empty(&e) => {
918 std::thread::sleep(delay);
919 delay = delay.saturating_mul(2);
920 }
921 Err(e) => return Err(e),
922 }
923 }
924 Ok(())
927}
928
929fn is_dir_not_empty(e: &std::io::Error) -> bool {
933 match e.raw_os_error() {
934 Some(66) => true, Some(39) => true, Some(145) => true, _ => false,
938 }
939}
940
941fn copy_dir(src: &Path, dest: &Path) -> Result<()> {
943 if !src.exists() {
944 return Ok(());
945 }
946 fs::create_dir_all(dest).map_err(|e| Error::Io(e.to_string()))?;
947 let mut stack: Vec<(std::path::PathBuf, std::path::PathBuf)> =
948 vec![(src.to_path_buf(), dest.to_path_buf())];
949 while let Some((s, d)) = stack.pop() {
950 for entry in fs::read_dir(&s).map_err(|e| Error::Io(e.to_string()))? {
951 let entry = entry.map_err(|e| Error::Io(e.to_string()))?;
952 let dest_path = d.join(entry.file_name());
953 let ft = entry.file_type().map_err(|e| Error::Io(e.to_string()))?;
954 if ft.is_dir() {
955 fs::create_dir_all(&dest_path).map_err(|e| Error::Io(e.to_string()))?;
956 stack.push((entry.path(), dest_path));
957 } else {
958 fs::copy(entry.path(), &dest_path).map_err(|e| Error::Io(e.to_string()))?;
959 }
960 }
961 }
962 Ok(())
963}
964
965pub(crate) fn truncate_utf8(s: &str, max: usize) -> &str {
967 if s.len() <= max {
968 return s;
969 }
970 let mut end = max;
971 while end > 0 && !s.is_char_boundary(end) {
972 end -= 1;
973 }
974 &s[..end]
975}
976
977#[derive(Default)]
978struct SearchText {
979 content: String,
980 headings: Vec<SearchHeading>,
981}
982
983struct SearchHeading {
988 text: String,
989 anchor: String,
990 level: u8,
991}
992
993struct CurrentHeading {
995 text: String,
996 anchor: String,
997 level: u8,
998}
999
1000struct HtmlTag<'a> {
1001 name: &'a str,
1002 is_end: bool,
1003}
1004
1005pub(crate) fn build_search_entry(title: &str, path: &str, html: &str) -> Value {
1009 let search_text = extract_search_text(html);
1010 let content = normalize_search_text(&search_text.content);
1011 let truncated = truncate_utf8(&content, 500);
1012 let headings: Vec<Value> = search_text
1013 .headings
1014 .into_iter()
1015 .map(|heading| {
1016 json_obj([
1017 ("text", Value::String(heading.text)),
1018 ("anchor", Value::String(heading.anchor)),
1019 (
1020 "level",
1021 Value::Number(serde_json::Number::from(u64::from(heading.level))),
1022 ),
1023 ])
1024 })
1025 .collect();
1026 json_obj([
1027 ("title", Value::String(title.to_string())),
1028 ("path", Value::String(path.to_string())),
1029 ("content", Value::String(truncated.to_string())),
1030 ("headings", Value::Array(headings)),
1031 ])
1032}
1033
1034fn extract_search_text(html: &str) -> SearchText {
1035 let mut text = SearchText {
1036 content: String::with_capacity(html.len() / 2),
1037 headings: Vec::with_capacity(8),
1038 };
1039 let mut current_heading: Option<CurrentHeading> = None;
1040 let mut skip_header_anchor = 0usize;
1041 let mut cursor = 0;
1042
1043 while cursor < html.len() {
1044 let remaining = &html[cursor..];
1045 let Some(tag_offset) = remaining.find('<') else {
1046 push_search_text(
1047 &mut text,
1048 current_heading.as_mut(),
1049 skip_header_anchor,
1050 remaining,
1051 );
1052 break;
1053 };
1054 let text_end = cursor + tag_offset;
1055 push_search_text(
1056 &mut text,
1057 current_heading.as_mut(),
1058 skip_header_anchor,
1059 &html[cursor..text_end],
1060 );
1061 let tag_start = text_end + 1;
1062 let Some(tag_len) = html[tag_start..].find('>') else {
1063 push_search_text(
1064 &mut text,
1065 current_heading.as_mut(),
1066 skip_header_anchor,
1067 &html[text_end..],
1068 );
1069 break;
1070 };
1071 let tag_end = tag_start + tag_len;
1072 handle_search_tag(
1073 &html[tag_start..tag_end],
1074 &mut text,
1075 &mut current_heading,
1076 &mut skip_header_anchor,
1077 );
1078 cursor = tag_end + 1;
1079 }
1080
1081 text
1082}
1083
1084fn handle_search_tag(
1085 raw_tag: &str,
1086 text: &mut SearchText,
1087 current_heading: &mut Option<CurrentHeading>,
1088 skip_header_anchor: &mut usize,
1089) {
1090 if let Some(tag) = parse_html_tag(raw_tag) {
1091 if tag.is_end {
1092 handle_end_tag(tag.name, text, current_heading, skip_header_anchor);
1093 } else {
1094 handle_start_tag(tag.name, raw_tag, current_heading, skip_header_anchor);
1095 }
1096 }
1097 push_search_space(text, current_heading.as_mut(), *skip_header_anchor);
1098}
1099
1100fn handle_start_tag(
1101 name: &str,
1102 raw_tag: &str,
1103 current_heading: &mut Option<CurrentHeading>,
1104 skip_header_anchor: &mut usize,
1105) {
1106 if let Some(level) = heading_level(name) {
1107 *current_heading = Some(CurrentHeading {
1108 text: String::with_capacity(64),
1109 anchor: attr_value(raw_tag, "id").unwrap_or("").to_string(),
1110 level,
1111 });
1112 } else if name.eq_ignore_ascii_case("a") && raw_tag.contains("header-anchor") {
1113 *skip_header_anchor += 1;
1114 }
1115}
1116
1117fn handle_end_tag(
1118 name: &str,
1119 text: &mut SearchText,
1120 current_heading: &mut Option<CurrentHeading>,
1121 skip_header_anchor: &mut usize,
1122) {
1123 if name.eq_ignore_ascii_case("a") && *skip_header_anchor > 0 {
1124 *skip_header_anchor -= 1;
1125 }
1126 if heading_level(name).is_some() {
1127 if let Some(raw_heading) = current_heading.take() {
1128 let heading_text = normalize_search_text(&raw_heading.text);
1129 if !heading_text.is_empty() {
1130 text.headings.push(SearchHeading {
1131 text: heading_text,
1132 anchor: raw_heading.anchor,
1133 level: raw_heading.level,
1134 });
1135 }
1136 }
1137 }
1138}
1139
1140fn parse_html_tag(raw: &str) -> Option<HtmlTag<'_>> {
1141 let mut tag = raw.trim_start();
1142 if tag.starts_with('!') || tag.starts_with('?') {
1143 return None;
1144 }
1145 let is_end = tag.starts_with('/');
1146 if is_end {
1147 tag = tag[1..].trim_start();
1148 }
1149
1150 let mut name_end = 0;
1151 for (idx, byte) in tag.bytes().enumerate() {
1152 if byte.is_ascii_alphanumeric() || byte == b'-' {
1153 name_end = idx + 1;
1154 } else {
1155 break;
1156 }
1157 }
1158 if name_end == 0 {
1159 return None;
1160 }
1161 Some(HtmlTag {
1162 name: &tag[..name_end],
1163 is_end,
1164 })
1165}
1166
1167fn heading_level(name: &str) -> Option<u8> {
1172 let bytes = name.as_bytes();
1173 if bytes.len() == 2 && bytes[0].eq_ignore_ascii_case(&b'h') && (b'1'..=b'6').contains(&bytes[1])
1174 {
1175 Some(bytes[1] - b'0')
1176 } else {
1177 None
1178 }
1179}
1180
1181fn attr_value<'a>(raw_tag: &'a str, attr_name: &str) -> Option<&'a str> {
1188 let bytes = raw_tag.as_bytes();
1189 let mut cursor = 0;
1190
1191 while cursor < bytes.len() {
1192 while bytes.get(cursor).is_some_and(u8::is_ascii_whitespace) {
1193 cursor += 1;
1194 }
1195
1196 let name_start = cursor;
1197 while bytes.get(cursor).is_some_and(|b| is_attr_name_byte(*b)) {
1198 cursor += 1;
1199 }
1200 if name_start == cursor {
1201 cursor += 1;
1202 continue;
1203 }
1204 let name = &raw_tag[name_start..cursor];
1205
1206 while bytes.get(cursor).is_some_and(u8::is_ascii_whitespace) {
1207 cursor += 1;
1208 }
1209 if bytes.get(cursor) != Some(&b'=') {
1210 continue;
1211 }
1212 cursor += 1;
1213 while bytes.get(cursor).is_some_and(u8::is_ascii_whitespace) {
1214 cursor += 1;
1215 }
1216
1217 let (value_start, value_end) = attr_value_range(raw_tag, cursor);
1218 if name.eq_ignore_ascii_case(attr_name) {
1219 return Some(&raw_tag[value_start..value_end]);
1220 }
1221 cursor = value_end.saturating_add(1);
1222 }
1223
1224 None
1225}
1226
1227fn is_attr_name_byte(byte: u8) -> bool {
1228 byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b':')
1229}
1230
1231fn attr_value_range(raw_tag: &str, cursor: usize) -> (usize, usize) {
1232 let bytes = raw_tag.as_bytes();
1233 match bytes.get(cursor).copied() {
1234 Some(b'"' | b'\'') => {
1235 let quote = bytes[cursor];
1236 let value_start = cursor + 1;
1237 let value_end = raw_tag[value_start..]
1238 .find(char::from(quote))
1239 .map_or(raw_tag.len(), |offset| value_start + offset);
1240 (value_start, value_end)
1241 }
1242 Some(_) => {
1243 let value_start = cursor;
1244 let value_end = raw_tag[value_start..]
1245 .find(char::is_whitespace)
1246 .map_or(raw_tag.len(), |offset| value_start + offset);
1247 (value_start, value_end)
1248 }
1249 None => (raw_tag.len(), raw_tag.len()),
1250 }
1251}
1252
1253fn push_search_text(
1254 text: &mut SearchText,
1255 current_heading: Option<&mut CurrentHeading>,
1256 skip_header_anchor: usize,
1257 value: &str,
1258) {
1259 if skip_header_anchor > 0 {
1260 return;
1261 }
1262 text.content.push_str(value);
1263 if let Some(heading) = current_heading {
1264 heading.text.push_str(value);
1265 }
1266}
1267
1268fn push_search_space(
1269 text: &mut SearchText,
1270 current_heading: Option<&mut CurrentHeading>,
1271 skip_header_anchor: usize,
1272) {
1273 if skip_header_anchor > 0 {
1274 return;
1275 }
1276 text.content.push(' ');
1277 if let Some(heading) = current_heading {
1278 heading.text.push(' ');
1279 }
1280}
1281
1282fn normalize_search_text(raw: &str) -> String {
1283 let decoded = html_escape::decode_html_entities(raw);
1284 let mut normalized = String::with_capacity(decoded.len());
1285 let mut first = true;
1286 for token in decoded.split_whitespace() {
1287 if !first {
1288 normalized.push(' ');
1289 }
1290 normalized.push_str(token);
1291 first = false;
1292 }
1293 normalized
1294}
1295
1296const PRE_BLOCK_MARKER_PREFIX: &str = "<span data-webui-press-pre-block=\"";
1297const PRE_BLOCK_MARKER_SUFFIX: &str = "\"></span>";
1298
1299fn protect_pre_blocks(content: &str) -> (String, Vec<String>) {
1304 use std::fmt::Write as _;
1305 let mut blocks: Vec<String> = Vec::new();
1306 let mut out = String::with_capacity(content.len());
1307 let mut cursor = 0;
1308 while let Some(rel_start) = find_pre_open(&content[cursor..]) {
1309 let start = cursor + rel_start;
1310 if let Some(rel_end) = content[start..].find("</pre>") {
1311 let end = start + rel_end + "</pre>".len();
1312 out.push_str(&content[cursor..start]);
1313 out.push_str(PRE_BLOCK_MARKER_PREFIX);
1314 let _ = write!(&mut out, "{}", blocks.len());
1316 out.push_str(PRE_BLOCK_MARKER_SUFFIX);
1317 blocks.push(content[start..end].to_string());
1318 cursor = end;
1319 } else {
1320 break;
1321 }
1322 }
1323 out.push_str(&content[cursor..]);
1324 (out, blocks)
1325}
1326
1327fn find_pre_open(s: &str) -> Option<usize> {
1330 let bytes = s.as_bytes();
1331 let mut i = 0;
1332 while let Some(rel) = s[i..].find("<pre") {
1333 let pos = i + rel;
1334 let after = pos + 4;
1335 match bytes.get(after) {
1336 Some(b'>' | b' ' | b'\t' | b'\n' | b'\r') => return Some(pos),
1337 _ => i = after,
1338 }
1339 }
1340 None
1341}
1342
1343fn restore_pre_blocks(html: &str, blocks: &[String]) -> String {
1346 if blocks.is_empty() {
1347 return html.to_string();
1348 }
1349 let extra: usize = blocks.iter().map(|b| b.len()).sum();
1350 let mut out = String::with_capacity(html.len() + extra);
1351 let mut cursor = 0;
1352 while let Some(rel) = html[cursor..].find(PRE_BLOCK_MARKER_PREFIX) {
1353 let p = cursor + rel;
1354 out.push_str(&html[cursor..p]);
1355 let after = p + PRE_BLOCK_MARKER_PREFIX.len();
1356 if let Some(end_rel) = html[after..].find(PRE_BLOCK_MARKER_SUFFIX) {
1357 let num_str = &html[after..after + end_rel];
1358 if let Ok(idx) = num_str.parse::<usize>() {
1359 if let Some(block) = blocks.get(idx) {
1360 out.push_str(block);
1361 cursor = after + end_rel + PRE_BLOCK_MARKER_SUFFIX.len();
1362 continue;
1363 }
1364 }
1365 out.push_str(&html[p..after + end_rel + PRE_BLOCK_MARKER_SUFFIX.len()]);
1367 cursor = after + end_rel + PRE_BLOCK_MARKER_SUFFIX.len();
1368 } else {
1369 out.push_str(&html[p..]);
1370 return out;
1371 }
1372 }
1373 out.push_str(&html[cursor..]);
1374 out
1375}
1376
1377fn fxhash(s: &str) -> u64 {
1379 fxhash_bytes(s.as_bytes())
1380}
1381
1382fn fxhash_bytes(bytes: &[u8]) -> u64 {
1383 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
1384 for &b in bytes {
1385 hash ^= u64::from(b);
1386 hash = hash.wrapping_mul(0x100_0000_01b3);
1387 }
1388 hash
1389}
1390
1391#[cfg(test)]
1392mod tests {
1393 use super::*;
1394
1395 type TestResult<T = ()> = std::result::Result<T, Box<dyn std::error::Error>>;
1396
1397 fn test_obj<const N: usize>(entries: [(&str, Value); N]) -> Value {
1398 let mut map = Map::with_capacity(N);
1399 for (key, value) in entries {
1400 map.insert(key.to_string(), value);
1401 }
1402 Value::Object(map)
1403 }
1404
1405 fn string_value(value: &str) -> Value {
1406 Value::String(value.to_string())
1407 }
1408
1409 fn docs_config() -> TestResult<DocsConfig> {
1410 let config = serde_json::from_str(
1411 r#"{
1412 "site": { "title": "Docs" },
1413 "basePath": "/",
1414 "contentDir": ".",
1415 "nav": [],
1416 "sidebar": []
1417 }"#,
1418 )?;
1419 Ok(config)
1420 }
1421
1422 #[test]
1423 fn not_found_state_includes_global_state() -> TestResult {
1424 let config = docs_config()?;
1425 let global = test_obj([("release", test_obj([("version", string_value("v1.2.3"))]))]);
1426
1427 let state = build_not_found_state(NotFoundStateInput {
1428 config: &config,
1429 base_path: "/",
1430 nav: Value::Array(vec![]),
1431 footer: Value::Null,
1432 content: "<h1>404</h1>",
1433 head_injection: "<meta name=\"docs\">",
1434 global_state: Some(&global),
1435 });
1436
1437 assert_eq!(state["release"]["version"], "v1.2.3");
1438 assert_eq!(state["page"]["title"], "Page Not Found");
1439 assert_eq!(state["pageData"], Value::Null);
1440 assert_eq!(state["headTags"], "<meta name=\"docs\">");
1441 Ok(())
1442 }
1443
1444 #[test]
1447 fn truncate_utf8_short_string_unchanged() {
1448 assert_eq!(truncate_utf8("hello", 100), "hello");
1449 }
1450
1451 #[test]
1452 fn truncate_utf8_ascii_at_exact_boundary() {
1453 assert_eq!(truncate_utf8("hello world", 5), "hello");
1454 }
1455
1456 #[test]
1457 fn truncate_utf8_steps_back_off_multibyte_boundary() {
1458 let s = "é-suffix";
1460 let out = truncate_utf8(s, 1);
1461 assert!(s.is_char_boundary(out.len()), "out.len()={}", out.len());
1462 assert_eq!(out, "");
1463 }
1464
1465 #[test]
1466 fn truncate_utf8_keeps_multibyte_when_room_allows() {
1467 let s = "café";
1468 let out = truncate_utf8(s, 5); assert_eq!(out, "café");
1470 }
1471
1472 #[test]
1475 fn protect_pre_blocks_with_attrs() {
1476 let input = r#"<p>before</p><pre class="hljs">code</pre><p>after</p>"#;
1477 let (out, blocks) = protect_pre_blocks(input);
1478 assert_eq!(blocks.len(), 1);
1479 assert!(out.contains(r#"<span data-webui-press-pre-block="0"></span>"#));
1480 assert!(!out.contains("<pre"));
1481 assert_eq!(blocks[0], r#"<pre class="hljs">code</pre>"#);
1482 }
1483
1484 #[test]
1485 fn protect_pre_blocks_bare_open_tag() {
1486 let input = "<pre>code</pre>";
1488 let (out, blocks) = protect_pre_blocks(input);
1489 assert_eq!(blocks.len(), 1, "got out={out:?} blocks={blocks:?}");
1490 assert_eq!(blocks[0], "<pre>code</pre>");
1491 }
1492
1493 #[test]
1494 fn protect_pre_blocks_does_not_match_presentation_tag() {
1495 let input = "<presentation>x</presentation>";
1497 let (_out, blocks) = protect_pre_blocks(input);
1498 assert_eq!(blocks.len(), 0);
1499 }
1500
1501 #[test]
1502 fn protect_then_restore_round_trip() {
1503 let input = "before <pre>a</pre> mid <pre class=\"x\">b</pre> end";
1504 let (protected, blocks) = protect_pre_blocks(input);
1505 let restored = restore_pre_blocks(&protected, &blocks);
1506 assert_eq!(restored, input);
1507 }
1508
1509 #[test]
1510 fn restore_pre_blocks_no_blocks_returns_input() {
1511 assert_eq!(restore_pre_blocks("plain html", &[]), "plain html");
1512 }
1513
1514 #[test]
1515 fn pre_block_placeholder_survives_webui_component_slot_render() -> TestResult {
1516 let input = "<code-block><pre><code>let x = 1;\n</code></pre></code-block>";
1517 let (protected, blocks) = protect_pre_blocks(input);
1518 let page_html = format!("<!DOCTYPE html><html><body>{protected}</body></html>");
1519
1520 let tmp = std::env::temp_dir().join(format!(
1521 "webui-press-slot-test-{}-{:x}",
1522 std::process::id(),
1523 fxhash(input)
1524 ));
1525 if tmp.exists() {
1526 fs::remove_dir_all(&tmp)?;
1527 }
1528 fs::create_dir_all(&tmp)?;
1529 fs::write(tmp.join("index.html"), page_html)?;
1530
1531 let components = vec![Path::new(env!("CARGO_MANIFEST_DIR"))
1532 .join("components")
1533 .to_string_lossy()
1534 .into_owned()];
1535 let build_result = webui::build(BuildOptions {
1536 app_dir: tmp.clone(),
1537 entry: "index.html".to_string(),
1538 plugin: Some(webui::Plugin::WebUI),
1539 components,
1540 ..BuildOptions::default()
1541 })?;
1542
1543 let mut writer = StringWriter::with_capacity(4096);
1544 let handler = WebUIHandler::with_plugin(|| {
1545 Box::new(webui_handler::plugin::webui::WebUIHydrationPlugin::new())
1546 });
1547 handler.render(
1548 &build_result.protocol,
1549 &Value::Object(Map::new()),
1550 &RenderOptions::new("index.html", "/"),
1551 &mut writer,
1552 )?;
1553
1554 fs::remove_dir_all(&tmp)?;
1555
1556 let html = restore_pre_blocks(&writer.buf, &blocks);
1557 assert!(
1558 html.contains("<pre><code>let x = 1;\n</code></pre>"),
1559 "slotted pre block should be restored after WebUI render: {html}"
1560 );
1561 assert!(
1562 !html.contains(PRE_BLOCK_MARKER_PREFIX),
1563 "placeholder marker must not survive output: {html}"
1564 );
1565
1566 Ok(())
1567 }
1568
1569 #[test]
1570 fn build_search_entry_decodes_code_text_and_captures_headings() {
1571 let entry = build_search_entry(
1572 "`<for>` Loop Directive",
1573 "/guide/concepts/directives/for/",
1574 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>"##,
1575 );
1576
1577 assert_eq!(entry["title"].as_str(), Some("`<for>` Loop Directive"));
1578 assert_eq!(
1579 entry["content"].as_str(),
1580 Some("<for> Loop Directive Use <for> loops. Syntax Nested Search")
1581 );
1582 let Some(headings) = entry["headings"].as_array() else {
1583 panic!("headings should be an array: {entry:?}");
1584 };
1585 assert_eq!(headings.len(), 3);
1586 assert_eq!(headings[0]["text"].as_str(), Some("<for> Loop Directive"));
1587 assert_eq!(headings[0]["anchor"].as_str(), Some("for"));
1588 assert_eq!(headings[0]["level"].as_u64(), Some(1));
1589 assert_eq!(headings[1]["text"].as_str(), Some("Syntax"));
1590 assert_eq!(headings[1]["anchor"].as_str(), Some("syntax"));
1591 assert_eq!(headings[1]["level"].as_u64(), Some(2));
1592 assert_eq!(headings[2]["text"].as_str(), Some("Nested Search"));
1593 assert_eq!(headings[2]["anchor"].as_str(), Some("nested"));
1594 assert_eq!(headings[2]["level"].as_u64(), Some(3));
1595 }
1596
1597 #[test]
1600 fn fxhash_deterministic() {
1601 assert_eq!(fxhash("abc"), fxhash("abc"));
1602 assert_ne!(fxhash("abc"), fxhash("abd"));
1603 }
1604}