Skip to main content

webui_docs/
build.rs

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