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            plugin: Some(webui::Plugin::WebUI),
366            components: component_sources.clone(),
367            ..BuildOptions::default()
368        })
369        .map_err(|e| Error::Build(format!("{}: {e}", page.path)))?;
370
371        total_bytes.fetch_add(
372            build_result.protocol_bytes.len(),
373            std::sync::atomic::Ordering::Relaxed,
374        );
375
376        // Collect per-component CSS files (Link strategy emits one .css
377        // per component used on the page). Multiple pages share components,
378        // so we dedupe by filename in a shared map and write once after
379        // the parallel pass completes.
380        if !build_result.css_files.is_empty() {
381            let mut css_map = component_css
382                .lock()
383                .unwrap_or_else(std::sync::PoisonError::into_inner);
384            for (name, content) in build_result.css_files {
385                css_map.entry(name).or_insert(content);
386            }
387        }
388
389        let mut writer = StringWriter::with_capacity(8192);
390        handler
391            .render(
392                &build_result.protocol,
393                &page.state,
394                &RenderOptions::new("index.html", &page.path),
395                &mut writer,
396            )
397            .map_err(|e| Error::Render(format!("{}: {e}", page.path)))?;
398
399        // Restore the protected <pre> blocks via a single-pass scan.
400        let html = restore_pre_blocks(&writer.buf, &pre_blocks);
401
402        // Write directly inside the parallel closure.
403        fs::write(&target, &html)
404            .map_err(|e| Error::Io(format!("Cannot write {}: {e}", page.path)))?;
405
406        fs::remove_dir_all(&page_tmp).ok();
407        Ok(())
408    })?;
409
410    print_success(cache, &format!("Rendered {} pages", pages.len()));
411
412    // Step 4: Search index. Strip rendered HTML to plain text and emit
413    // an entry per non-home page. Done from-scratch every build.
414    let search_path = site_dir.join("search-index.json");
415    let search_entries: Vec<Value> = pages
416        .iter()
417        .filter(|p| !p.is_home)
418        .map(|p| {
419            let html = p.state["page"]["content"].as_str().unwrap_or("");
420            let title = p.state["page"]["title"].as_str().unwrap_or("");
421            build_search_entry(title, &p.path, html)
422        })
423        .collect();
424    fs::write(
425        &search_path,
426        serde_json::to_string(&search_entries)
427            .map_err(|e| Error::Build(format!("JSON error: {e}")))?,
428    )
429    .map_err(|e| Error::Io(e.to_string()))?;
430    print_success(
431        cache,
432        &format!("Indexed {} pages for search", search_entries.len()),
433    );
434
435    // Step 5: Copy static public assets.
436    let public_dir = Path::new(&config.public_dir);
437    if public_dir.exists() {
438        copy_dir(public_dir, &site_dir)?;
439    }
440
441    // Step 6: Generate 404 page
442    let nav_val = pages
443        .first()
444        .map(|p| p.state["navigation"].clone())
445        .unwrap_or_default();
446
447    let footer_val = config
448        .footer
449        .as_ref()
450        .map(|f| json_obj([("html", Value::String(f.html.clone()))]))
451        .unwrap_or(Value::Null);
452
453    let not_found_content = format!(
454        "<h1>404 — Page Not Found</h1>\
455         <p>The page you're looking for doesn't exist or has been moved.</p>\
456         <p><a href=\"{base_path}\">← Back to Home</a></p>"
457    );
458    let mut not_found_state = json_obj([
459        (
460            "site",
461            json_obj([
462                ("title", Value::String(config.site.title.clone())),
463                ("base", Value::String(base_path.to_string())),
464            ]),
465        ),
466        ("navigation", nav_val),
467        ("sidebar", json_obj([("sections", Value::Array(vec![]))])),
468        (
469            "page",
470            json_obj([
471                ("title", Value::String("Page Not Found".to_string())),
472                (
473                    "description",
474                    Value::String("The page you're looking for doesn't exist.".to_string()),
475                ),
476                ("content", Value::String(not_found_content.clone())),
477                ("isHome", Value::Bool(false)),
478                ("layout", Value::String("doc".to_string())),
479            ]),
480        ),
481        ("hero", Value::Null),
482        ("footer", footer_val),
483        ("prev", Value::Null),
484        ("next", Value::Null),
485    ]);
486    not_found_state["headTags"] = Value::String(head_injection);
487    not_found_state["label"] = Value::String("Copy".to_string());
488    not_found_state["icon"] = Value::String("🌙".to_string());
489
490    let not_found_html = template_html.replace("{{{page.content}}}", &not_found_content);
491    let nf_tmp = std::env::temp_dir().join(format!(
492        "webui-press-404-{}-{:x}",
493        std::process::id(),
494        REBUILD_NONCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
495    ));
496    if nf_tmp.exists() {
497        fs::remove_dir_all(&nf_tmp).ok();
498    }
499    fs::create_dir_all(&nf_tmp).map_err(|e| Error::Io(e.to_string()))?;
500    fs::write(nf_tmp.join("index.html"), &not_found_html).map_err(|e| Error::Io(e.to_string()))?;
501
502    let nf_build = webui::build(BuildOptions {
503        app_dir: nf_tmp.clone(),
504        entry: "index.html".to_string(),
505        plugin: Some(webui::Plugin::WebUI),
506        components: component_sources.clone(),
507        ..BuildOptions::default()
508    })
509    .map_err(|e| Error::Build(format!("404 build failed: {e}")))?;
510
511    // Fold the 404 page's component CSS into the shared map, then write
512    // all per-component stylesheets at site root in one pass. The handler
513    // emits relative hrefs like `<link rel="stylesheet" href="code-block.css">`;
514    // the template's <base href="{site.base}"> resolves them against site root.
515    {
516        let mut css_map = component_css
517            .lock()
518            .unwrap_or_else(std::sync::PoisonError::into_inner);
519        for (name, content) in &nf_build.css_files {
520            css_map
521                .entry(name.clone())
522                .or_insert_with(|| content.clone());
523        }
524    }
525
526    let mut writer_404 = StringWriter::with_capacity(4096);
527    handler
528        .render(
529            &nf_build.protocol,
530            &not_found_state,
531            &RenderOptions::new("index.html", &format!("{base_path}404/")),
532            &mut writer_404,
533        )
534        .map_err(|e| Error::Render(format!("404: {e}")))?;
535
536    fs::write(site_dir.join("404.html"), writer_404.buf).map_err(|e| Error::Io(e.to_string()))?;
537    fs::remove_dir_all(&nf_tmp).ok();
538    print_success(cache, "Generated 404 page");
539
540    // Step 7: Write per-component stylesheets collected during the
541    // parallel page builds (and the 404 build above). Sort by name for
542    // deterministic output.
543    let css_map_local = component_css
544        .into_inner()
545        .unwrap_or_else(std::sync::PoisonError::into_inner);
546    let mut css_names: Vec<&String> = css_map_local.keys().collect();
547    css_names.sort();
548    let css_count = css_names.len();
549    for name in css_names {
550        fs::write(site_dir.join(name), &css_map_local[name])
551            .map_err(|e| Error::Io(format!("Cannot write {name}: {e}")))?;
552    }
553    if css_count > 0 {
554        print_success(cache, &format!("Wrote {css_count} component stylesheets"));
555    }
556
557    // Step 8: Wait for the background bundling thread.
558    let component_count = bundle_handle
559        .join()
560        .map_err(|_| Error::Build("Bundle thread panicked".to_string()))??;
561    print_success(cache, &format!("Bundled {component_count} components"));
562
563    let elapsed = start.elapsed();
564    let total_bytes = total_bytes.load(std::sync::atomic::Ordering::Relaxed);
565    print_finish(cache, &format!("Built in {:.1}s", elapsed.as_secs_f64()));
566
567    Ok(BuildStats {
568        pages: pages.len(),
569        protocol_bytes: total_bytes,
570    })
571}
572
573/// Recursively remove a directory, retrying briefly on `ENOTEMPTY`.
574///
575/// On macOS (and occasionally Linux), `fs::remove_dir_all` can fail
576/// with `Directory not empty` when something writes a file into a
577/// subdirectory between the moment we walked it and the moment we try
578/// to `rmdir` it. Likely culprits during a dev-server rebuild:
579///
580///  - Spotlight / `mds` writing index hints,
581///  - Finder dropping a `.DS_Store`,
582///  - the user's editor reindexing the project,
583///  - a back-to-back save firing another rebuild before the first one
584///    finished cleanup (the worker coalesces, but a stray write into
585///    `out_dir` from a parallel `cargo` or another tool is still
586///    possible).
587///
588/// We retry up to a handful of times with short backoff. After that the
589/// error propagates so the user sees a real failure rather than an
590/// infinite loop.
591fn remove_dir_all_retry(path: &Path) -> std::io::Result<()> {
592    const MAX_ATTEMPTS: u32 = 5;
593    let mut delay = std::time::Duration::from_millis(20);
594    for attempt in 1..=MAX_ATTEMPTS {
595        match fs::remove_dir_all(path) {
596            Ok(()) => return Ok(()),
597            Err(e) if attempt < MAX_ATTEMPTS && is_dir_not_empty(&e) => {
598                std::thread::sleep(delay);
599                delay = delay.saturating_mul(2);
600            }
601            Err(e) => return Err(e),
602        }
603    }
604    // Loop guarantees a return on each iteration; this is unreachable
605    // but `for` returning `()` makes the type-checker happy without it.
606    Ok(())
607}
608
609/// Returns true if the error is `ENOTEMPTY` / `EEXIST` style "directory
610/// not empty". On macOS this is errno 66; on Linux it's 39. We also
611/// match by `ErrorKind` once Rust exposes one (currently `Other`).
612fn is_dir_not_empty(e: &std::io::Error) -> bool {
613    match e.raw_os_error() {
614        Some(66) => true,  // macOS / BSD: ENOTEMPTY
615        Some(39) => true,  // Linux: ENOTEMPTY
616        Some(145) => true, // Windows: ERROR_DIR_NOT_EMPTY
617        _ => false,
618    }
619}
620
621/// Iteratively copy a directory tree (BFS via stack — no recursion).
622fn copy_dir(src: &Path, dest: &Path) -> Result<()> {
623    if !src.exists() {
624        return Ok(());
625    }
626    fs::create_dir_all(dest).map_err(|e| Error::Io(e.to_string()))?;
627    let mut stack: Vec<(std::path::PathBuf, std::path::PathBuf)> =
628        vec![(src.to_path_buf(), dest.to_path_buf())];
629    while let Some((s, d)) = stack.pop() {
630        for entry in fs::read_dir(&s).map_err(|e| Error::Io(e.to_string()))? {
631            let entry = entry.map_err(|e| Error::Io(e.to_string()))?;
632            let dest_path = d.join(entry.file_name());
633            let ft = entry.file_type().map_err(|e| Error::Io(e.to_string()))?;
634            if ft.is_dir() {
635                fs::create_dir_all(&dest_path).map_err(|e| Error::Io(e.to_string()))?;
636                stack.push((entry.path(), dest_path));
637            } else {
638                fs::copy(entry.path(), &dest_path).map_err(|e| Error::Io(e.to_string()))?;
639            }
640        }
641    }
642    Ok(())
643}
644
645/// Truncate a string to at most `max` bytes at a valid UTF-8 boundary.
646pub(crate) fn truncate_utf8(s: &str, max: usize) -> &str {
647    if s.len() <= max {
648        return s;
649    }
650    let mut end = max;
651    while end > 0 && !s.is_char_boundary(end) {
652        end -= 1;
653    }
654    &s[..end]
655}
656
657/// Build a single search-index entry for a page. Strips HTML tags,
658/// collapses whitespace, and truncates to 500 bytes. Called once per
659/// page during descriptor construction in `process_content` so cache
660/// hits carry their entry forward verbatim.
661pub(crate) fn build_search_entry(title: &str, path: &str, html: &str) -> Value {
662    let mut clean = String::with_capacity(html.len() / 2);
663    let mut in_tag = false;
664    for ch in html.chars() {
665        match ch {
666            '<' => in_tag = true,
667            '>' => {
668                in_tag = false;
669                clean.push(' ');
670            }
671            _ if !in_tag => clean.push(ch),
672            _ => {}
673        }
674    }
675    // split_whitespace collapses arbitrary runs; we re-emit a single
676    // space between tokens. Avoiding `collect::<Vec<_>>().join(" ")` so
677    // we don't allocate an intermediate vector for sequential access.
678    let mut content = String::with_capacity(clean.len());
679    let mut first = true;
680    for tok in clean.split_whitespace() {
681        if !first {
682            content.push(' ');
683        }
684        content.push_str(tok);
685        first = false;
686    }
687    let truncated = truncate_utf8(&content, 500);
688    json_obj([
689        ("title", Value::String(title.to_string())),
690        ("path", Value::String(path.to_string())),
691        ("content", Value::String(truncated.to_string())),
692    ])
693}
694
695/// Collect all `.ts` files from a directory tree (iterative).
696fn collect_ts_files(dir: &Path) -> Vec<std::path::PathBuf> {
697    let mut files = Vec::new();
698    let mut stack = vec![dir.to_path_buf()];
699    while let Some(d) = stack.pop() {
700        let Ok(entries) = fs::read_dir(&d) else {
701            continue;
702        };
703        for entry in entries.flatten() {
704            let path = entry.path();
705            if path.is_dir() {
706                stack.push(path);
707            } else if path.extension().is_some_and(|e| e == "ts") {
708                files.push(path);
709            }
710        }
711    }
712    files.sort();
713    files
714}
715
716/// Bundle component TypeScript files into a single `components.js` via esbuild.
717/// Returns the number of components bundled.
718fn bundle_components(
719    template_dir: &Path,
720    component_sources: &[String],
721    site_dir: &Path,
722    node_modules: &Path,
723) -> Result<usize> {
724    let mut ts_files = Vec::new();
725
726    // Collect from user component directories
727    for dir in component_sources {
728        let p = Path::new(dir);
729        if p.exists() {
730            ts_files.extend(collect_ts_files(p));
731        }
732    }
733
734    // Collect from template subdirectories (docs-search, docs-theme-toggle, etc.)
735    if let Ok(entries) = fs::read_dir(template_dir) {
736        for entry in entries.flatten() {
737            if entry.path().is_dir() {
738                ts_files.extend(collect_ts_files(&entry.path()));
739            }
740        }
741    }
742
743    if ts_files.is_empty() {
744        return Ok(0);
745    }
746
747    // Generate the entry file content
748    let imports: String = ts_files
749        .iter()
750        .map(|f| format!("import \"{}\";", f.to_string_lossy().replace('\\', "/")))
751        .collect::<Vec<_>>()
752        .join("\n");
753
754    let out_file = site_dir.join("components.js");
755
756    let status = std::process::Command::new("npx")
757        .arg("esbuild")
758        .arg("--bundle")
759        .arg("--format=esm")
760        .arg("--minify")
761        .arg("--loader:.html=text")
762        .arg("--loader:.css=text")
763        .arg(format!("--outfile={}", out_file.to_string_lossy()))
764        .env("NODE_PATH", node_modules)
765        .stdin(std::process::Stdio::piped())
766        .stdout(std::process::Stdio::piped())
767        .stderr(std::process::Stdio::piped())
768        .spawn()
769        .and_then(|mut child| {
770            use std::io::Write;
771            if let Some(ref mut stdin) = child.stdin {
772                let _ = stdin.write_all(imports.as_bytes());
773            }
774            child.wait_with_output()
775        })
776        .map_err(|e| Error::Build(format!("esbuild failed: {e}")))?;
777
778    if !status.status.success() {
779        let stderr = String::from_utf8_lossy(&status.stderr);
780        return Err(Error::Build(format!("esbuild error: {stderr}")));
781    }
782
783    Ok(ts_files.len())
784}
785
786/// Replace `<pre …>…</pre>` blocks with placeholder comments so the WebUI
787/// HTML parser does not normalize whitespace inside them. Returns the
788/// modified string and the original blocks (in order) for restoration
789/// after rendering.
790fn protect_pre_blocks(content: &str) -> (String, Vec<String>) {
791    use std::fmt::Write as _;
792    let mut blocks: Vec<String> = Vec::new();
793    let mut out = String::with_capacity(content.len());
794    let mut cursor = 0;
795    while let Some(rel_start) = find_pre_open(&content[cursor..]) {
796        let start = cursor + rel_start;
797        if let Some(rel_end) = content[start..].find("</pre>") {
798            let end = start + rel_end + "</pre>".len();
799            out.push_str(&content[cursor..start]);
800            out.push_str("<!--PRE_BLOCK_");
801            // write! into existing buffer — avoids `format!` allocation per block.
802            let _ = write!(&mut out, "{}", blocks.len());
803            out.push_str("-->");
804            blocks.push(content[start..end].to_string());
805            cursor = end;
806        } else {
807            break;
808        }
809    }
810    out.push_str(&content[cursor..]);
811    (out, blocks)
812}
813
814/// Find the next opening `<pre` tag where the next byte is one of `>`, ` `,
815/// `\t`, `\n`, or `\r`. Avoids matching `<presentation`, `<pretend`, etc.
816fn find_pre_open(s: &str) -> Option<usize> {
817    let bytes = s.as_bytes();
818    let mut i = 0;
819    while let Some(rel) = s[i..].find("<pre") {
820        let pos = i + rel;
821        let after = pos + 4;
822        match bytes.get(after) {
823            Some(b'>' | b' ' | b'\t' | b'\n' | b'\r') => return Some(pos),
824            _ => i = after,
825        }
826    }
827    None
828}
829
830/// Single-pass restoration of `<!--PRE_BLOCK_N-->` placeholders to their
831/// original content. Faster than calling `String::replace` once per block.
832fn restore_pre_blocks(html: &str, blocks: &[String]) -> String {
833    if blocks.is_empty() {
834        return html.to_string();
835    }
836    const PREFIX: &str = "<!--PRE_BLOCK_";
837    let extra: usize = blocks.iter().map(|b| b.len()).sum();
838    let mut out = String::with_capacity(html.len() + extra);
839    let mut cursor = 0;
840    while let Some(rel) = html[cursor..].find(PREFIX) {
841        let p = cursor + rel;
842        out.push_str(&html[cursor..p]);
843        let after = p + PREFIX.len();
844        if let Some(end_rel) = html[after..].find("-->") {
845            let num_str = &html[after..after + end_rel];
846            if let Ok(idx) = num_str.parse::<usize>() {
847                if let Some(block) = blocks.get(idx) {
848                    out.push_str(block);
849                    cursor = after + end_rel + 3;
850                    continue;
851                }
852            }
853            // Unknown placeholder — keep verbatim.
854            out.push_str(&html[p..after + end_rel + 3]);
855            cursor = after + end_rel + 3;
856        } else {
857            out.push_str(&html[p..]);
858            return out;
859        }
860    }
861    out.push_str(&html[cursor..]);
862    out
863}
864
865/// Cheap deterministic hash for building unique temp directory names.
866fn fxhash(s: &str) -> u64 {
867    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
868    for b in s.bytes() {
869        hash ^= u64::from(b);
870        hash = hash.wrapping_mul(0x100_0000_01b3);
871    }
872    hash
873}
874
875#[cfg(test)]
876mod tests {
877    use super::*;
878
879    // --- truncate_utf8 ---------------------------------------------------
880
881    #[test]
882    fn truncate_utf8_short_string_unchanged() {
883        assert_eq!(truncate_utf8("hello", 100), "hello");
884    }
885
886    #[test]
887    fn truncate_utf8_ascii_at_exact_boundary() {
888        assert_eq!(truncate_utf8("hello world", 5), "hello");
889    }
890
891    #[test]
892    fn truncate_utf8_steps_back_off_multibyte_boundary() {
893        // "é" is two bytes (0xC3 0xA9). Cutting at 1 byte must step back to 0.
894        let s = "é-suffix";
895        let out = truncate_utf8(s, 1);
896        assert!(s.is_char_boundary(out.len()), "out.len()={}", out.len());
897        assert_eq!(out, "");
898    }
899
900    #[test]
901    fn truncate_utf8_keeps_multibyte_when_room_allows() {
902        let s = "café";
903        let out = truncate_utf8(s, 5); // "café" is 5 bytes total
904        assert_eq!(out, "café");
905    }
906
907    // --- protect_pre_blocks / find_pre_open ------------------------------
908
909    #[test]
910    fn protect_pre_blocks_with_attrs() {
911        let input = r#"<p>before</p><pre class="hljs">code</pre><p>after</p>"#;
912        let (out, blocks) = protect_pre_blocks(input);
913        assert_eq!(blocks.len(), 1);
914        assert!(out.contains("<!--PRE_BLOCK_0-->"));
915        assert!(!out.contains("<pre"));
916        assert_eq!(blocks[0], r#"<pre class="hljs">code</pre>"#);
917    }
918
919    #[test]
920    fn protect_pre_blocks_bare_open_tag() {
921        // Was previously missed because we only matched "<pre " (with space).
922        let input = "<pre>code</pre>";
923        let (out, blocks) = protect_pre_blocks(input);
924        assert_eq!(blocks.len(), 1, "got out={out:?} blocks={blocks:?}");
925        assert_eq!(blocks[0], "<pre>code</pre>");
926    }
927
928    #[test]
929    fn protect_pre_blocks_does_not_match_presentation_tag() {
930        // <presentation> must NOT be treated as a pre block.
931        let input = "<presentation>x</presentation>";
932        let (_out, blocks) = protect_pre_blocks(input);
933        assert_eq!(blocks.len(), 0);
934    }
935
936    #[test]
937    fn protect_then_restore_round_trip() {
938        let input = "before <pre>a</pre> mid <pre class=\"x\">b</pre> end";
939        let (protected, blocks) = protect_pre_blocks(input);
940        let restored = restore_pre_blocks(&protected, &blocks);
941        assert_eq!(restored, input);
942    }
943
944    #[test]
945    fn restore_pre_blocks_no_blocks_returns_input() {
946        assert_eq!(restore_pre_blocks("plain html", &[]), "plain html");
947    }
948
949    // --- fxhash ----------------------------------------------------------
950
951    #[test]
952    fn fxhash_deterministic() {
953        assert_eq!(fxhash("abc"), fxhash("abc"));
954        assert_ne!(fxhash("abc"), fxhash("abd"));
955    }
956}