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