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