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