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::bundler::{
21    bundle_assets, collect_component_scripts_for_html, discover_component_scripts,
22    extract_bundle_scripts, inject_module_script_tags, inject_script_tag, module_script_tag,
23    page_bundle_signature, resolve_config_component_source, resolve_node_modules, BundleOptions,
24    BundleResult, BundleThread, PageBundleEntry, RootBundleEntry, ScriptSource,
25};
26use crate::content::process_content_with_states;
27use crate::error::{Error, Result};
28use crate::markdown::Highlighter;
29use crate::regions::RegionSet;
30use crate::state::{load_render_states, merge_page_state, StateDependencies};
31use crate::types::{BuildStats, DocsConfig, PageDescriptor, ShowMode};
32
33webui_handler::define_string_response_writer!(StringWriter, buf);
34
35fn region_layout(page: &PageDescriptor) -> &str {
36    let layout = page.state["page"]["layout"].as_str().unwrap_or("doc");
37    if layout == "home" && !page.is_home {
38        "doc"
39    } else {
40        layout
41    }
42}
43
44fn template_css(template_dir: &Path, show: ShowMode) -> Result<String> {
45    if show == ShowMode::Content {
46        return Ok(include_str!("../template/docs.css").to_string());
47    }
48    let mut css = String::new();
49    for name in ["docs.css", "shell.css"] {
50        let path = template_dir.join(name);
51        match fs::read_to_string(&path) {
52            Ok(source) => css.push_str(&source),
53            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
54            Err(error) => {
55                return Err(Error::Io(format!(
56                    "Cannot read {}: {error}",
57                    path.display()
58                )));
59            }
60        }
61    }
62    Ok(css)
63}
64
65/// Persistent state held by the dev server across rebuilds. The dev
66/// server always performs a full rebuild on every watcher tick — the
67/// previous incremental machinery proved too complex for the marginal
68/// time savings — so this struct exists solely to amortize startup
69/// costs that don't depend on user-edited content.
70///
71/// Today that's just the syntect `Highlighter` (the syntax + theme
72/// load is ~30-50ms). One-shot CLI builds construct a fresh
73/// `BuildCache::default()` and discard it.
74#[derive(Default)]
75pub struct BuildCache {
76    /// Syntect highlighter — kept alive across rebuilds so we don't pay
77    /// the syntax/theme load cost on every keystroke.
78    pub(crate) highlighter: Option<Highlighter>,
79    /// Suppress per-step terminal output (`✔ Rendered 31 pages`, etc.).
80    /// The dev server flips this on after the first build so subsequent
81    /// rebuilds collapse into the rolling rebuild line.
82    pub quiet: bool,
83    /// Skip the `remove_dir_all(out_dir)` step. Files are overwritten
84    /// in place. Dev-mode optimization — saves the macOS-ENOTEMPTY
85    /// retry path and shaves disk I/O off every rebuild. Stale files
86    /// from deleted source pages survive until the next process restart.
87    pub skip_clean: bool,
88    /// Dev-mode flag: when true, the bundler skips minification for
89    /// faster rebuilds during `webui-press serve`.
90    pub dev_mode: bool,
91    /// Additional state inputs discovered from Markdown.
92    /// Versions describe this build's reads; failed builds retain watch paths.
93    pub(crate) dependencies: StateDependencies,
94}
95
96impl BuildCache {
97    /// Create an empty cache with verbose output and clean-then-build
98    /// semantics. Subsequent calls to [`build_docs_with_cache`] will
99    /// populate it on first use.
100    #[must_use]
101    pub fn new() -> Self {
102        Self::default()
103    }
104
105    /// Switch the cache into dev-server rebuild mode: suppresses
106    /// per-step output banners and skips wiping the output directory.
107    pub fn set_dev_rebuild(&mut self) {
108        self.quiet = true;
109        self.skip_clean = true;
110        self.dev_mode = true;
111    }
112}
113
114fn load_theme(theme: &str, config_dir: &Path) -> Result<TokenFile> {
115    let config_relative = config_dir.join(theme);
116    let resolved = if config_relative.exists() {
117        config_relative
118            .canonicalize()
119            .map_err(|e| Error::Build(format!("Failed to canonicalize {theme}: {e}")))?
120    } else {
121        webui_tokens::resolve_theme_path(theme, config_dir)
122            .map_err(|e| Error::Build(format!("Failed to resolve theme {theme}: {e}")))?
123    };
124    webui_tokens::load_token_file(&resolved)
125        .map_err(|e| Error::Build(format!("Failed to load theme {}: {e}", resolved.display())))
126}
127
128fn inject_theme_tokens(
129    state: &mut Value,
130    token_file: &TokenFile,
131    protocol_tokens: &[String],
132) -> Result<()> {
133    let resolved = webui_tokens::resolve_tokens(protocol_tokens, token_file)
134        .map_err(|e| Error::Build(format!("Failed to resolve theme tokens: {e}")))?;
135    webui_tokens::inject_into_state(state, &resolved);
136    Ok(())
137}
138
139struct NotFoundStateInput<'a> {
140    config: &'a DocsConfig,
141    base_path: &'a str,
142    nav: Value,
143    footer: Value,
144    content: &'a str,
145    head_injection: &'a str,
146    global_state: Option<&'a Value>,
147}
148
149fn build_not_found_state(input: NotFoundStateInput<'_>) -> Value {
150    let state = json_obj([
151        (
152            "site",
153            json_obj([
154                ("title", Value::String(input.config.site.title.clone())),
155                ("base", Value::String(input.base_path.to_string())),
156            ]),
157        ),
158        ("navigation", input.nav),
159        ("sidebar", json_obj([("sections", Value::Array(vec![]))])),
160        (
161            "page",
162            json_obj([
163                ("title", Value::String("Page Not Found".to_string())),
164                (
165                    "description",
166                    Value::String("The page you're looking for doesn't exist.".to_string()),
167                ),
168                ("content", Value::String(input.content.to_string())),
169                ("isHome", Value::Bool(false)),
170                ("layout", Value::String("doc".to_string())),
171            ]),
172        ),
173        ("hero", Value::Null),
174        ("footer", input.footer),
175        ("prev", Value::Null),
176        ("next", Value::Null),
177        ("pageData", Value::Null),
178        ("headTags", Value::String(input.head_injection.to_string())),
179        ("label", Value::String("Copy".to_string())),
180        ("icon", Value::String("🌙".to_string())),
181    ]);
182
183    merge_page_state(state, input.global_state, None)
184}
185
186// ── Output helpers ──────────────────────────────────────────────
187//
188// Mirrors the styling vocabulary in `crates/webui-cli/src/utils/output.rs`
189// so webui-press feels at home in a workspace where users may also see
190// `webui build` / `webui serve` output.
191
192/// Monotonic counter used to produce unique per-rebuild temp-dir names.
193/// Combined with the process id and a fxhash of the page path so two
194/// rebuilds running in the same process can never collide on the
195/// per-page scratch dir.
196static REBUILD_NONCE: AtomicU64 = AtomicU64::new(0);
197
198fn print_header(cache: &BuildCache, title: &str) {
199    if cache.quiet {
200        return;
201    }
202    eprintln!(
203        "\n  {} {}",
204        style("⚡").cyan().bold(),
205        style(title).cyan().bold()
206    );
207}
208
209fn print_success(cache: &BuildCache, message: &str) {
210    if cache.quiet {
211        return;
212    }
213    eprintln!("  {} {message}", style("✔").green());
214}
215
216fn print_bundle_summary(cache: &BuildCache, result: &BundleResult) {
217    print_success(
218        cache,
219        &format!(
220            "Bundled {} component script{} into {} root script{} and {} page script group{} in {:.0}ms",
221            result.component_count,
222            if result.component_count == 1 { "" } else { "s" },
223            usize::from(result.root_script.is_some()),
224            if result.root_script.is_some() {
225                ""
226            } else {
227                "s"
228            },
229            result.page_entry_count,
230            if result.page_entry_count == 1 {
231                ""
232            } else {
233                "s"
234            },
235            result.duration.as_secs_f64() * 1000.0
236        ),
237    );
238}
239
240fn print_finish(cache: &BuildCache, message: &str) {
241    if cache.quiet {
242        return;
243    }
244    eprintln!("\n  {} {message}\n", style("✨").green());
245}
246
247/// Build a JSON object from key-value pairs without using `json!` (which calls `unwrap`).
248fn json_obj<const N: usize>(entries: [(&str, Value); N]) -> Value {
249    let mut map = Map::with_capacity(N);
250    for (k, v) in entries {
251        map.insert(k.to_string(), v);
252    }
253    Value::Object(map)
254}
255
256/// Build a documentation site from the given configuration.
257///
258/// `config_dir` is the directory containing `config.json`. It is used to
259/// resolve relative paths declared in the config (such as a custom page's
260/// `state_file`).
261///
262/// One-shot variant for the CLI build command. The dev server uses
263/// [`build_docs_with_cache`] so the syntect highlighter survives across
264/// rebuilds.
265pub fn build_docs(
266    config: &DocsConfig,
267    config_dir: &Path,
268    template_dir: &Path,
269) -> Result<BuildStats> {
270    let mut cache = BuildCache::new();
271    build_docs_with_cache(config, config_dir, template_dir, &mut cache)
272}
273
274/// Build a documentation site, reusing the supplied [`BuildCache`] across
275/// invocations.
276///
277/// **Always performs a full rebuild.** The previous incremental machinery
278/// (per-page state hashes, component-bundle signatures, descriptor cache,
279/// search-index sig, etc.) was removed because the resulting invariants
280/// were complex for a dev-server-only optimization. The rebuild is
281/// already fast in practice and a from-scratch run keeps the output
282/// guaranteed-consistent with the source.
283///
284/// The cache exists today purely to amortize startup costs that are
285/// independent of user-edited content (the syntect highlighter load).
286pub fn build_docs_with_cache(
287    config: &DocsConfig,
288    config_dir: &Path,
289    template_dir: &Path,
290    cache: &mut BuildCache,
291) -> Result<BuildStats> {
292    cache.dependencies.begin_build();
293    let start = Instant::now();
294    let base_path = &config.base_path;
295    let out_dir = Path::new(&config.out_dir);
296
297    // Flat output: site files live at the root of `out_dir`. URLs in HTML
298    // include `basePath` via `<base href>` and link rewriting; the dev server
299    // (`webui-press serve`) maps URL `<basePath>/foo` → file `out_dir/foo`.
300    // For deploys (e.g. GitHub Pages project sites), the host mounts `out_dir`
301    // at `<basePath>`, so the same flat layout works.
302    let site_dir = out_dir.to_path_buf();
303
304    // Per-page and 404 scratch directories follow the output volume so a whole
305    // Press build never spans two filesystem roots.
306    let scratch_root = crate::scratch::scratch_base(out_dir, config_dir).map_err(|e| {
307        Error::Io(format!(
308            "Cannot prepare the Press scratch directory for {}: {e}",
309            out_dir.display()
310        ))
311    })?;
312
313    // Read custom CSS
314    let custom_css = config
315        .css
316        .as_ref()
317        .and_then(|p| fs::read_to_string(p).ok())
318        .unwrap_or_default();
319    let token_file = match &config.theme {
320        Some(theme) => Some(load_theme(theme, config_dir)?),
321        None => None,
322    };
323
324    print_header(cache, &config.site.title);
325    eprintln!();
326
327    // Compute the `<head>` injection string up-front, BEFORE any
328    // filesystem mutations. Including it here makes it cheap to bake
329    // into each page's state inside `process_content` (avoiding a
330    // post-process_content mutation pass that would force-clone every
331    // cached descriptor) and lets the content cache invalidate when
332    // the link tags change. We don't write the CSS files yet — that
333    // happens after content processing succeeds, so a content failure
334    // can't corrupt the previous valid output.
335    let base_css = template_css(template_dir, config.show)?;
336    let has_base_css = !base_css.is_empty();
337    let base_css_link = if has_base_css {
338        format!("<link rel=\"stylesheet\" href=\"{base_path}docs.css\">")
339    } else {
340        String::new()
341    };
342    let has_theme_css = !custom_css.is_empty();
343    let theme_css_link = if has_theme_css {
344        format!("<link rel=\"stylesheet\" href=\"{base_path}theme.css\">")
345    } else {
346        String::new()
347    };
348    let head_injection = {
349        let mut parts: Vec<String> = Vec::with_capacity(2 + config.head.len());
350        if !base_css_link.is_empty() {
351            parts.push(base_css_link.clone());
352        }
353        if !theme_css_link.is_empty() {
354            parts.push(theme_css_link.clone());
355        }
356        for tag in &config.head {
357            parts.push(tag.to_html());
358        }
359        parts.join("\n  ")
360    };
361
362    // Step 1: Process content. Highlighter is cached across rebuilds —
363    // syntect's default syntax/theme load is ~30-50ms which we don't want
364    // to pay every keystroke.
365    let render_states = load_render_states(config, config_dir)?;
366    let highlighter = cache.highlighter.take().unwrap_or_default();
367    let mut dependencies = StateDependencies::default();
368    let content = process_content_with_states(
369        config,
370        &highlighter,
371        &head_injection,
372        &render_states,
373        &mut dependencies,
374    );
375    if content.is_err() {
376        dependencies.paths.append(&mut cache.dependencies.paths);
377        dependencies.paths.sort();
378        dependencies.paths.dedup();
379    }
380    cache.dependencies = dependencies;
381
382    // Restore the highlighter for the next rebuild.
383    cache.highlighter = Some(highlighter);
384    let mut pages = content?;
385
386    // Step 2: Resolve component sources for the per-page builds
387    let mut component_sources: Vec<String> = Vec::new();
388    let cwd = std::env::current_dir().unwrap_or_default();
389    // Built-in component library (e.g. crates/webui-press/components/)
390    let builtin_components = template_dir.parent().map(|p| p.join("components"));
391    if let Some(ref bc) = builtin_components {
392        if bc.exists() {
393            component_sources.push(bc.to_string_lossy().to_string());
394        }
395    }
396    // User component sources from config. Local paths are resolved against
397    // the current project root; npm package names/scopes must stay bare so
398    // webui-discovery resolves them from node_modules.
399    if let Some(ref user_sources) = config.components {
400        for source in user_sources {
401            component_sources.push(resolve_config_component_source(source, &cwd));
402        }
403    }
404    // Template-local components (e.g. docs-search, docs-theme-toggle living
405    // beside the template's index.html).
406    component_sources.push(template_dir.to_string_lossy().to_string());
407
408    let template_html = fs::read_to_string(template_dir.join("index.html"))
409        .map_err(|e| Error::Build(format!("Failed to read template: {e}")))?;
410    let regions = RegionSet::load(&config.regions, config_dir, template_html)?;
411    // Validate configured region names even when the selected presentation
412    // makes every shell region inactive. Authored page content is never filtered.
413    let regions = if config.show == ShowMode::Content {
414        RegionSet::load(
415            &Default::default(),
416            config_dir,
417            include_str!("../template/content.html").to_string(),
418        )?
419    } else {
420        regions
421    };
422    let component_script_index = discover_component_scripts(&component_sources)?;
423
424    // Step 3: Wipe the previous output and recreate the site root.
425    // Always done — a from-scratch run guarantees the output matches
426    // the current source with no possibility of stale per-page dirs,
427    // stale CSS files, or orphaned assets surviving a config change.
428    if out_dir.exists() && !cache.skip_clean {
429        remove_dir_all_retry(out_dir)
430            .map_err(|e| Error::Io(format!("Cannot clean output dir: {e}")))?;
431    }
432    fs::create_dir_all(&site_dir).map_err(|e| Error::Io(format!("Cannot create site dir: {e}")))?;
433
434    // Materialize the CSS files referenced by the head injection. Done
435    // after content processing succeeds so a content failure can't
436    // corrupt the previous valid output (the wipe above still happens
437    // because we clean before processing — but that order means a
438    // failure leaves no output at all, never half-output).
439    if has_base_css {
440        fs::write(site_dir.join("docs.css"), &base_css)
441            .map_err(|e| Error::Io(format!("Cannot write docs.css: {e}")))?;
442    }
443    if has_theme_css {
444        fs::write(site_dir.join("theme.css"), &custom_css)
445            .map_err(|e| Error::Io(format!("Cannot write theme.css: {e}")))?;
446    }
447
448    let handler = WebUIHandler::with_plugin(|| {
449        Box::new(webui_handler::plugin::webui::WebUIHydrationPlugin::new())
450    });
451
452    // Pre-create per-page output directories sequentially (avoids races and
453    // cheap fs::create_dir_all calls competing on the same parent paths).
454    for page in &pages {
455        let page_dir = site_dir.join(page.path.strip_prefix(base_path).unwrap_or(&page.path));
456        fs::create_dir_all(&page_dir)
457            .map_err(|e| Error::Io(format!("Cannot create dir {}: {e}", page_dir.display())))?;
458    }
459
460    // Step 2b: Extract <script bundle> tags from page content BEFORE
461    // rendering. Page scripts become imports in the page's virtual esbuild
462    // entry; the source HTML keeps plain, non-bundled scripts untouched.
463    //
464    // Also collect `scriptFile` entries from customPages config. These
465    // produce per-page scripts without polluting the HTML string.
466    let mut explicit_page_scripts: HashMap<String, Vec<ScriptSource>> = HashMap::new();
467    let mut page_contents_with_scripts: HashMap<String, String> = HashMap::new();
468
469    for page in &pages {
470        let content = page.state["page"]["content"].as_str().unwrap_or("");
471        if content.contains("<script") && content.contains("bundle") {
472            let (modified, scripts) = extract_bundle_scripts(content);
473            if !scripts.is_empty() {
474                page_contents_with_scripts.insert(page.path.clone(), modified);
475                explicit_page_scripts
476                    .entry(page.path.clone())
477                    .or_default()
478                    .extend(scripts);
479            }
480        }
481        for script_file in regions.script_files(region_layout(page)) {
482            explicit_page_scripts
483                .entry(page.path.clone())
484                .or_default()
485                .push(ScriptSource::File(script_file.to_string()));
486        }
487    }
488
489    // Collect scriptFile from customPages config.
490    for (link, custom_page) in &config.custom_pages {
491        if let Some(script_path) = custom_page.script_file() {
492            // Build the full page path matching how process_content constructs
493            // URL paths: base_path + link (minus leading slash).
494            let normalized = if link.ends_with('/') {
495                link.clone()
496            } else {
497                format!("{link}/")
498            };
499            let page_path = format!("{}{}", base_path, &normalized[1..]);
500            explicit_page_scripts
501                .entry(page_path)
502                .or_default()
503                .push(ScriptSource::File(script_path.to_string()));
504        }
505    }
506
507    let not_found_content = format!(
508        "<h1>404 — Page Not Found</h1>\
509         <p>The page you're looking for doesn't exist or has been moved.</p>\
510         <p><a href=\"{base_path}\">← Back to Home</a></p>"
511    );
512
513    let template_script_path = template_dir.join("index.ts");
514    let template_script = if config.show == ShowMode::All && template_script_path.exists() {
515        Some(
516            template_script_path
517                .canonicalize()
518                .unwrap_or(template_script_path),
519        )
520    } else {
521        None
522    };
523    let template_shell = regions.template_shell();
524    let root_component_scripts =
525        collect_component_scripts_for_html(&[template_shell.as_str()], &component_script_index)?;
526    let root_bundle = if template_script.is_some() || !root_component_scripts.is_empty() {
527        Some(RootBundleEntry {
528            script_path: template_script,
529            component_scripts: root_component_scripts,
530        })
531    } else {
532        None
533    };
534
535    // Build virtual entries only for pages with content-specific scripts.
536    // Template/chrome scripts are loaded once through the root entry, so pages
537    // that only use template components do not get duplicate tiny page-N files.
538    let mut page_bundles: Vec<PageBundleEntry> = Vec::new();
539    let mut page_bundle_ids: HashMap<String, usize> = HashMap::with_capacity(pages.len());
540    let mut page_bundle_signatures: HashMap<String, usize> = HashMap::new();
541    for page in &pages {
542        let content = page_contents_with_scripts
543            .get(&page.path)
544            .map(String::as_str)
545            .unwrap_or_else(|| page.state["page"]["content"].as_str().unwrap_or(""));
546        let region_fragments = regions.html_fragments(region_layout(page));
547        let mut html_sources = Vec::with_capacity(region_fragments.len() + 1);
548        html_sources.push(content);
549        html_sources.extend(region_fragments);
550        let mut component_scripts =
551            collect_component_scripts_for_html(&html_sources, &component_script_index)?;
552        if let Some(root) = &root_bundle {
553            component_scripts.retain(|path| !root.component_scripts.contains(path));
554        }
555        let explicit_scripts = explicit_page_scripts.remove(&page.path).unwrap_or_default();
556        if component_scripts.is_empty() && explicit_scripts.is_empty() {
557            continue;
558        }
559        let signature = page_bundle_signature(&component_scripts, &explicit_scripts, config_dir);
560        if let Some(&id) = page_bundle_signatures.get(&signature) {
561            page_bundle_ids.insert(page.path.clone(), id);
562            continue;
563        }
564
565        let id = page_bundles.len();
566        page_bundle_signatures.insert(signature, id);
567        page_bundle_ids.insert(page.path.clone(), id);
568        page_bundles.push(PageBundleEntry {
569            id,
570            page_path: page.path.clone(),
571            component_scripts,
572            explicit_scripts,
573        });
574    }
575
576    let not_found_region_fragments = regions.html_fragments("doc");
577    let mut not_found_sources = Vec::with_capacity(not_found_region_fragments.len() + 1);
578    not_found_sources.push(not_found_content.as_str());
579    not_found_sources.extend(not_found_region_fragments);
580    let mut not_found_component_scripts =
581        collect_component_scripts_for_html(&not_found_sources, &component_script_index)?;
582    if let Some(root) = &root_bundle {
583        not_found_component_scripts.retain(|path| !root.component_scripts.contains(path));
584    }
585    let not_found_scripts: Vec<ScriptSource> = regions
586        .script_files("doc")
587        .map(|path| ScriptSource::File(path.to_string()))
588        .collect();
589    let not_found_bundle_id =
590        if not_found_component_scripts.is_empty() && not_found_scripts.is_empty() {
591            None
592        } else {
593            let signature =
594                page_bundle_signature(&not_found_component_scripts, &not_found_scripts, config_dir);
595            if let Some(&id) = page_bundle_signatures.get(&signature) {
596                Some(id)
597            } else {
598                let id = page_bundles.len();
599                page_bundle_signatures.insert(signature, id);
600                page_bundles.push(PageBundleEntry {
601                    id,
602                    page_path: format!("{base_path}404/"),
603                    component_scripts: not_found_component_scripts,
604                    explicit_scripts: not_found_scripts,
605                });
606                Some(id)
607            }
608        };
609
610    // Start the one client bundle in parallel with per-page template parsing.
611    // Each page blocks only when it reaches projection finalization.
612    let node_modules = if root_bundle.is_some() || !page_bundles.is_empty() {
613        Some(resolve_node_modules(config_dir)?)
614    } else {
615        None
616    };
617    let (projection_source, projection_completer) = webui::projection_manifest_barrier();
618    let site_dir_for_bundle = site_dir.clone();
619    let root_bundle_for_bundle = root_bundle.clone();
620    let page_bundles_for_bundle = page_bundles.clone();
621    let bundler_config = config.bundler.clone();
622    let config_dir_for_bundle = config_dir.to_path_buf();
623    let content_dir_for_bundle = PathBuf::from(&config.content_dir);
624    let base_path_for_bundle = base_path.to_string();
625    let dev_mode = cache.dev_mode;
626    // Published before the projection completer wakes page builds. This keeps
627    // parsing overlapped with esbuild while making exact generated URLs
628    // available before each protocol is handed to the renderer.
629    let generated_preloads = Arc::new(OnceLock::new());
630    let generated_preloads_for_bundle = Arc::clone(&generated_preloads);
631    let bundle_thread = BundleThread::spawn(move || {
632        match bundle_assets(&BundleOptions {
633            site_dir: &site_dir_for_bundle,
634            base_path: &base_path_for_bundle,
635            node_modules: node_modules.as_deref(),
636            root_bundle: root_bundle_for_bundle.as_ref(),
637            page_bundles: &page_bundles_for_bundle,
638            bundler_config: bundler_config.as_ref(),
639            dev_mode,
640            config_dir: &config_dir_for_bundle,
641            content_dir: &content_dir_for_bundle,
642        }) {
643            Ok(result) => {
644                if generated_preloads_for_bundle
645                    .set(result.preloads.clone())
646                    .is_err()
647                {
648                    let message = "Generated preload metadata was published twice".to_string();
649                    projection_completer.complete(Err(message.clone()));
650                    return Err(Error::Build(message));
651                }
652                projection_completer.complete(Ok(result.projection.clone()));
653                Ok(result)
654            }
655            Err(error) => {
656                projection_completer.complete(Err(error.to_string()));
657                Err(error)
658            }
659        }
660    });
661
662    // Per-page build + render + write in parallel.
663    //
664    // Each page is its own complete app: we substitute `{{{page.content}}}`
665    // in the template with the page's actual HTML (including any custom
666    // elements like <code-block>) and run `webui::build()` on the result.
667    // This means the build pipeline naturally discovers the components used
668    // on this page, expands their declarative shadow DOM, and emits their
669    // template metadata to `window.__webui.templates` for client-side
670    // hydration — no manual registration tricks required.
671    let total_bytes = std::sync::atomic::AtomicUsize::new(0);
672    let component_css: std::sync::Mutex<HashMap<String, String>> =
673        std::sync::Mutex::new(HashMap::new());
674
675    let page_start = Instant::now();
676    pages.par_iter_mut().try_for_each(|page| -> Result<()> {
677        let page_dir = site_dir.join(page.path.strip_prefix(base_path).unwrap_or(&page.path));
678        let target = page_dir.join("index.html");
679
680        // Use modified content (with bundled script tags removed) if scripts were extracted.
681        let content = page_contents_with_scripts
682            .get(&page.path)
683            .map(|s| s.as_str())
684            .unwrap_or_else(|| page.state["page"]["content"].as_str().unwrap_or(""));
685
686        // Protect markdown code blocks before injecting content into the
687        // region-resolved template.
688        let (protected, pre_blocks) = protect_pre_blocks(content);
689        let page_html = regions
690            .render(region_layout(page))
691            .replace("{{{page.content}}}", &protected);
692
693        // Per-page temp dir holding only this page's index.html — components
694        // come exclusively from `component_sources`, which already includes
695        // the template dir (for docs-search/docs-theme-toggle) plus any
696        // configured component libraries.
697        //
698        // Name includes pid + per-rebuild nonce so two parallel page
699        // builds (and successive rebuilds) can never collide and wipe
700        // each other's in-progress files.
701        let nonce = REBUILD_NONCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
702        let page_tmp = scratch_root.join(format!(
703            "webui-press-page-{}-{:x}-{nonce:x}",
704            std::process::id(),
705            fxhash(&page.path),
706        ));
707        if page_tmp.exists() {
708            fs::remove_dir_all(&page_tmp).ok();
709        }
710        fs::create_dir_all(&page_tmp)
711            .map_err(|e| Error::Io(format!("Cannot create page temp: {e}")))?;
712        fs::write(page_tmp.join("index.html"), &page_html)
713            .map_err(|e| Error::Io(format!("Cannot write page temp: {e}")))?;
714
715        let mut build_result = webui::build(BuildOptions {
716            app_dir: page_tmp.clone(),
717            entry: "index.html".to_string(),
718            plugin: Some(webui::Plugin::WebUI),
719            components: component_sources.clone(),
720            theme: token_file.clone(),
721            projection_manifests: vec![projection_source.clone()],
722            ..BuildOptions::default()
723        })
724        .map_err(|e| Error::Build(format!("{}: {}", page.path, e.chain_message())))?;
725        let preloads = generated_preloads.get().ok_or_else(|| {
726            Error::Build("Generated preload metadata was not published".to_string())
727        })?;
728        let (hrefs, mut preload_warnings) = preloads.resolve(
729            &build_result.protocol.module_preloads,
730            page_bundle_ids.get(&page.path).copied(),
731        );
732        build_result.protocol.module_preloads = hrefs;
733        build_result.warnings.append(&mut preload_warnings);
734
735        total_bytes.fetch_add(
736            build_result.protocol_bytes.len(),
737            std::sync::atomic::Ordering::Relaxed,
738        );
739
740        // Collect per-component CSS files (Link strategy emits one .css
741        // per component used on the page). Multiple pages share components,
742        // so we dedupe by filename in a shared map and write once after
743        // the parallel pass completes.
744        if !build_result.css_files.is_empty() {
745            let mut css_map = component_css
746                .lock()
747                .unwrap_or_else(std::sync::PoisonError::into_inner);
748            for (name, content) in build_result.css_files {
749                css_map.entry(name).or_insert(content);
750            }
751        }
752
753        let layout = region_layout(page).to_string();
754        regions.apply_state(&layout, &mut page.state)?;
755        if let Some(token_file) = token_file.as_ref() {
756            inject_theme_tokens(&mut page.state, token_file, &build_result.protocol.tokens)?;
757        }
758
759        let protocol = Protocol::new(build_result.protocol);
760        let mut writer = StringWriter::with_capacity(8192);
761        handler
762            .render(
763                &protocol,
764                &page.state,
765                &RenderOptions::new("index.html", &page.path),
766                &mut writer,
767            )
768            .map_err(|e| Error::Render(format!("{}: {e}", page.path)))?;
769
770        // Restore the protected <pre> blocks via a single-pass scan.
771        let html = restore_pre_blocks(&writer.buf, &pre_blocks);
772
773        // Write directly inside the parallel closure.
774        fs::write(&target, &html)
775            .map_err(|e| Error::Io(format!("Cannot write {}: {e}", page.path)))?;
776
777        fs::remove_dir_all(&page_tmp).ok();
778        Ok(())
779    })?;
780
781    print_success(
782        cache,
783        &format!(
784            "Rendered {} pages in {:.0}ms",
785            pages.len(),
786            page_start.elapsed().as_secs_f64() * 1000.0
787        ),
788    );
789    let bundle_result = bundle_thread.join()?;
790    print_bundle_summary(cache, &bundle_result);
791
792    // Step 4: Search index. Strip rendered HTML to plain text and emit
793    // an entry per non-home page. Done from-scratch every build.
794    let search_path = site_dir.join("search-index.json");
795    let search_entries: Vec<Value> = pages
796        .iter()
797        .filter(|p| !p.is_home)
798        .map(|p| {
799            let html = p.state["page"]["content"].as_str().unwrap_or("");
800            let title = p.state["page"]["title"].as_str().unwrap_or("");
801            build_search_entry(title, &p.path, html)
802        })
803        .collect();
804    fs::write(
805        &search_path,
806        serde_json::to_string(&search_entries)
807            .map_err(|e| Error::Build(format!("JSON error: {e}")))?,
808    )
809    .map_err(|e| Error::Io(e.to_string()))?;
810    print_success(
811        cache,
812        &format!("Indexed {} pages for search", search_entries.len()),
813    );
814
815    // Step 5: Copy static public assets.
816    let public_dir = Path::new(&config.public_dir);
817    if public_dir.exists() {
818        copy_dir(public_dir, &site_dir)?;
819    }
820
821    // Step 6: Generate 404 page
822    let nav_val = pages
823        .first()
824        .map(|p| p.state["navigation"].clone())
825        .unwrap_or_default();
826
827    let footer_val = config
828        .footer
829        .as_ref()
830        .map(|f| json_obj([("html", Value::String(f.html.clone()))]))
831        .unwrap_or(Value::Null);
832
833    let mut not_found_state = build_not_found_state(NotFoundStateInput {
834        config,
835        base_path,
836        nav: nav_val,
837        footer: footer_val,
838        content: &not_found_content,
839        head_injection: &head_injection,
840        global_state: render_states.global(),
841    });
842    regions.apply_state("doc", &mut not_found_state)?;
843
844    let (protected_not_found, not_found_pre_blocks) = protect_pre_blocks(&not_found_content);
845    let not_found_html = regions
846        .render("doc")
847        .replace("{{{page.content}}}", &protected_not_found);
848    let nf_tmp = scratch_root.join(format!(
849        "webui-press-404-{}-{:x}",
850        std::process::id(),
851        REBUILD_NONCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
852    ));
853    if nf_tmp.exists() {
854        fs::remove_dir_all(&nf_tmp).ok();
855    }
856    fs::create_dir_all(&nf_tmp).map_err(|e| Error::Io(e.to_string()))?;
857    fs::write(nf_tmp.join("index.html"), &not_found_html).map_err(|e| Error::Io(e.to_string()))?;
858
859    let mut nf_build = webui::build(BuildOptions {
860        app_dir: nf_tmp.clone(),
861        entry: "index.html".to_string(),
862        plugin: Some(webui::Plugin::WebUI),
863        components: component_sources.clone(),
864        theme: token_file.clone(),
865        projection_manifests: vec![projection_source],
866        ..BuildOptions::default()
867    })
868    .map_err(|e| Error::Build(format!("404 build failed: {}", e.chain_message())))?;
869    let preloads = generated_preloads
870        .get()
871        .ok_or_else(|| Error::Build("Generated preload metadata was not published".to_string()))?;
872    let (hrefs, mut preload_warnings) =
873        preloads.resolve(&nf_build.protocol.module_preloads, not_found_bundle_id);
874    nf_build.protocol.module_preloads = hrefs;
875    nf_build.warnings.append(&mut preload_warnings);
876
877    if let Some(token_file) = token_file.as_ref() {
878        inject_theme_tokens(&mut not_found_state, token_file, &nf_build.protocol.tokens)?;
879    }
880
881    // Fold the 404 page's component CSS into the shared map, then write
882    // all per-component stylesheets at site root in one pass. The handler
883    // emits relative hrefs like `<link rel="stylesheet" href="code-block.css">`;
884    // the template's <base href="{site.base}"> resolves them against site root.
885    {
886        let mut css_map = component_css
887            .lock()
888            .unwrap_or_else(std::sync::PoisonError::into_inner);
889        for (name, content) in &nf_build.css_files {
890            css_map
891                .entry(name.clone())
892                .or_insert_with(|| content.clone());
893        }
894    }
895
896    let nf_protocol = Protocol::new(nf_build.protocol);
897    let mut writer_404 = StringWriter::with_capacity(4096);
898    handler
899        .render(
900            &nf_protocol,
901            &not_found_state,
902            &RenderOptions::new("index.html", &format!("{base_path}404/")),
903            &mut writer_404,
904        )
905        .map_err(|e| Error::Render(format!("404: {e}")))?;
906
907    let not_found_output = restore_pre_blocks(&writer_404.buf, &not_found_pre_blocks);
908    fs::write(site_dir.join("404.html"), not_found_output).map_err(|e| Error::Io(e.to_string()))?;
909    fs::remove_dir_all(&nf_tmp).ok();
910    print_success(cache, "Generated 404 page");
911
912    // Step 7: Write per-component stylesheets collected during the
913    // parallel page builds (and the 404 build above). Sort by name for
914    // deterministic output.
915    let css_map_local = component_css
916        .into_inner()
917        .unwrap_or_else(std::sync::PoisonError::into_inner);
918    let mut css_names: Vec<&String> = css_map_local.keys().collect();
919    css_names.sort();
920    let css_count = css_names.len();
921    for name in css_names {
922        fs::write(site_dir.join(name), &css_map_local[name])
923            .map_err(|e| Error::Io(format!("Cannot write {name}: {e}")))?;
924    }
925    if css_count > 0 {
926        print_success(cache, &format!("Wrote {css_count} component stylesheets"));
927    }
928
929    // Step 8: Inject page script <script> tags into rendered pages.
930    if bundle_result.root_script.is_some() || !bundle_result.script_map.is_empty() {
931        let mut linked_count = 0usize;
932        for page in &pages {
933            let page_rel_paths = if let Some(bundle_id) = page_bundle_ids.get(&page.path) {
934                let rel_paths = bundle_result.script_map.get(bundle_id).ok_or_else(|| {
935                    Error::Build(format!(
936                        "Missing bundled script for page {} entry {}",
937                        page.path, bundle_id
938                    ))
939                })?;
940                if rel_paths.is_empty() {
941                    None
942                } else {
943                    Some(rel_paths)
944                }
945            } else {
946                None
947            };
948            if bundle_result.root_script.is_none() && page_rel_paths.is_none() {
949                continue;
950            }
951            let page_dir = site_dir.join(page.path.strip_prefix(base_path).unwrap_or(&page.path));
952            let target = page_dir.join("index.html");
953            let mut html = fs::read_to_string(&target)
954                .map_err(|e| Error::Io(format!("Cannot read {}: {e}", target.display())))?;
955
956            if let Some(rel_path) = bundle_result.root_script.as_ref() {
957                let tag = module_script_tag(base_path, rel_path);
958                inject_script_tag(&mut html, &tag);
959                linked_count += 1;
960            }
961            if let Some(rel_paths) = page_rel_paths {
962                linked_count += inject_module_script_tags(&mut html, base_path, rel_paths);
963            }
964
965            fs::write(&target, &html)
966                .map_err(|e| Error::Io(format!("Cannot rewrite {}: {e}", page.path)))?;
967        }
968        if let Some(bundle_id) = not_found_bundle_id {
969            let rel_paths = bundle_result.script_map.get(&bundle_id).ok_or_else(|| {
970                Error::Build(format!(
971                    "Missing bundled script for 404 page entry {bundle_id}"
972                ))
973            })?;
974            let target = site_dir.join("404.html");
975            let mut html = fs::read_to_string(&target)
976                .map_err(|e| Error::Io(format!("Cannot read {}: {e}", target.display())))?;
977            if let Some(rel_path) = bundle_result.root_script.as_ref() {
978                let tag = module_script_tag(base_path, rel_path);
979                inject_script_tag(&mut html, &tag);
980                linked_count += 1;
981            }
982            linked_count += inject_module_script_tags(&mut html, base_path, rel_paths);
983            fs::write(&target, &html)
984                .map_err(|e| Error::Io(format!("Cannot rewrite 404.html: {e}")))?;
985        } else if let Some(rel_path) = bundle_result.root_script.as_ref() {
986            let target = site_dir.join("404.html");
987            let mut html = fs::read_to_string(&target)
988                .map_err(|e| Error::Io(format!("Cannot read {}: {e}", target.display())))?;
989            let tag = module_script_tag(base_path, rel_path);
990            inject_script_tag(&mut html, &tag);
991            fs::write(&target, &html)
992                .map_err(|e| Error::Io(format!("Cannot rewrite 404.html: {e}")))?;
993            linked_count += 1;
994        }
995        if linked_count > 0 {
996            print_success(
997                cache,
998                &format!(
999                    "Linked {} script tag{}",
1000                    linked_count,
1001                    if linked_count == 1 { "" } else { "s" }
1002                ),
1003            );
1004        }
1005    }
1006
1007    let elapsed = start.elapsed();
1008    let total_bytes = total_bytes.load(std::sync::atomic::Ordering::Relaxed);
1009    print_finish(cache, &format!("Built in {:.1}s", elapsed.as_secs_f64()));
1010
1011    Ok(BuildStats {
1012        pages: pages.len(),
1013        protocol_bytes: total_bytes,
1014    })
1015}
1016
1017/// Recursively remove a directory, retrying briefly on `ENOTEMPTY`.
1018///
1019/// On macOS (and occasionally Linux), `fs::remove_dir_all` can fail
1020/// with `Directory not empty` when something writes a file into a
1021/// subdirectory between the moment we walked it and the moment we try
1022/// to `rmdir` it. Likely culprits during a dev-server rebuild:
1023///
1024///  - Spotlight / `mds` writing index hints,
1025///  - Finder dropping a `.DS_Store`,
1026///  - the user's editor reindexing the project,
1027///  - a back-to-back save firing another rebuild before the first one
1028///    finished cleanup (the worker coalesces, but a stray write into
1029///    `out_dir` from a parallel `cargo` or another tool is still
1030///    possible).
1031///
1032/// We retry up to a handful of times with short backoff. After that the
1033/// error propagates so the user sees a real failure rather than an
1034/// infinite loop.
1035fn remove_dir_all_retry(path: &Path) -> std::io::Result<()> {
1036    const MAX_ATTEMPTS: u32 = 5;
1037    let mut delay = std::time::Duration::from_millis(20);
1038    for attempt in 1..=MAX_ATTEMPTS {
1039        match fs::remove_dir_all(path) {
1040            Ok(()) => return Ok(()),
1041            Err(e) if attempt < MAX_ATTEMPTS && is_dir_not_empty(&e) => {
1042                std::thread::sleep(delay);
1043                delay = delay.saturating_mul(2);
1044            }
1045            Err(e) => return Err(e),
1046        }
1047    }
1048    // Loop guarantees a return on each iteration; this is unreachable
1049    // but `for` returning `()` makes the type-checker happy without it.
1050    Ok(())
1051}
1052
1053/// Returns true if the error is `ENOTEMPTY` / `EEXIST` style "directory
1054/// not empty". On macOS this is errno 66; on Linux it's 39. We also
1055/// match by `ErrorKind` once Rust exposes one (currently `Other`).
1056fn is_dir_not_empty(e: &std::io::Error) -> bool {
1057    match e.raw_os_error() {
1058        Some(66) => true,  // macOS / BSD: ENOTEMPTY
1059        Some(39) => true,  // Linux: ENOTEMPTY
1060        Some(145) => true, // Windows: ERROR_DIR_NOT_EMPTY
1061        _ => false,
1062    }
1063}
1064
1065/// Iteratively copy a directory tree (BFS via stack — no recursion).
1066fn copy_dir(src: &Path, dest: &Path) -> Result<()> {
1067    if !src.exists() {
1068        return Ok(());
1069    }
1070    fs::create_dir_all(dest).map_err(|e| Error::Io(e.to_string()))?;
1071    let mut stack: Vec<(std::path::PathBuf, std::path::PathBuf)> =
1072        vec![(src.to_path_buf(), dest.to_path_buf())];
1073    while let Some((s, d)) = stack.pop() {
1074        for entry in fs::read_dir(&s).map_err(|e| Error::Io(e.to_string()))? {
1075            let entry = entry.map_err(|e| Error::Io(e.to_string()))?;
1076            let dest_path = d.join(entry.file_name());
1077            let ft = entry.file_type().map_err(|e| Error::Io(e.to_string()))?;
1078            if ft.is_dir() {
1079                fs::create_dir_all(&dest_path).map_err(|e| Error::Io(e.to_string()))?;
1080                stack.push((entry.path(), dest_path));
1081            } else {
1082                fs::copy(entry.path(), &dest_path).map_err(|e| Error::Io(e.to_string()))?;
1083            }
1084        }
1085    }
1086    Ok(())
1087}
1088
1089/// Truncate a string to at most `max` bytes at a valid UTF-8 boundary.
1090pub(crate) fn truncate_utf8(s: &str, max: usize) -> &str {
1091    if s.len() <= max {
1092        return s;
1093    }
1094    let mut end = max;
1095    while end > 0 && !s.is_char_boundary(end) {
1096        end -= 1;
1097    }
1098    &s[..end]
1099}
1100
1101#[derive(Default)]
1102struct SearchText {
1103    content: String,
1104    headings: Vec<SearchHeading>,
1105}
1106
1107/// Heading metadata emitted into `search-index.json`.
1108///
1109/// The client search UI uses this to create separate, anchor-linked rows for
1110/// matching sections (`Page > Heading`) without re-parsing page HTML at runtime.
1111struct SearchHeading {
1112    text: String,
1113    anchor: String,
1114    level: u8,
1115}
1116
1117/// Mutable heading capture state while scanning rendered HTML.
1118struct CurrentHeading {
1119    text: String,
1120    anchor: String,
1121    level: u8,
1122}
1123
1124struct HtmlTag<'a> {
1125    name: &'a str,
1126    is_end: bool,
1127}
1128
1129/// Build a single search-index entry for a page. Strips HTML tags,
1130/// decodes escaped text, captures headings for weighted ranking,
1131/// collapses whitespace, and truncates body content to 500 bytes.
1132pub(crate) fn build_search_entry(title: &str, path: &str, html: &str) -> Value {
1133    let search_text = extract_search_text(html);
1134    let content = normalize_search_text(&search_text.content);
1135    let truncated = truncate_utf8(&content, 500);
1136    let headings: Vec<Value> = search_text
1137        .headings
1138        .into_iter()
1139        .map(|heading| {
1140            json_obj([
1141                ("text", Value::String(heading.text)),
1142                ("anchor", Value::String(heading.anchor)),
1143                (
1144                    "level",
1145                    Value::Number(serde_json::Number::from(u64::from(heading.level))),
1146                ),
1147            ])
1148        })
1149        .collect();
1150    json_obj([
1151        ("title", Value::String(title.to_string())),
1152        ("path", Value::String(path.to_string())),
1153        ("content", Value::String(truncated.to_string())),
1154        ("headings", Value::Array(headings)),
1155    ])
1156}
1157
1158fn extract_search_text(html: &str) -> SearchText {
1159    let mut text = SearchText {
1160        content: String::with_capacity(html.len() / 2),
1161        headings: Vec::with_capacity(8),
1162    };
1163    let mut current_heading: Option<CurrentHeading> = None;
1164    let mut skip_header_anchor = 0usize;
1165    let mut cursor = 0;
1166
1167    while cursor < html.len() {
1168        let remaining = &html[cursor..];
1169        let Some(tag_offset) = remaining.find('<') else {
1170            push_search_text(
1171                &mut text,
1172                current_heading.as_mut(),
1173                skip_header_anchor,
1174                remaining,
1175            );
1176            break;
1177        };
1178        let text_end = cursor + tag_offset;
1179        push_search_text(
1180            &mut text,
1181            current_heading.as_mut(),
1182            skip_header_anchor,
1183            &html[cursor..text_end],
1184        );
1185        let tag_start = text_end + 1;
1186        let Some(tag_len) = html[tag_start..].find('>') else {
1187            push_search_text(
1188                &mut text,
1189                current_heading.as_mut(),
1190                skip_header_anchor,
1191                &html[text_end..],
1192            );
1193            break;
1194        };
1195        let tag_end = tag_start + tag_len;
1196        handle_search_tag(
1197            &html[tag_start..tag_end],
1198            &mut text,
1199            &mut current_heading,
1200            &mut skip_header_anchor,
1201        );
1202        cursor = tag_end + 1;
1203    }
1204
1205    text
1206}
1207
1208fn handle_search_tag(
1209    raw_tag: &str,
1210    text: &mut SearchText,
1211    current_heading: &mut Option<CurrentHeading>,
1212    skip_header_anchor: &mut usize,
1213) {
1214    if let Some(tag) = parse_html_tag(raw_tag) {
1215        if tag.is_end {
1216            handle_end_tag(tag.name, text, current_heading, skip_header_anchor);
1217        } else {
1218            handle_start_tag(tag.name, raw_tag, current_heading, skip_header_anchor);
1219        }
1220    }
1221    push_search_space(text, current_heading.as_mut(), *skip_header_anchor);
1222}
1223
1224fn handle_start_tag(
1225    name: &str,
1226    raw_tag: &str,
1227    current_heading: &mut Option<CurrentHeading>,
1228    skip_header_anchor: &mut usize,
1229) {
1230    if let Some(level) = heading_level(name) {
1231        *current_heading = Some(CurrentHeading {
1232            text: String::with_capacity(64),
1233            anchor: attr_value(raw_tag, "id").unwrap_or("").to_string(),
1234            level,
1235        });
1236    } else if name.eq_ignore_ascii_case("a") && raw_tag.contains("header-anchor") {
1237        *skip_header_anchor += 1;
1238    }
1239}
1240
1241fn handle_end_tag(
1242    name: &str,
1243    text: &mut SearchText,
1244    current_heading: &mut Option<CurrentHeading>,
1245    skip_header_anchor: &mut usize,
1246) {
1247    if name.eq_ignore_ascii_case("a") && *skip_header_anchor > 0 {
1248        *skip_header_anchor -= 1;
1249    }
1250    if heading_level(name).is_some() {
1251        if let Some(raw_heading) = current_heading.take() {
1252            let heading_text = normalize_search_text(&raw_heading.text);
1253            if !heading_text.is_empty() {
1254                text.headings.push(SearchHeading {
1255                    text: heading_text,
1256                    anchor: raw_heading.anchor,
1257                    level: raw_heading.level,
1258                });
1259            }
1260        }
1261    }
1262}
1263
1264fn parse_html_tag(raw: &str) -> Option<HtmlTag<'_>> {
1265    let mut tag = raw.trim_start();
1266    if tag.starts_with('!') || tag.starts_with('?') {
1267        return None;
1268    }
1269    let is_end = tag.starts_with('/');
1270    if is_end {
1271        tag = tag[1..].trim_start();
1272    }
1273
1274    let mut name_end = 0;
1275    for (idx, byte) in tag.bytes().enumerate() {
1276        if byte.is_ascii_alphanumeric() || byte == b'-' {
1277            name_end = idx + 1;
1278        } else {
1279            break;
1280        }
1281    }
1282    if name_end == 0 {
1283        return None;
1284    }
1285    Some(HtmlTag {
1286        name: &tag[..name_end],
1287        is_end,
1288    })
1289}
1290
1291/// Return a heading level for `h1` through `h6`.
1292///
1293/// The scanner works on tag names from already-rendered markdown, so an ASCII
1294/// byte check is sufficient and avoids allocating a lowercased copy.
1295fn heading_level(name: &str) -> Option<u8> {
1296    let bytes = name.as_bytes();
1297    if bytes.len() == 2 && bytes[0].eq_ignore_ascii_case(&b'h') && (b'1'..=b'6').contains(&bytes[1])
1298    {
1299        Some(bytes[1] - b'0')
1300    } else {
1301        None
1302    }
1303}
1304
1305/// Extract an HTML attribute value from a rendered start tag without a DOM
1306/// parser.
1307///
1308/// Search indexing runs once per page at build time, but it still avoids regex
1309/// and allocation-heavy parsing. This helper handles quoted and unquoted values
1310/// because heading IDs are generated as ordinary HTML attributes.
1311fn attr_value<'a>(raw_tag: &'a str, attr_name: &str) -> Option<&'a str> {
1312    let bytes = raw_tag.as_bytes();
1313    let mut cursor = 0;
1314
1315    while cursor < bytes.len() {
1316        while bytes.get(cursor).is_some_and(u8::is_ascii_whitespace) {
1317            cursor += 1;
1318        }
1319
1320        let name_start = cursor;
1321        while bytes.get(cursor).is_some_and(|b| is_attr_name_byte(*b)) {
1322            cursor += 1;
1323        }
1324        if name_start == cursor {
1325            cursor += 1;
1326            continue;
1327        }
1328        let name = &raw_tag[name_start..cursor];
1329
1330        while bytes.get(cursor).is_some_and(u8::is_ascii_whitespace) {
1331            cursor += 1;
1332        }
1333        if bytes.get(cursor) != Some(&b'=') {
1334            continue;
1335        }
1336        cursor += 1;
1337        while bytes.get(cursor).is_some_and(u8::is_ascii_whitespace) {
1338            cursor += 1;
1339        }
1340
1341        let (value_start, value_end) = attr_value_range(raw_tag, cursor);
1342        if name.eq_ignore_ascii_case(attr_name) {
1343            return Some(&raw_tag[value_start..value_end]);
1344        }
1345        cursor = value_end.saturating_add(1);
1346    }
1347
1348    None
1349}
1350
1351fn is_attr_name_byte(byte: u8) -> bool {
1352    byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b':')
1353}
1354
1355fn attr_value_range(raw_tag: &str, cursor: usize) -> (usize, usize) {
1356    let bytes = raw_tag.as_bytes();
1357    match bytes.get(cursor).copied() {
1358        Some(b'"' | b'\'') => {
1359            let quote = bytes[cursor];
1360            let value_start = cursor + 1;
1361            let value_end = raw_tag[value_start..]
1362                .find(char::from(quote))
1363                .map_or(raw_tag.len(), |offset| value_start + offset);
1364            (value_start, value_end)
1365        }
1366        Some(_) => {
1367            let value_start = cursor;
1368            let value_end = raw_tag[value_start..]
1369                .find(char::is_whitespace)
1370                .map_or(raw_tag.len(), |offset| value_start + offset);
1371            (value_start, value_end)
1372        }
1373        None => (raw_tag.len(), raw_tag.len()),
1374    }
1375}
1376
1377fn push_search_text(
1378    text: &mut SearchText,
1379    current_heading: Option<&mut CurrentHeading>,
1380    skip_header_anchor: usize,
1381    value: &str,
1382) {
1383    if skip_header_anchor > 0 {
1384        return;
1385    }
1386    text.content.push_str(value);
1387    if let Some(heading) = current_heading {
1388        heading.text.push_str(value);
1389    }
1390}
1391
1392fn push_search_space(
1393    text: &mut SearchText,
1394    current_heading: Option<&mut CurrentHeading>,
1395    skip_header_anchor: usize,
1396) {
1397    if skip_header_anchor > 0 {
1398        return;
1399    }
1400    text.content.push(' ');
1401    if let Some(heading) = current_heading {
1402        heading.text.push(' ');
1403    }
1404}
1405
1406fn normalize_search_text(raw: &str) -> String {
1407    let decoded = html_escape::decode_html_entities(raw);
1408    let mut normalized = String::with_capacity(decoded.len());
1409    let mut first = true;
1410    for token in decoded.split_whitespace() {
1411        if !first {
1412            normalized.push(' ');
1413        }
1414        normalized.push_str(token);
1415        first = false;
1416    }
1417    normalized
1418}
1419
1420const PRE_BLOCK_MARKER_PREFIX: &str = "<span data-webui-press-pre-block=\"";
1421const PRE_BLOCK_MARKER_SUFFIX: &str = "\"></span>";
1422
1423/// Replace `<pre …>…</pre>` blocks with placeholder elements so the WebUI
1424/// HTML parser does not normalize whitespace inside them. Returns the
1425/// modified string and the original blocks (in order) for restoration
1426/// after rendering.
1427fn protect_pre_blocks(content: &str) -> (String, Vec<String>) {
1428    use std::fmt::Write as _;
1429    let mut blocks: Vec<String> = Vec::new();
1430    let mut out = String::with_capacity(content.len());
1431    let mut cursor = 0;
1432    while let Some(rel_start) = find_pre_open(&content[cursor..]) {
1433        let start = cursor + rel_start;
1434        if let Some(rel_end) = content[start..].find("</pre>") {
1435            let end = start + rel_end + "</pre>".len();
1436            out.push_str(&content[cursor..start]);
1437            out.push_str(PRE_BLOCK_MARKER_PREFIX);
1438            // write! into existing buffer — avoids `format!` allocation per block.
1439            let _ = write!(&mut out, "{}", blocks.len());
1440            out.push_str(PRE_BLOCK_MARKER_SUFFIX);
1441            blocks.push(content[start..end].to_string());
1442            cursor = end;
1443        } else {
1444            break;
1445        }
1446    }
1447    out.push_str(&content[cursor..]);
1448    (out, blocks)
1449}
1450
1451/// Find the next opening `<pre` tag where the next byte is one of `>`, ` `,
1452/// `\t`, `\n`, or `\r`. Avoids matching `<presentation`, `<pretend`, etc.
1453fn find_pre_open(s: &str) -> Option<usize> {
1454    let bytes = s.as_bytes();
1455    let mut i = 0;
1456    while let Some(rel) = s[i..].find("<pre") {
1457        let pos = i + rel;
1458        let after = pos + 4;
1459        match bytes.get(after) {
1460            Some(b'>' | b' ' | b'\t' | b'\n' | b'\r') => return Some(pos),
1461            _ => i = after,
1462        }
1463    }
1464    None
1465}
1466
1467/// Single-pass restoration of pre-block placeholder elements to their
1468/// original content. Faster than calling `String::replace` once per block.
1469fn restore_pre_blocks(html: &str, blocks: &[String]) -> String {
1470    if blocks.is_empty() {
1471        return html.to_string();
1472    }
1473    let extra: usize = blocks.iter().map(|b| b.len()).sum();
1474    let mut out = String::with_capacity(html.len() + extra);
1475    let mut cursor = 0;
1476    while let Some(rel) = html[cursor..].find(PRE_BLOCK_MARKER_PREFIX) {
1477        let p = cursor + rel;
1478        out.push_str(&html[cursor..p]);
1479        let after = p + PRE_BLOCK_MARKER_PREFIX.len();
1480        if let Some(end_rel) = html[after..].find(PRE_BLOCK_MARKER_SUFFIX) {
1481            let num_str = &html[after..after + end_rel];
1482            if let Ok(idx) = num_str.parse::<usize>() {
1483                if let Some(block) = blocks.get(idx) {
1484                    out.push_str(block);
1485                    cursor = after + end_rel + PRE_BLOCK_MARKER_SUFFIX.len();
1486                    continue;
1487                }
1488            }
1489            // Unknown placeholder — keep verbatim.
1490            out.push_str(&html[p..after + end_rel + PRE_BLOCK_MARKER_SUFFIX.len()]);
1491            cursor = after + end_rel + PRE_BLOCK_MARKER_SUFFIX.len();
1492        } else {
1493            out.push_str(&html[p..]);
1494            return out;
1495        }
1496    }
1497    out.push_str(&html[cursor..]);
1498    out
1499}
1500
1501/// Cheap deterministic hash for building unique temp directory names.
1502fn fxhash(s: &str) -> u64 {
1503    fxhash_bytes(s.as_bytes())
1504}
1505
1506fn fxhash_bytes(bytes: &[u8]) -> u64 {
1507    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
1508    for &b in bytes {
1509        hash ^= u64::from(b);
1510        hash = hash.wrapping_mul(0x100_0000_01b3);
1511    }
1512    hash
1513}
1514
1515#[cfg(test)]
1516mod tests {
1517    use super::*;
1518
1519    type TestResult<T = ()> = std::result::Result<T, Box<dyn std::error::Error>>;
1520
1521    #[test]
1522    fn early_build_failure_does_not_recheck_versions_from_a_previous_build() -> TestResult {
1523        let root = tempfile::tempdir()?;
1524        let file = root.path().join("state.json");
1525        fs::write(&file, r#"{"count":1}"#)?;
1526        let mut loader = crate::state::StateLoader::new();
1527        loader.load_markdown_state(Some("state.json"), None, &root.path().join("page.md"))?;
1528        let mut cache = BuildCache {
1529            dependencies: loader.into_dependencies(),
1530            ..BuildCache::default()
1531        };
1532        fs::write(&file, r#"{"count":2}"#)?;
1533        assert_eq!(cache.dependencies.changed_paths(), cache.dependencies.paths);
1534        let mut config = docs_config()?;
1535        config.state = Some(Value::Null);
1536        config.out_dir = root.path().join("output").to_string_lossy().into_owned();
1537        assert!(build_docs_with_cache(&config, root.path(), root.path(), &mut cache).is_err());
1538        assert!(cache.dependencies.changed_paths().is_empty());
1539        assert_eq!(cache.dependencies.paths, vec![fs::canonicalize(file)?]);
1540        Ok(())
1541    }
1542
1543    fn test_obj<const N: usize>(entries: [(&str, Value); N]) -> Value {
1544        let mut map = Map::with_capacity(N);
1545        for (key, value) in entries {
1546            map.insert(key.to_string(), value);
1547        }
1548        Value::Object(map)
1549    }
1550
1551    fn string_value(value: &str) -> Value {
1552        Value::String(value.to_string())
1553    }
1554
1555    fn docs_config() -> TestResult<DocsConfig> {
1556        let config = serde_json::from_str(
1557            r#"{
1558                "site": { "title": "Docs" },
1559                "basePath": "/",
1560                "contentDir": ".",
1561                "nav": [],
1562                "sidebar": []
1563            }"#,
1564        )?;
1565        Ok(config)
1566    }
1567
1568    #[test]
1569    fn not_found_state_includes_global_state() -> TestResult {
1570        let config = docs_config()?;
1571        let global = test_obj([("release", test_obj([("version", string_value("v1.2.3"))]))]);
1572
1573        let state = build_not_found_state(NotFoundStateInput {
1574            config: &config,
1575            base_path: "/",
1576            nav: Value::Array(vec![]),
1577            footer: Value::Null,
1578            content: "<h1>404</h1>",
1579            head_injection: "<meta name=\"docs\">",
1580            global_state: Some(&global),
1581        });
1582
1583        assert_eq!(state["release"]["version"], "v1.2.3");
1584        assert_eq!(state["page"]["title"], "Page Not Found");
1585        assert_eq!(state["pageData"], Value::Null);
1586        assert_eq!(state["headTags"], "<meta name=\"docs\">");
1587        Ok(())
1588    }
1589
1590    #[test]
1591    fn custom_home_layout_uses_non_home_regions() {
1592        let state = test_obj([("page", test_obj([("layout", string_value("home"))]))]);
1593        let custom_page = PageDescriptor {
1594            path: "/custom/".to_string(),
1595            is_home: false,
1596            state: state.clone(),
1597        };
1598        let home_page = PageDescriptor {
1599            path: "/".to_string(),
1600            is_home: true,
1601            state,
1602        };
1603
1604        assert_eq!(region_layout(&custom_page), "doc");
1605        assert_eq!(region_layout(&home_page), "home");
1606    }
1607
1608    // --- truncate_utf8 ---------------------------------------------------
1609
1610    #[test]
1611    fn truncate_utf8_short_string_unchanged() {
1612        assert_eq!(truncate_utf8("hello", 100), "hello");
1613    }
1614
1615    #[test]
1616    fn truncate_utf8_ascii_at_exact_boundary() {
1617        assert_eq!(truncate_utf8("hello world", 5), "hello");
1618    }
1619
1620    #[test]
1621    fn truncate_utf8_steps_back_off_multibyte_boundary() {
1622        // "é" is two bytes (0xC3 0xA9). Cutting at 1 byte must step back to 0.
1623        let s = "é-suffix";
1624        let out = truncate_utf8(s, 1);
1625        assert!(s.is_char_boundary(out.len()), "out.len()={}", out.len());
1626        assert_eq!(out, "");
1627    }
1628
1629    #[test]
1630    fn truncate_utf8_keeps_multibyte_when_room_allows() {
1631        let s = "café";
1632        let out = truncate_utf8(s, 5); // "café" is 5 bytes total
1633        assert_eq!(out, "café");
1634    }
1635
1636    // --- protect_pre_blocks / find_pre_open ------------------------------
1637
1638    #[test]
1639    fn protect_pre_blocks_with_attrs() {
1640        let input = r#"<p>before</p><pre class="hljs">code</pre><p>after</p>"#;
1641        let (out, blocks) = protect_pre_blocks(input);
1642        assert_eq!(blocks.len(), 1);
1643        assert!(out.contains(r#"<span data-webui-press-pre-block="0"></span>"#));
1644        assert!(!out.contains("<pre"));
1645        assert_eq!(blocks[0], r#"<pre class="hljs">code</pre>"#);
1646    }
1647
1648    #[test]
1649    fn protect_pre_blocks_bare_open_tag() {
1650        // Was previously missed because we only matched "<pre " (with space).
1651        let input = "<pre>code</pre>";
1652        let (out, blocks) = protect_pre_blocks(input);
1653        assert_eq!(blocks.len(), 1, "got out={out:?} blocks={blocks:?}");
1654        assert_eq!(blocks[0], "<pre>code</pre>");
1655    }
1656
1657    #[test]
1658    fn protect_pre_blocks_does_not_match_presentation_tag() {
1659        // <presentation> must NOT be treated as a pre block.
1660        let input = "<presentation>x</presentation>";
1661        let (_out, blocks) = protect_pre_blocks(input);
1662        assert_eq!(blocks.len(), 0);
1663    }
1664
1665    #[test]
1666    fn protect_then_restore_round_trip() {
1667        let input = "before <pre>a</pre> mid <pre class=\"x\">b</pre> end";
1668        let (protected, blocks) = protect_pre_blocks(input);
1669        let restored = restore_pre_blocks(&protected, &blocks);
1670        assert_eq!(restored, input);
1671    }
1672
1673    #[test]
1674    fn restore_pre_blocks_no_blocks_returns_input() {
1675        assert_eq!(restore_pre_blocks("plain html", &[]), "plain html");
1676    }
1677
1678    #[test]
1679    fn pre_block_placeholder_survives_webui_component_slot_render() -> TestResult {
1680        let input = "<code-block><pre><code>let x = 1;\n</code></pre></code-block>";
1681        let (protected, blocks) = protect_pre_blocks(input);
1682        let page_html = format!("<!DOCTYPE html><html><body>{protected}</body></html>");
1683
1684        let tmp = std::env::temp_dir().join(format!(
1685            "webui-press-slot-test-{}-{:x}",
1686            std::process::id(),
1687            fxhash(input)
1688        ));
1689        if tmp.exists() {
1690            fs::remove_dir_all(&tmp)?;
1691        }
1692        fs::create_dir_all(&tmp)?;
1693        fs::write(tmp.join("index.html"), page_html)?;
1694
1695        let components = vec![Path::new(env!("CARGO_MANIFEST_DIR"))
1696            .join("components")
1697            .to_string_lossy()
1698            .into_owned()];
1699        let build_result = webui::build(BuildOptions {
1700            app_dir: tmp.clone(),
1701            entry: "index.html".to_string(),
1702            plugin: Some(webui::Plugin::WebUI),
1703            components,
1704            ..BuildOptions::default()
1705        })?;
1706
1707        let mut writer = StringWriter::with_capacity(4096);
1708        let handler = WebUIHandler::with_plugin(|| {
1709            Box::new(webui_handler::plugin::webui::WebUIHydrationPlugin::new())
1710        });
1711        let protocol = Protocol::new(build_result.protocol);
1712        handler.render(
1713            &protocol,
1714            &Value::Object(Map::new()),
1715            &RenderOptions::new("index.html", "/"),
1716            &mut writer,
1717        )?;
1718
1719        fs::remove_dir_all(&tmp)?;
1720
1721        let html = restore_pre_blocks(&writer.buf, &blocks);
1722        assert!(
1723            html.contains("<pre><code>let x = 1;\n</code></pre>"),
1724            "slotted pre block should be restored after WebUI render: {html}"
1725        );
1726        assert!(
1727            !html.contains(PRE_BLOCK_MARKER_PREFIX),
1728            "placeholder marker must not survive output: {html}"
1729        );
1730
1731        Ok(())
1732    }
1733
1734    #[test]
1735    fn build_search_entry_decodes_code_text_and_captures_headings() {
1736        let entry = build_search_entry(
1737            "`<for>` Loop Directive",
1738            "/guide/concepts/directives/for/",
1739            r##"<h1 id="for"><code>&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>"##,
1740        );
1741
1742        assert_eq!(entry["title"].as_str(), Some("`<for>` Loop Directive"));
1743        assert_eq!(
1744            entry["content"].as_str(),
1745            Some("<for> Loop Directive Use <for> loops. Syntax Nested Search")
1746        );
1747        let Some(headings) = entry["headings"].as_array() else {
1748            panic!("headings should be an array: {entry:?}");
1749        };
1750        assert_eq!(headings.len(), 3);
1751        assert_eq!(headings[0]["text"].as_str(), Some("<for> Loop Directive"));
1752        assert_eq!(headings[0]["anchor"].as_str(), Some("for"));
1753        assert_eq!(headings[0]["level"].as_u64(), Some(1));
1754        assert_eq!(headings[1]["text"].as_str(), Some("Syntax"));
1755        assert_eq!(headings[1]["anchor"].as_str(), Some("syntax"));
1756        assert_eq!(headings[1]["level"].as_u64(), Some(2));
1757        assert_eq!(headings[2]["text"].as_str(), Some("Nested Search"));
1758        assert_eq!(headings[2]["anchor"].as_str(), Some("nested"));
1759        assert_eq!(headings[2]["level"].as_u64(), Some(3));
1760    }
1761
1762    // --- fxhash ----------------------------------------------------------
1763
1764    #[test]
1765    fn fxhash_deterministic() {
1766        assert_eq!(fxhash("abc"), fxhash("abc"));
1767        assert_ne!(fxhash("abc"), fxhash("abd"));
1768    }
1769}