Skip to main content

webui_docs/
build.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! Build orchestrator: content pipeline → protocol build → parallel render → output.
5
6use 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/// Persistent state held by the dev server across rebuilds. The dev
68/// server always performs a full rebuild on every watcher tick — the
69/// previous incremental machinery proved too complex for the marginal
70/// time savings — so this struct exists solely to amortize startup
71/// costs that don't depend on user-edited content.
72///
73/// Today that's just the syntect `Highlighter` (the syntax + theme
74/// load is ~30-50ms). One-shot CLI builds construct a fresh
75/// `BuildCache::default()` and discard it.
76#[derive(Default)]
77pub struct BuildCache {
78    /// Syntect highlighter — kept alive across rebuilds so we don't pay
79    /// the syntax/theme load cost on every keystroke.
80    pub(crate) highlighter: Option<Highlighter>,
81    /// Suppress per-step terminal output (`✔ Rendered 31 pages`, etc.).
82    /// The dev server flips this on after the first build so subsequent
83    /// rebuilds collapse into the rolling rebuild line.
84    pub quiet: bool,
85    /// Skip the `remove_dir_all(out_dir)` step. Files are overwritten
86    /// in place. Dev-mode optimization — saves the macOS-ENOTEMPTY
87    /// retry path and shaves disk I/O off every rebuild. Stale files
88    /// from deleted source pages survive until the next process restart.
89    pub skip_clean: bool,
90    /// Dev-mode flag: when true, the bundler skips minification for
91    /// faster rebuilds during `webui-press serve`.
92    pub dev_mode: bool,
93    /// Additional state inputs discovered from Markdown.
94    /// Versions describe this build's reads; failed builds retain watch paths.
95    pub(crate) dependencies: StateDependencies,
96}
97
98impl BuildCache {
99    /// Create an empty cache with verbose output and clean-then-build
100    /// semantics. Subsequent calls to [`build_docs_with_cache`] will
101    /// populate it on first use.
102    #[must_use]
103    pub fn new() -> Self {
104        Self::default()
105    }
106
107    /// Switch the cache into dev-server rebuild mode: suppresses
108    /// per-step output banners and skips wiping the output directory.
109    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
191// ── Output helpers ──────────────────────────────────────────────
192//
193// Mirrors the styling vocabulary in `crates/webui-cli/src/utils/output.rs`
194// so webui-press feels at home in a workspace where users may also see
195// `webui build` / `webui serve` output.
196
197/// Monotonic counter used to produce unique per-rebuild temp-dir names.
198/// Combined with the process id and a fxhash of the page path so two
199/// rebuilds running in the same process can never collide on the
200/// per-page scratch dir.
201static 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
252/// Build a JSON object from key-value pairs without using `json!` (which calls `unwrap`).
253fn 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
261/// Build a documentation site from the given configuration.
262///
263/// `config_dir` is the directory containing `config.json`. It is used to
264/// resolve relative paths declared in the config (such as a custom page's
265/// `state_file`).
266///
267/// One-shot variant for the CLI build command. The dev server uses
268/// [`build_docs_with_cache`] so the syntect highlighter survives across
269/// rebuilds.
270pub 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
279/// Build a documentation site, reusing the supplied [`BuildCache`] across
280/// invocations.
281///
282/// **Always performs a full rebuild.** The previous incremental machinery
283/// (per-page state hashes, component-bundle signatures, descriptor cache,
284/// search-index sig, etc.) was removed because the resulting invariants
285/// were complex for a dev-server-only optimization. The rebuild is
286/// already fast in practice and a from-scratch run keeps the output
287/// guaranteed-consistent with the source.
288///
289/// The cache exists today purely to amortize startup costs that are
290/// independent of user-edited content (the syntect highlighter load).
291pub 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    // Flat output: site files live at the root of `out_dir`. URLs in HTML
303    // include `basePath` via `<base href>` and link rewriting; the dev server
304    // (`webui-press serve`) maps URL `<basePath>/foo` → file `out_dir/foo`.
305    // For deploys (e.g. GitHub Pages project sites), the host mounts `out_dir`
306    // at `<basePath>`, so the same flat layout works.
307    let site_dir = out_dir.to_path_buf();
308
309    // Per-page and 404 scratch directories follow the output volume so a whole
310    // Press build never spans two filesystem roots.
311    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    // Read custom CSS
319    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    // Compute the `<head>` injection string up-front, BEFORE any
331    // filesystem mutations. Including it here makes it cheap to bake
332    // into each page's state inside `process_content` (avoiding a
333    // post-process_content mutation pass that would force-clone every
334    // cached descriptor) and lets the content cache invalidate when
335    // the link tags change. We don't write the CSS files yet — that
336    // happens after content processing succeeds, so a content failure
337    // can't corrupt the previous valid output.
338    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    // Step 1: Process content. Highlighter is cached across rebuilds —
377    // syntect's default syntax/theme load is ~30-50ms which we don't want
378    // to pay every keystroke.
379    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    // Restore the highlighter for the next rebuild.
400    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    // Step 2: Resolve component sources for the per-page builds
409    let mut component_sources: Vec<String> = Vec::new();
410    let cwd = std::env::current_dir().unwrap_or_default();
411    // Built-in component library (e.g. crates/webui-press/components/)
412    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    // User component sources from config. Local paths are resolved against
419    // the current project root; npm package names/scopes must stay bare so
420    // webui-discovery resolves them from node_modules.
421    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    // Template-local components (e.g. docs-search, docs-theme-toggle living
427    // beside the template's index.html).
428    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    // Validate configured region names even when the selected presentation
434    // makes every shell region inactive. Authored page content is never filtered.
435    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    // Step 3: Wipe the previous output and recreate the site root.
451    // Always done — a from-scratch run guarantees the output matches
452    // the current source with no possibility of stale per-page dirs,
453    // stale CSS files, or orphaned assets surviving a config change.
454    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    // Materialize the CSS files referenced by the head injection. Done
461    // after content processing succeeds so a content failure can't
462    // corrupt the previous valid output (the wipe above still happens
463    // because we clean before processing — but that order means a
464    // failure leaves no output at all, never half-output).
465    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    // Pre-create per-page output directories sequentially (avoids races and
485    // cheap fs::create_dir_all calls competing on the same parent paths).
486    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    // Step 2b: Extract <script bundle> tags from page content BEFORE
493    // rendering. Page scripts become imports in the page's virtual esbuild
494    // entry; the source HTML keeps plain, non-bundled scripts untouched.
495    //
496    // Also collect `scriptFile` entries from customPages config. These
497    // produce per-page scripts without polluting the HTML string.
498    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    // Collect scriptFile from customPages config.
523    for (link, custom_page) in &config.custom_pages {
524        if let Some(script_path) = custom_page.script_file() {
525            // Build the full page path matching how process_content constructs
526            // URL paths: base_path + link (minus leading slash).
527            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    // Build virtual entries only for pages with content-specific scripts.
569    // Template/chrome scripts are loaded once through the root entry, so pages
570    // that only use template components do not get duplicate tiny page-N files.
571    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(&not_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(&not_found_component_scripts, &not_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    // Start the one client bundle in parallel with per-page template parsing.
646    // Each page blocks only when it reaches projection finalization.
647    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    // Published before the projection completer wakes page builds. This keeps
662    // parsing overlapped with esbuild while making exact generated URLs
663    // available before each protocol is handed to the renderer.
664    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    // Per-page build + render + write in parallel.
698    //
699    // Each page is its own complete app: we substitute `{{{page.content}}}`
700    // in the template with the page's actual HTML (including any custom
701    // elements like <code-block>) and run `webui::build()` on the result.
702    // This means the build pipeline naturally discovers the components used
703    // on this page, expands their declarative shadow DOM, and emits their
704    // template metadata to `window.__webui.templates` for client-side
705    // hydration — no manual registration tricks required.
706    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        // Use modified content (with bundled script tags removed) if scripts were extracted.
716        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        // Protect markdown code blocks before injecting content into the
722        // region-resolved template.
723        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        // Per-page temp dir holding only this page's index.html — components
730        // come exclusively from `component_sources`, which already includes
731        // the template dir (for docs-search/docs-theme-toggle) plus any
732        // configured component libraries.
733        //
734        // Name includes pid + per-rebuild nonce so two parallel page
735        // builds (and successive rebuilds) can never collide and wipe
736        // each other's in-progress files.
737        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        // Collect per-component CSS files (Link strategy emits one .css
777        // per component used on the page). Multiple pages share components,
778        // so we dedupe by filename in a shared map and write once after
779        // the parallel pass completes.
780        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        // Restore the protected <pre> blocks via a single-pass scan.
807        let html = restore_pre_blocks(&writer.buf, &pre_blocks);
808
809        // Write directly inside the parallel closure.
810        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    // Step 4: Write the index prepared with page heading metadata before render.
829    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    // Step 5: Copy static public assets.
842    let public_dir = Path::new(&config.public_dir);
843    if public_dir.exists() {
844        copy_dir(public_dir, &site_dir)?;
845    }
846
847    // Step 6: Generate 404 page
848    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: &not_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(&not_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"), &not_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    // Fold the 404 page's component CSS into the shared map, then write
909    // all per-component stylesheets at site root in one pass. The handler
910    // emits relative hrefs like `<link rel="stylesheet" href="code-block.css">`;
911    // the template's <base href="{site.base}"> resolves them against site root.
912    {
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            &not_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, &not_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    // Step 7: Write per-component stylesheets collected during the
940    // parallel page builds (and the 404 build above). Sort by name for
941    // deterministic output.
942    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    // Step 8: Inject page script <script> tags into rendered pages.
957    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
1044/// Recursively remove a directory, retrying briefly on `ENOTEMPTY`.
1045///
1046/// On macOS (and occasionally Linux), `fs::remove_dir_all` can fail
1047/// with `Directory not empty` when something writes a file into a
1048/// subdirectory between the moment we walked it and the moment we try
1049/// to `rmdir` it. Likely culprits during a dev-server rebuild:
1050///
1051///  - Spotlight / `mds` writing index hints,
1052///  - Finder dropping a `.DS_Store`,
1053///  - the user's editor reindexing the project,
1054///  - a back-to-back save firing another rebuild before the first one
1055///    finished cleanup (the worker coalesces, but a stray write into
1056///    `out_dir` from a parallel `cargo` or another tool is still
1057///    possible).
1058///
1059/// We retry up to a handful of times with short backoff. After that the
1060/// error propagates so the user sees a real failure rather than an
1061/// infinite loop.
1062fn 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    // Loop guarantees a return on each iteration; this is unreachable
1076    // but `for` returning `()` makes the type-checker happy without it.
1077    Ok(())
1078}
1079
1080/// Returns true if the error is `ENOTEMPTY` / `EEXIST` style "directory
1081/// not empty". On macOS this is errno 66; on Linux it's 39. We also
1082/// match by `ErrorKind` once Rust exposes one (currently `Other`).
1083fn is_dir_not_empty(e: &std::io::Error) -> bool {
1084    match e.raw_os_error() {
1085        Some(66) => true,  // macOS / BSD: ENOTEMPTY
1086        Some(39) => true,  // Linux: ENOTEMPTY
1087        Some(145) => true, // Windows: ERROR_DIR_NOT_EMPTY
1088        _ => false,
1089    }
1090}
1091
1092/// Iteratively copy a directory tree (BFS via stack — no recursion).
1093fn 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
1116/// Truncate a string to at most `max` bytes at a valid UTF-8 boundary.
1117pub(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
1134/// Heading metadata emitted into `search-index.json`.
1135///
1136/// The client search UI uses this to create separate, anchor-linked rows for
1137/// matching sections (`Page > Heading`) without re-parsing page HTML at runtime.
1138struct SearchHeading {
1139    text: String,
1140    anchor: String,
1141    level: u8,
1142}
1143
1144/// Mutable heading capture state while scanning rendered HTML.
1145struct 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        // Reuse the search scan and the Markdown renderer's actual IDs. Raw
1163        // custom-page headings without IDs are searchable but not TOC targets.
1164        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
1184/// Build a single search-index entry for a page. Strips HTML tags,
1185/// decodes escaped text, captures headings for weighted ranking,
1186/// collapses whitespace, and truncates body content to 500 bytes.
1187pub(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
1347/// Return a heading level for `h1` through `h6`.
1348///
1349/// The scanner works on tag names from already-rendered markdown, so an ASCII
1350/// byte check is sufficient and avoids allocating a lowercased copy.
1351fn 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
1361/// Extract an HTML attribute value from a rendered start tag without a DOM
1362/// parser.
1363///
1364/// Search indexing runs once per page at build time, but it still avoids regex
1365/// and allocation-heavy parsing. This helper handles quoted and unquoted values
1366/// because heading IDs are generated as ordinary HTML attributes.
1367fn 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
1479/// Replace `<pre …>…</pre>` blocks with placeholder elements so the WebUI
1480/// HTML parser does not normalize whitespace inside them. Returns the
1481/// modified string and the original blocks (in order) for restoration
1482/// after rendering.
1483fn 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            // write! into existing buffer — avoids `format!` allocation per block.
1495            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
1507/// Find the next opening `<pre` tag where the next byte is one of `>`, ` `,
1508/// `\t`, `\n`, or `\r`. Avoids matching `<presentation`, `<pretend`, etc.
1509fn 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
1523/// Single-pass restoration of pre-block placeholder elements to their
1524/// original content. Faster than calling `String::replace` once per block.
1525fn 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            // Unknown placeholder — keep verbatim.
1546            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
1557/// Cheap deterministic hash for build-time artifact and temporary-file identities.
1558fn 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    // --- truncate_utf8 ---------------------------------------------------
1669
1670    #[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        // "é" is two bytes (0xC3 0xA9). Cutting at 1 byte must step back to 0.
1683        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); // "café" is 5 bytes total
1693        assert_eq!(out, "café");
1694    }
1695
1696    // --- protect_pre_blocks / find_pre_open ------------------------------
1697
1698    #[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        // Was previously missed because we only matched "<pre " (with space).
1711        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        // <presentation> must NOT be treated as a pre block.
1720        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>&lt;for&gt;</code> Loop Directive <a class="header-anchor" href="#for">#</a></h1><p>Use <code>&lt;for&gt;</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>&lt;for&gt;</code> &amp; Syntax",
1836                            "<a class=\"header-anchor\" href=\"#syntax\">#</a></h2>",
1837                            "<h3>Unanchored custom heading</h3>",
1838                            "<h6 id=\"details&amp;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    // --- fxhash ----------------------------------------------------------
1862
1863    #[test]
1864    fn fxhash_deterministic() {
1865        assert_eq!(fxhash("abc"), fxhash("abc"));
1866        assert_ne!(fxhash("abc"), fxhash("abd"));
1867    }
1868}