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;
9use std::sync::atomic::AtomicU64;
10use std::time::Instant;
11
12use console::style;
13use rayon::prelude::*;
14use serde_json::{Map, Value};
15use webui::BuildOptions;
16use webui_handler::{RenderOptions, ResponseWriter, WebUIHandler};
17use webui_tokens::TokenFile;
18
19use crate::content::process_content;
20use crate::error::{Error, Result};
21use crate::markdown::Highlighter;
22use crate::types::{BuildStats, DocsConfig};
23
24/// Persistent state held by the dev server across rebuilds. The dev
25/// server always performs a full rebuild on every watcher tick — the
26/// previous incremental machinery proved too complex for the marginal
27/// time savings — so this struct exists solely to amortize startup
28/// costs that don't depend on user-edited content.
29///
30/// Today that's just the syntect `Highlighter` (the syntax + theme
31/// load is ~30-50ms). One-shot CLI builds construct a fresh
32/// `BuildCache::default()` and discard it.
33#[derive(Default)]
34pub struct BuildCache {
35    /// Syntect highlighter — kept alive across rebuilds so we don't pay
36    /// the syntax/theme load cost on every keystroke.
37    pub(crate) highlighter: Option<Highlighter>,
38    /// Suppress per-step terminal output (`✔ Rendered 31 pages`, etc.).
39    /// The dev server flips this on after the first build so subsequent
40    /// rebuilds collapse into the rolling rebuild line.
41    pub quiet: bool,
42    /// Skip the `remove_dir_all(out_dir)` step. Files are overwritten
43    /// in place. Dev-mode optimization — saves the macOS-ENOTEMPTY
44    /// retry path and shaves disk I/O off every rebuild. Stale files
45    /// from deleted source pages survive until the next process restart.
46    pub skip_clean: bool,
47}
48
49impl BuildCache {
50    /// Create an empty cache with verbose output and clean-then-build
51    /// semantics. Subsequent calls to [`build_docs_with_cache`] will
52    /// populate it on first use.
53    #[must_use]
54    pub fn new() -> Self {
55        Self::default()
56    }
57
58    /// Switch the cache into dev-server rebuild mode: suppresses
59    /// per-step output banners and skips wiping the output directory.
60    pub fn set_dev_rebuild(&mut self) {
61        self.quiet = true;
62        self.skip_clean = true;
63    }
64}
65
66fn load_theme(theme: &str, config_dir: &Path) -> Result<TokenFile> {
67    let config_relative = config_dir.join(theme);
68    let resolved = if config_relative.exists() {
69        config_relative
70            .canonicalize()
71            .map_err(|e| Error::Build(format!("Failed to canonicalize {theme}: {e}")))?
72    } else {
73        webui_tokens::resolve_theme_path(theme, config_dir)
74            .map_err(|e| Error::Build(format!("Failed to resolve theme {theme}: {e}")))?
75    };
76    webui_tokens::load_token_file(&resolved)
77        .map_err(|e| Error::Build(format!("Failed to load theme {}: {e}", resolved.display())))
78}
79
80fn inject_theme_tokens(
81    state: &mut Value,
82    token_file: &TokenFile,
83    protocol_tokens: &[String],
84) -> Result<()> {
85    let resolved = webui_tokens::resolve_tokens(protocol_tokens, token_file)
86        .map_err(|e| Error::Build(format!("Failed to resolve theme tokens: {e}")))?;
87    webui_tokens::inject_into_state(state, &resolved);
88    Ok(())
89}
90
91// ── Output helpers ──────────────────────────────────────────────
92//
93// Mirrors the styling vocabulary in `crates/webui-cli/src/utils/output.rs`
94// so webui-press feels at home in a workspace where users may also see
95// `webui build` / `webui serve` output.
96
97/// Monotonic counter used to produce unique per-rebuild temp-dir names.
98/// Combined with the process id and a fxhash of the page path so two
99/// rebuilds running in the same process can never collide on the
100/// per-page scratch dir.
101static REBUILD_NONCE: AtomicU64 = AtomicU64::new(0);
102
103fn print_header(cache: &BuildCache, title: &str) {
104    if cache.quiet {
105        return;
106    }
107    eprintln!(
108        "\n  {} {}",
109        style("⚡").cyan().bold(),
110        style(title).cyan().bold()
111    );
112}
113
114fn print_success(cache: &BuildCache, message: &str) {
115    if cache.quiet {
116        return;
117    }
118    eprintln!("  {} {message}", style("✔").green());
119}
120
121fn print_finish(cache: &BuildCache, message: &str) {
122    if cache.quiet {
123        return;
124    }
125    eprintln!("\n  {} {message}\n", style("✨").green());
126}
127
128/// Build a JSON object from key-value pairs without using `json!` (which calls `unwrap`).
129fn json_obj<const N: usize>(entries: [(&str, Value); N]) -> Value {
130    let mut map = Map::with_capacity(N);
131    for (k, v) in entries {
132        map.insert(k.to_string(), v);
133    }
134    Value::Object(map)
135}
136
137/// A writer that collects rendered HTML into a String buffer.
138struct StringWriter {
139    buf: String,
140}
141
142impl StringWriter {
143    fn with_capacity(cap: usize) -> Self {
144        Self {
145            buf: String::with_capacity(cap),
146        }
147    }
148}
149
150impl ResponseWriter for StringWriter {
151    fn write(&mut self, content: &str) -> webui_handler::Result<()> {
152        self.buf.push_str(content);
153        Ok(())
154    }
155
156    fn end(&mut self) -> webui_handler::Result<()> {
157        Ok(())
158    }
159}
160
161/// Build a documentation site from the given configuration.
162///
163/// `config_dir` is the directory containing `config.json`. It is used to
164/// resolve relative paths declared in the config (such as a custom page's
165/// `state_file`).
166///
167/// One-shot variant for the CLI build command. The dev server uses
168/// [`build_docs_with_cache`] so the syntect highlighter survives across
169/// rebuilds.
170pub fn build_docs(
171    config: &DocsConfig,
172    config_dir: &Path,
173    template_dir: &Path,
174) -> Result<BuildStats> {
175    let mut cache = BuildCache::new();
176    build_docs_with_cache(config, config_dir, template_dir, &mut cache)
177}
178
179/// Build a documentation site, reusing the supplied [`BuildCache`] across
180/// invocations.
181///
182/// **Always performs a full rebuild.** The previous incremental machinery
183/// (per-page state hashes, component-bundle signatures, descriptor cache,
184/// search-index sig, etc.) was removed because the resulting invariants
185/// were complex for a dev-server-only optimization. The rebuild is
186/// already fast in practice and a from-scratch run keeps the output
187/// guaranteed-consistent with the source.
188///
189/// The cache exists today purely to amortize startup costs that are
190/// independent of user-edited content (the syntect highlighter load).
191pub fn build_docs_with_cache(
192    config: &DocsConfig,
193    config_dir: &Path,
194    template_dir: &Path,
195    cache: &mut BuildCache,
196) -> Result<BuildStats> {
197    let start = Instant::now();
198    let base_path = &config.base_path;
199    let out_dir = Path::new(&config.out_dir);
200
201    // Flat output: site files live at the root of `out_dir`. URLs in HTML
202    // include `basePath` via `<base href>` and link rewriting; the dev server
203    // (`webui-press serve`) maps URL `<basePath>/foo` → file `out_dir/foo`.
204    // For deploys (e.g. GitHub Pages project sites), the host mounts `out_dir`
205    // at `<basePath>`, so the same flat layout works.
206    let site_dir = out_dir.to_path_buf();
207
208    // Read custom CSS
209    let custom_css = config
210        .css
211        .as_ref()
212        .and_then(|p| fs::read_to_string(p).ok())
213        .unwrap_or_default();
214    let token_file = match &config.theme {
215        Some(theme) => Some(load_theme(theme, config_dir)?),
216        None => None,
217    };
218
219    print_header(cache, &config.site.title);
220    eprintln!();
221
222    // Compute the `<head>` injection string up-front, BEFORE any
223    // filesystem mutations. Including it here makes it cheap to bake
224    // into each page's state inside `process_content` (avoiding a
225    // post-process_content mutation pass that would force-clone every
226    // cached descriptor) and lets the content cache invalidate when
227    // the link tags change. We don't write the CSS files yet — that
228    // happens after content processing succeeds, so a content failure
229    // can't corrupt the previous valid output.
230    let base_css_src = template_dir.join("docs.css");
231    let has_base_css = base_css_src.exists();
232    let base_css_link = if has_base_css {
233        format!("<link rel=\"stylesheet\" href=\"{base_path}docs.css\">")
234    } else {
235        String::new()
236    };
237    let has_theme_css = !custom_css.is_empty();
238    let theme_css_link = if has_theme_css {
239        format!("<link rel=\"stylesheet\" href=\"{base_path}theme.css\">")
240    } else {
241        String::new()
242    };
243    let head_injection = {
244        let mut parts: Vec<String> = Vec::with_capacity(2 + config.head.len());
245        if !base_css_link.is_empty() {
246            parts.push(base_css_link.clone());
247        }
248        if !theme_css_link.is_empty() {
249            parts.push(theme_css_link.clone());
250        }
251        for tag in &config.head {
252            parts.push(tag.to_html());
253        }
254        parts.join("\n  ")
255    };
256
257    // Step 1: Process content. Highlighter is cached across rebuilds —
258    // syntect's default syntax/theme load is ~30-50ms which we don't want
259    // to pay every keystroke.
260    let highlighter = cache.highlighter.take().unwrap_or_default();
261
262    let pages = process_content(config, config_dir, &highlighter, &head_injection)?;
263
264    // Restore the highlighter for the next rebuild.
265    cache.highlighter = Some(highlighter);
266
267    // Step 2: Resolve component sources for the per-page builds
268    let mut component_sources: Vec<String> = Vec::new();
269    // Built-in component library (e.g. crates/webui-press/components/)
270    let builtin_components = template_dir.parent().map(|p| p.join("components"));
271    if let Some(ref bc) = builtin_components {
272        if bc.exists() {
273            component_sources.push(bc.to_string_lossy().to_string());
274        }
275    }
276    // User component dirs from config
277    if let Some(ref user_dirs) = config.components {
278        for d in user_dirs {
279            let abs = std::env::current_dir().unwrap_or_default().join(d);
280            component_sources.push(abs.to_string_lossy().to_string());
281        }
282    }
283    // Template-local components (e.g. docs-search, docs-theme-toggle living
284    // beside the template's index.html).
285    component_sources.push(template_dir.to_string_lossy().to_string());
286
287    let template_html = fs::read_to_string(template_dir.join("index.html"))
288        .map_err(|e| Error::Build(format!("Failed to read template: {e}")))?;
289
290    // Step 3: Wipe the previous output and recreate the site root.
291    // Always done — a from-scratch run guarantees the output matches
292    // the current source with no possibility of stale per-page dirs,
293    // stale CSS files, or orphaned assets surviving a config change.
294    if out_dir.exists() && !cache.skip_clean {
295        remove_dir_all_retry(out_dir)
296            .map_err(|e| Error::Io(format!("Cannot clean output dir: {e}")))?;
297    }
298    fs::create_dir_all(&site_dir).map_err(|e| Error::Io(format!("Cannot create site dir: {e}")))?;
299
300    // Materialize the CSS files referenced by the head injection. Done
301    // after content processing succeeds so a content failure can't
302    // corrupt the previous valid output (the wipe above still happens
303    // because we clean before processing — but that order means a
304    // failure leaves no output at all, never half-output).
305    if has_base_css {
306        fs::copy(&base_css_src, site_dir.join("docs.css"))
307            .map_err(|e| Error::Io(format!("Cannot copy docs.css: {e}")))?;
308    }
309    if has_theme_css {
310        fs::write(site_dir.join("theme.css"), &custom_css)
311            .map_err(|e| Error::Io(format!("Cannot write theme.css: {e}")))?;
312    }
313
314    let handler = WebUIHandler::with_plugin(|| {
315        Box::new(webui_handler::plugin::webui::WebUIHydrationPlugin::new())
316    });
317
318    // Pre-create per-page output directories sequentially (avoids races and
319    // cheap fs::create_dir_all calls competing on the same parent paths).
320    for page in &pages {
321        let page_dir = site_dir.join(page.path.strip_prefix(base_path).unwrap_or(&page.path));
322        fs::create_dir_all(&page_dir)
323            .map_err(|e| Error::Io(format!("Cannot create dir {}: {e}", page_dir.display())))?;
324    }
325
326    // Kick off TypeScript component bundling on a background thread.
327    // esbuild (npx process startup) takes 100s of ms and is independent
328    // of the render pipeline, so we overlap it with the per-page
329    // protocol build + render.
330    let template_dir_owned = template_dir.to_path_buf();
331    let component_sources_clone = component_sources.clone();
332    let site_dir_clone = site_dir.clone();
333    let bundle_handle = std::thread::spawn(move || -> Result<usize> {
334        let node_modules = std::env::current_dir()
335            .unwrap_or_default()
336            .join("node_modules");
337        bundle_components(
338            &template_dir_owned,
339            &component_sources_clone,
340            &site_dir_clone,
341            &node_modules,
342        )
343    });
344
345    // Per-page build + render + write in parallel.
346    //
347    // Each page is its own complete app: we substitute `{{{page.content}}}`
348    // in the template with the page's actual HTML (including any custom
349    // elements like <code-block>) and run `webui::build()` on the result.
350    // This means the build pipeline naturally discovers the components used
351    // on this page, expands their declarative shadow DOM, and emits their
352    // template metadata to `window.__webui.templates` for client-side
353    // hydration — no manual registration tricks required.
354    let total_bytes = std::sync::atomic::AtomicUsize::new(0);
355    let component_css: std::sync::Mutex<HashMap<String, String>> =
356        std::sync::Mutex::new(HashMap::new());
357
358    pages.par_iter().try_for_each(|page| -> Result<()> {
359        let page_dir = site_dir.join(page.path.strip_prefix(base_path).unwrap_or(&page.path));
360        let target = page_dir.join("index.html");
361
362        let content = page.state["page"]["content"].as_str().unwrap_or("");
363
364        // Protect <pre> blocks from HTML parser whitespace normalization.
365        let (protected, pre_blocks) = protect_pre_blocks(content);
366
367        // Substitute the raw signal in the template with the literal HTML.
368        let page_html = template_html.replace("{{{page.content}}}", &protected);
369
370        // Per-page temp dir holding only this page's index.html — components
371        // come exclusively from `component_sources`, which already includes
372        // the template dir (for docs-search/docs-theme-toggle) plus any
373        // configured component libraries.
374        //
375        // Name includes pid + per-rebuild nonce so two parallel page
376        // builds (and successive rebuilds) can never collide and wipe
377        // each other's in-progress files.
378        let nonce = REBUILD_NONCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
379        let page_tmp = std::env::temp_dir().join(format!(
380            "webui-press-page-{}-{:x}-{nonce:x}",
381            std::process::id(),
382            fxhash(&page.path),
383        ));
384        if page_tmp.exists() {
385            fs::remove_dir_all(&page_tmp).ok();
386        }
387        fs::create_dir_all(&page_tmp)
388            .map_err(|e| Error::Io(format!("Cannot create page temp: {e}")))?;
389        fs::write(page_tmp.join("index.html"), &page_html)
390            .map_err(|e| Error::Io(format!("Cannot write page temp: {e}")))?;
391
392        let build_result = webui::build(BuildOptions {
393            app_dir: page_tmp.clone(),
394            entry: "index.html".to_string(),
395            plugin: Some(webui::Plugin::WebUI),
396            components: component_sources.clone(),
397            ..BuildOptions::default()
398        })
399        .map_err(|e| Error::Build(format!("{}: {e}", page.path)))?;
400
401        total_bytes.fetch_add(
402            build_result.protocol_bytes.len(),
403            std::sync::atomic::Ordering::Relaxed,
404        );
405
406        // Collect per-component CSS files (Link strategy emits one .css
407        // per component used on the page). Multiple pages share components,
408        // so we dedupe by filename in a shared map and write once after
409        // the parallel pass completes.
410        if !build_result.css_files.is_empty() {
411            let mut css_map = component_css
412                .lock()
413                .unwrap_or_else(std::sync::PoisonError::into_inner);
414            for (name, content) in build_result.css_files {
415                css_map.entry(name).or_insert(content);
416            }
417        }
418
419        let mut themed_state;
420        let render_state = if let Some(token_file) = token_file.as_ref() {
421            themed_state = page.state.clone();
422            inject_theme_tokens(&mut themed_state, token_file, &build_result.protocol.tokens)?;
423            &themed_state
424        } else {
425            &page.state
426        };
427
428        let mut writer = StringWriter::with_capacity(8192);
429        handler
430            .render(
431                &build_result.protocol,
432                render_state,
433                &RenderOptions::new("index.html", &page.path),
434                &mut writer,
435            )
436            .map_err(|e| Error::Render(format!("{}: {e}", page.path)))?;
437
438        // Restore the protected <pre> blocks via a single-pass scan.
439        let html = restore_pre_blocks(&writer.buf, &pre_blocks);
440
441        // Write directly inside the parallel closure.
442        fs::write(&target, &html)
443            .map_err(|e| Error::Io(format!("Cannot write {}: {e}", page.path)))?;
444
445        fs::remove_dir_all(&page_tmp).ok();
446        Ok(())
447    })?;
448
449    print_success(cache, &format!("Rendered {} pages", pages.len()));
450
451    // Step 4: Search index. Strip rendered HTML to plain text and emit
452    // an entry per non-home page. Done from-scratch every build.
453    let search_path = site_dir.join("search-index.json");
454    let search_entries: Vec<Value> = pages
455        .iter()
456        .filter(|p| !p.is_home)
457        .map(|p| {
458            let html = p.state["page"]["content"].as_str().unwrap_or("");
459            let title = p.state["page"]["title"].as_str().unwrap_or("");
460            build_search_entry(title, &p.path, html)
461        })
462        .collect();
463    fs::write(
464        &search_path,
465        serde_json::to_string(&search_entries)
466            .map_err(|e| Error::Build(format!("JSON error: {e}")))?,
467    )
468    .map_err(|e| Error::Io(e.to_string()))?;
469    print_success(
470        cache,
471        &format!("Indexed {} pages for search", search_entries.len()),
472    );
473
474    // Step 5: Copy static public assets.
475    let public_dir = Path::new(&config.public_dir);
476    if public_dir.exists() {
477        copy_dir(public_dir, &site_dir)?;
478    }
479
480    // Step 6: Generate 404 page
481    let nav_val = pages
482        .first()
483        .map(|p| p.state["navigation"].clone())
484        .unwrap_or_default();
485
486    let footer_val = config
487        .footer
488        .as_ref()
489        .map(|f| json_obj([("html", Value::String(f.html.clone()))]))
490        .unwrap_or(Value::Null);
491
492    let not_found_content = format!(
493        "<h1>404 — Page Not Found</h1>\
494         <p>The page you're looking for doesn't exist or has been moved.</p>\
495         <p><a href=\"{base_path}\">← Back to Home</a></p>"
496    );
497    let mut not_found_state = json_obj([
498        (
499            "site",
500            json_obj([
501                ("title", Value::String(config.site.title.clone())),
502                ("base", Value::String(base_path.to_string())),
503            ]),
504        ),
505        ("navigation", nav_val),
506        ("sidebar", json_obj([("sections", Value::Array(vec![]))])),
507        (
508            "page",
509            json_obj([
510                ("title", Value::String("Page Not Found".to_string())),
511                (
512                    "description",
513                    Value::String("The page you're looking for doesn't exist.".to_string()),
514                ),
515                ("content", Value::String(not_found_content.clone())),
516                ("isHome", Value::Bool(false)),
517                ("layout", Value::String("doc".to_string())),
518            ]),
519        ),
520        ("hero", Value::Null),
521        ("footer", footer_val),
522        ("prev", Value::Null),
523        ("next", Value::Null),
524    ]);
525    not_found_state["headTags"] = Value::String(head_injection);
526    not_found_state["label"] = Value::String("Copy".to_string());
527    not_found_state["icon"] = Value::String("🌙".to_string());
528
529    let not_found_html = template_html.replace("{{{page.content}}}", &not_found_content);
530    let nf_tmp = std::env::temp_dir().join(format!(
531        "webui-press-404-{}-{:x}",
532        std::process::id(),
533        REBUILD_NONCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
534    ));
535    if nf_tmp.exists() {
536        fs::remove_dir_all(&nf_tmp).ok();
537    }
538    fs::create_dir_all(&nf_tmp).map_err(|e| Error::Io(e.to_string()))?;
539    fs::write(nf_tmp.join("index.html"), &not_found_html).map_err(|e| Error::Io(e.to_string()))?;
540
541    let nf_build = webui::build(BuildOptions {
542        app_dir: nf_tmp.clone(),
543        entry: "index.html".to_string(),
544        plugin: Some(webui::Plugin::WebUI),
545        components: component_sources.clone(),
546        ..BuildOptions::default()
547    })
548    .map_err(|e| Error::Build(format!("404 build failed: {e}")))?;
549
550    if let Some(token_file) = token_file.as_ref() {
551        inject_theme_tokens(&mut not_found_state, token_file, &nf_build.protocol.tokens)?;
552    }
553
554    // Fold the 404 page's component CSS into the shared map, then write
555    // all per-component stylesheets at site root in one pass. The handler
556    // emits relative hrefs like `<link rel="stylesheet" href="code-block.css">`;
557    // the template's <base href="{site.base}"> resolves them against site root.
558    {
559        let mut css_map = component_css
560            .lock()
561            .unwrap_or_else(std::sync::PoisonError::into_inner);
562        for (name, content) in &nf_build.css_files {
563            css_map
564                .entry(name.clone())
565                .or_insert_with(|| content.clone());
566        }
567    }
568
569    let mut writer_404 = StringWriter::with_capacity(4096);
570    handler
571        .render(
572            &nf_build.protocol,
573            &not_found_state,
574            &RenderOptions::new("index.html", &format!("{base_path}404/")),
575            &mut writer_404,
576        )
577        .map_err(|e| Error::Render(format!("404: {e}")))?;
578
579    fs::write(site_dir.join("404.html"), writer_404.buf).map_err(|e| Error::Io(e.to_string()))?;
580    fs::remove_dir_all(&nf_tmp).ok();
581    print_success(cache, "Generated 404 page");
582
583    // Step 7: Write per-component stylesheets collected during the
584    // parallel page builds (and the 404 build above). Sort by name for
585    // deterministic output.
586    let css_map_local = component_css
587        .into_inner()
588        .unwrap_or_else(std::sync::PoisonError::into_inner);
589    let mut css_names: Vec<&String> = css_map_local.keys().collect();
590    css_names.sort();
591    let css_count = css_names.len();
592    for name in css_names {
593        fs::write(site_dir.join(name), &css_map_local[name])
594            .map_err(|e| Error::Io(format!("Cannot write {name}: {e}")))?;
595    }
596    if css_count > 0 {
597        print_success(cache, &format!("Wrote {css_count} component stylesheets"));
598    }
599
600    // Step 8: Wait for the background bundling thread.
601    let component_count = bundle_handle
602        .join()
603        .map_err(|_| Error::Build("Bundle thread panicked".to_string()))??;
604    print_success(cache, &format!("Bundled {component_count} components"));
605
606    let elapsed = start.elapsed();
607    let total_bytes = total_bytes.load(std::sync::atomic::Ordering::Relaxed);
608    print_finish(cache, &format!("Built in {:.1}s", elapsed.as_secs_f64()));
609
610    Ok(BuildStats {
611        pages: pages.len(),
612        protocol_bytes: total_bytes,
613    })
614}
615
616/// Recursively remove a directory, retrying briefly on `ENOTEMPTY`.
617///
618/// On macOS (and occasionally Linux), `fs::remove_dir_all` can fail
619/// with `Directory not empty` when something writes a file into a
620/// subdirectory between the moment we walked it and the moment we try
621/// to `rmdir` it. Likely culprits during a dev-server rebuild:
622///
623///  - Spotlight / `mds` writing index hints,
624///  - Finder dropping a `.DS_Store`,
625///  - the user's editor reindexing the project,
626///  - a back-to-back save firing another rebuild before the first one
627///    finished cleanup (the worker coalesces, but a stray write into
628///    `out_dir` from a parallel `cargo` or another tool is still
629///    possible).
630///
631/// We retry up to a handful of times with short backoff. After that the
632/// error propagates so the user sees a real failure rather than an
633/// infinite loop.
634fn remove_dir_all_retry(path: &Path) -> std::io::Result<()> {
635    const MAX_ATTEMPTS: u32 = 5;
636    let mut delay = std::time::Duration::from_millis(20);
637    for attempt in 1..=MAX_ATTEMPTS {
638        match fs::remove_dir_all(path) {
639            Ok(()) => return Ok(()),
640            Err(e) if attempt < MAX_ATTEMPTS && is_dir_not_empty(&e) => {
641                std::thread::sleep(delay);
642                delay = delay.saturating_mul(2);
643            }
644            Err(e) => return Err(e),
645        }
646    }
647    // Loop guarantees a return on each iteration; this is unreachable
648    // but `for` returning `()` makes the type-checker happy without it.
649    Ok(())
650}
651
652/// Returns true if the error is `ENOTEMPTY` / `EEXIST` style "directory
653/// not empty". On macOS this is errno 66; on Linux it's 39. We also
654/// match by `ErrorKind` once Rust exposes one (currently `Other`).
655fn is_dir_not_empty(e: &std::io::Error) -> bool {
656    match e.raw_os_error() {
657        Some(66) => true,  // macOS / BSD: ENOTEMPTY
658        Some(39) => true,  // Linux: ENOTEMPTY
659        Some(145) => true, // Windows: ERROR_DIR_NOT_EMPTY
660        _ => false,
661    }
662}
663
664/// Iteratively copy a directory tree (BFS via stack — no recursion).
665fn copy_dir(src: &Path, dest: &Path) -> Result<()> {
666    if !src.exists() {
667        return Ok(());
668    }
669    fs::create_dir_all(dest).map_err(|e| Error::Io(e.to_string()))?;
670    let mut stack: Vec<(std::path::PathBuf, std::path::PathBuf)> =
671        vec![(src.to_path_buf(), dest.to_path_buf())];
672    while let Some((s, d)) = stack.pop() {
673        for entry in fs::read_dir(&s).map_err(|e| Error::Io(e.to_string()))? {
674            let entry = entry.map_err(|e| Error::Io(e.to_string()))?;
675            let dest_path = d.join(entry.file_name());
676            let ft = entry.file_type().map_err(|e| Error::Io(e.to_string()))?;
677            if ft.is_dir() {
678                fs::create_dir_all(&dest_path).map_err(|e| Error::Io(e.to_string()))?;
679                stack.push((entry.path(), dest_path));
680            } else {
681                fs::copy(entry.path(), &dest_path).map_err(|e| Error::Io(e.to_string()))?;
682            }
683        }
684    }
685    Ok(())
686}
687
688/// Truncate a string to at most `max` bytes at a valid UTF-8 boundary.
689pub(crate) fn truncate_utf8(s: &str, max: usize) -> &str {
690    if s.len() <= max {
691        return s;
692    }
693    let mut end = max;
694    while end > 0 && !s.is_char_boundary(end) {
695        end -= 1;
696    }
697    &s[..end]
698}
699
700/// Build a single search-index entry for a page. Strips HTML tags,
701/// collapses whitespace, and truncates to 500 bytes. Called once per
702/// page during descriptor construction in `process_content` so cache
703/// hits carry their entry forward verbatim.
704pub(crate) fn build_search_entry(title: &str, path: &str, html: &str) -> Value {
705    let mut clean = String::with_capacity(html.len() / 2);
706    let mut in_tag = false;
707    for ch in html.chars() {
708        match ch {
709            '<' => in_tag = true,
710            '>' => {
711                in_tag = false;
712                clean.push(' ');
713            }
714            _ if !in_tag => clean.push(ch),
715            _ => {}
716        }
717    }
718    // split_whitespace collapses arbitrary runs; we re-emit a single
719    // space between tokens. Avoiding `collect::<Vec<_>>().join(" ")` so
720    // we don't allocate an intermediate vector for sequential access.
721    let mut content = String::with_capacity(clean.len());
722    let mut first = true;
723    for tok in clean.split_whitespace() {
724        if !first {
725            content.push(' ');
726        }
727        content.push_str(tok);
728        first = false;
729    }
730    let truncated = truncate_utf8(&content, 500);
731    json_obj([
732        ("title", Value::String(title.to_string())),
733        ("path", Value::String(path.to_string())),
734        ("content", Value::String(truncated.to_string())),
735    ])
736}
737
738fn is_component_ts_file(path: &Path) -> bool {
739    let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
740        return false;
741    };
742    path.extension().is_some_and(|extension| extension == "ts")
743        && !file_name.ends_with(".spec.ts")
744        && !file_name.ends_with(".test.ts")
745        && !file_name.ends_with(".d.ts")
746}
747
748/// Collect component `.ts` files from a directory tree (iterative).
749fn collect_ts_files(dir: &Path) -> Vec<std::path::PathBuf> {
750    let mut files = Vec::new();
751    let mut stack = vec![dir.to_path_buf()];
752    while let Some(d) = stack.pop() {
753        let Ok(entries) = fs::read_dir(&d) else {
754            continue;
755        };
756        for entry in entries.flatten() {
757            let path = entry.path();
758            if path.is_dir() {
759                stack.push(path);
760            } else if is_component_ts_file(&path) {
761                files.push(path);
762            }
763        }
764    }
765    files.sort();
766    files
767}
768
769/// Bundle component TypeScript files into a single `components.js` via esbuild.
770/// Returns the number of components bundled.
771fn bundle_components(
772    template_dir: &Path,
773    component_sources: &[String],
774    site_dir: &Path,
775    node_modules: &Path,
776) -> Result<usize> {
777    let mut ts_files = Vec::new();
778
779    // Collect from user component directories
780    for dir in component_sources {
781        let p = Path::new(dir);
782        if p.exists() {
783            ts_files.extend(collect_ts_files(p));
784        }
785    }
786
787    // Collect from template subdirectories (docs-search, docs-theme-toggle, etc.)
788    if let Ok(entries) = fs::read_dir(template_dir) {
789        for entry in entries.flatten() {
790            if entry.path().is_dir() {
791                ts_files.extend(collect_ts_files(&entry.path()));
792            }
793        }
794    }
795
796    if ts_files.is_empty() {
797        return Ok(0);
798    }
799
800    // Generate the entry file content
801    let imports: String = ts_files
802        .iter()
803        .map(|f| format!("import \"{}\";", f.to_string_lossy().replace('\\', "/")))
804        .collect::<Vec<_>>()
805        .join("\n");
806
807    let out_file = site_dir.join("components.js");
808
809    let status = std::process::Command::new("npx")
810        .arg("esbuild")
811        .arg("--bundle")
812        .arg("--format=esm")
813        .arg("--minify")
814        .arg("--loader:.html=text")
815        .arg("--loader:.css=text")
816        .arg(format!("--outfile={}", out_file.to_string_lossy()))
817        .env("NODE_PATH", node_modules)
818        .stdin(std::process::Stdio::piped())
819        .stdout(std::process::Stdio::piped())
820        .stderr(std::process::Stdio::piped())
821        .spawn()
822        .and_then(|mut child| {
823            use std::io::Write;
824            if let Some(ref mut stdin) = child.stdin {
825                let _ = stdin.write_all(imports.as_bytes());
826            }
827            child.wait_with_output()
828        })
829        .map_err(|e| Error::Build(format!("esbuild failed: {e}")))?;
830
831    if !status.status.success() {
832        let stderr = String::from_utf8_lossy(&status.stderr);
833        return Err(Error::Build(format!("esbuild error: {stderr}")));
834    }
835
836    Ok(ts_files.len())
837}
838
839const PRE_BLOCK_MARKER_PREFIX: &str = "<span data-webui-press-pre-block=\"";
840const PRE_BLOCK_MARKER_SUFFIX: &str = "\"></span>";
841
842/// Replace `<pre …>…</pre>` blocks with placeholder elements so the WebUI
843/// HTML parser does not normalize whitespace inside them. Returns the
844/// modified string and the original blocks (in order) for restoration
845/// after rendering.
846fn protect_pre_blocks(content: &str) -> (String, Vec<String>) {
847    use std::fmt::Write as _;
848    let mut blocks: Vec<String> = Vec::new();
849    let mut out = String::with_capacity(content.len());
850    let mut cursor = 0;
851    while let Some(rel_start) = find_pre_open(&content[cursor..]) {
852        let start = cursor + rel_start;
853        if let Some(rel_end) = content[start..].find("</pre>") {
854            let end = start + rel_end + "</pre>".len();
855            out.push_str(&content[cursor..start]);
856            out.push_str(PRE_BLOCK_MARKER_PREFIX);
857            // write! into existing buffer — avoids `format!` allocation per block.
858            let _ = write!(&mut out, "{}", blocks.len());
859            out.push_str(PRE_BLOCK_MARKER_SUFFIX);
860            blocks.push(content[start..end].to_string());
861            cursor = end;
862        } else {
863            break;
864        }
865    }
866    out.push_str(&content[cursor..]);
867    (out, blocks)
868}
869
870/// Find the next opening `<pre` tag where the next byte is one of `>`, ` `,
871/// `\t`, `\n`, or `\r`. Avoids matching `<presentation`, `<pretend`, etc.
872fn find_pre_open(s: &str) -> Option<usize> {
873    let bytes = s.as_bytes();
874    let mut i = 0;
875    while let Some(rel) = s[i..].find("<pre") {
876        let pos = i + rel;
877        let after = pos + 4;
878        match bytes.get(after) {
879            Some(b'>' | b' ' | b'\t' | b'\n' | b'\r') => return Some(pos),
880            _ => i = after,
881        }
882    }
883    None
884}
885
886/// Single-pass restoration of pre-block placeholder elements to their
887/// original content. Faster than calling `String::replace` once per block.
888fn restore_pre_blocks(html: &str, blocks: &[String]) -> String {
889    if blocks.is_empty() {
890        return html.to_string();
891    }
892    let extra: usize = blocks.iter().map(|b| b.len()).sum();
893    let mut out = String::with_capacity(html.len() + extra);
894    let mut cursor = 0;
895    while let Some(rel) = html[cursor..].find(PRE_BLOCK_MARKER_PREFIX) {
896        let p = cursor + rel;
897        out.push_str(&html[cursor..p]);
898        let after = p + PRE_BLOCK_MARKER_PREFIX.len();
899        if let Some(end_rel) = html[after..].find(PRE_BLOCK_MARKER_SUFFIX) {
900            let num_str = &html[after..after + end_rel];
901            if let Ok(idx) = num_str.parse::<usize>() {
902                if let Some(block) = blocks.get(idx) {
903                    out.push_str(block);
904                    cursor = after + end_rel + PRE_BLOCK_MARKER_SUFFIX.len();
905                    continue;
906                }
907            }
908            // Unknown placeholder — keep verbatim.
909            out.push_str(&html[p..after + end_rel + PRE_BLOCK_MARKER_SUFFIX.len()]);
910            cursor = after + end_rel + PRE_BLOCK_MARKER_SUFFIX.len();
911        } else {
912            out.push_str(&html[p..]);
913            return out;
914        }
915    }
916    out.push_str(&html[cursor..]);
917    out
918}
919
920/// Cheap deterministic hash for building unique temp directory names.
921fn fxhash(s: &str) -> u64 {
922    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
923    for b in s.bytes() {
924        hash ^= u64::from(b);
925        hash = hash.wrapping_mul(0x100_0000_01b3);
926    }
927    hash
928}
929
930#[cfg(test)]
931mod tests {
932    use super::*;
933
934    type TestResult = std::result::Result<(), Box<dyn std::error::Error>>;
935
936    // --- truncate_utf8 ---------------------------------------------------
937
938    #[test]
939    fn truncate_utf8_short_string_unchanged() {
940        assert_eq!(truncate_utf8("hello", 100), "hello");
941    }
942
943    #[test]
944    fn truncate_utf8_ascii_at_exact_boundary() {
945        assert_eq!(truncate_utf8("hello world", 5), "hello");
946    }
947
948    #[test]
949    fn truncate_utf8_steps_back_off_multibyte_boundary() {
950        // "é" is two bytes (0xC3 0xA9). Cutting at 1 byte must step back to 0.
951        let s = "é-suffix";
952        let out = truncate_utf8(s, 1);
953        assert!(s.is_char_boundary(out.len()), "out.len()={}", out.len());
954        assert_eq!(out, "");
955    }
956
957    #[test]
958    fn truncate_utf8_keeps_multibyte_when_room_allows() {
959        let s = "café";
960        let out = truncate_utf8(s, 5); // "café" is 5 bytes total
961        assert_eq!(out, "café");
962    }
963
964    // --- protect_pre_blocks / find_pre_open ------------------------------
965
966    #[test]
967    fn protect_pre_blocks_with_attrs() {
968        let input = r#"<p>before</p><pre class="hljs">code</pre><p>after</p>"#;
969        let (out, blocks) = protect_pre_blocks(input);
970        assert_eq!(blocks.len(), 1);
971        assert!(out.contains(r#"<span data-webui-press-pre-block="0"></span>"#));
972        assert!(!out.contains("<pre"));
973        assert_eq!(blocks[0], r#"<pre class="hljs">code</pre>"#);
974    }
975
976    #[test]
977    fn protect_pre_blocks_bare_open_tag() {
978        // Was previously missed because we only matched "<pre " (with space).
979        let input = "<pre>code</pre>";
980        let (out, blocks) = protect_pre_blocks(input);
981        assert_eq!(blocks.len(), 1, "got out={out:?} blocks={blocks:?}");
982        assert_eq!(blocks[0], "<pre>code</pre>");
983    }
984
985    #[test]
986    fn protect_pre_blocks_does_not_match_presentation_tag() {
987        // <presentation> must NOT be treated as a pre block.
988        let input = "<presentation>x</presentation>";
989        let (_out, blocks) = protect_pre_blocks(input);
990        assert_eq!(blocks.len(), 0);
991    }
992
993    #[test]
994    fn protect_then_restore_round_trip() {
995        let input = "before <pre>a</pre> mid <pre class=\"x\">b</pre> end";
996        let (protected, blocks) = protect_pre_blocks(input);
997        let restored = restore_pre_blocks(&protected, &blocks);
998        assert_eq!(restored, input);
999    }
1000
1001    #[test]
1002    fn restore_pre_blocks_no_blocks_returns_input() {
1003        assert_eq!(restore_pre_blocks("plain html", &[]), "plain html");
1004    }
1005
1006    #[test]
1007    fn pre_block_placeholder_survives_webui_component_slot_render() -> TestResult {
1008        let input = "<code-block><pre><code>let x = 1;\n</code></pre></code-block>";
1009        let (protected, blocks) = protect_pre_blocks(input);
1010        let page_html = format!("<!DOCTYPE html><html><body>{protected}</body></html>");
1011
1012        let tmp = std::env::temp_dir().join(format!(
1013            "webui-press-slot-test-{}-{:x}",
1014            std::process::id(),
1015            fxhash(input)
1016        ));
1017        if tmp.exists() {
1018            fs::remove_dir_all(&tmp)?;
1019        }
1020        fs::create_dir_all(&tmp)?;
1021        fs::write(tmp.join("index.html"), page_html)?;
1022
1023        let components = vec![Path::new(env!("CARGO_MANIFEST_DIR"))
1024            .join("components")
1025            .to_string_lossy()
1026            .into_owned()];
1027        let build_result = webui::build(BuildOptions {
1028            app_dir: tmp.clone(),
1029            entry: "index.html".to_string(),
1030            plugin: Some(webui::Plugin::WebUI),
1031            components,
1032            ..BuildOptions::default()
1033        })?;
1034
1035        let mut writer = StringWriter::with_capacity(4096);
1036        let handler = WebUIHandler::with_plugin(|| {
1037            Box::new(webui_handler::plugin::webui::WebUIHydrationPlugin::new())
1038        });
1039        handler.render(
1040            &build_result.protocol,
1041            &Value::Object(Map::new()),
1042            &RenderOptions::new("index.html", "/"),
1043            &mut writer,
1044        )?;
1045
1046        fs::remove_dir_all(&tmp)?;
1047
1048        let html = restore_pre_blocks(&writer.buf, &blocks);
1049        assert!(
1050            html.contains("<pre><code>let x = 1;\n</code></pre>"),
1051            "slotted pre block should be restored after WebUI render: {html}"
1052        );
1053        assert!(
1054            !html.contains(PRE_BLOCK_MARKER_PREFIX),
1055            "placeholder marker must not survive output: {html}"
1056        );
1057
1058        Ok(())
1059    }
1060
1061    #[test]
1062    fn collect_ts_files_skips_tests_and_declarations() -> TestResult {
1063        let root = std::env::temp_dir().join(format!(
1064            "webui-press-ts-collect-test-{}-{:x}",
1065            std::process::id(),
1066            fxhash("ts-collect")
1067        ));
1068        if root.exists() {
1069            fs::remove_dir_all(&root)?;
1070        }
1071        fs::create_dir_all(root.join("my-widget"))?;
1072        fs::write(root.join("my-widget/my-widget.ts"), "")?;
1073        fs::write(root.join("my-widget/my-widget.spec.ts"), "")?;
1074        fs::write(root.join("my-widget/my-widget.test.ts"), "")?;
1075        fs::write(root.join("my-widget/my-widget.d.ts"), "")?;
1076
1077        let files = collect_ts_files(&root);
1078
1079        fs::remove_dir_all(&root)?;
1080
1081        assert_eq!(
1082            files,
1083            vec![root.join("my-widget/my-widget.ts")],
1084            "only hydration entry files should be bundled"
1085        );
1086        Ok(())
1087    }
1088
1089    // --- fxhash ----------------------------------------------------------
1090
1091    #[test]
1092    fn fxhash_deterministic() {
1093        assert_eq!(fxhash("abc"), fxhash("abc"));
1094        assert_ne!(fxhash("abc"), fxhash("abd"));
1095    }
1096}