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