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