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