Skip to main content

docgen_build/
lib.rs

1//! The reusable site-build pipeline: discover -> render -> emit the whole
2//! `docs/` tree into an output dir. Both `docgen build` and the dev server
3//! (`docgen-server`) call [`build_site`], so the pipeline lives here once
4//! rather than inline in the bin.
5//!
6//! [`build_site`] loads an optional `docgen.toml` (`docgen-config`) and builds a
7//! custom-component registry (`docgen-components`: embedded built-ins overridden
8//! by a project `components/` dir). Config gates the graph/search/math/mermaid
9//! features and supplies the site title/`base`; the registry drives directive
10//! rendering and per-page component asset emission.
11
12// Retained for its timeline-bucket grouping logic + tests; the per-doc history
13// page it fed was superseded by the global `/diff` workspace.
14#[allow(dead_code)]
15mod history;
16
17pub mod incremental;
18
19pub use incremental::{DevState, RebuildKind, Rebuilt};
20
21use std::fs;
22use std::path::{Path, PathBuf};
23
24use anyhow::{Context, Result};
25use chrono::Local;
26use docgen_core::discover::discover_docs;
27use docgen_core::pipeline::{prepare, render_docs};
28use docgen_core::tree::build_tree;
29use docgen_render::{
30    GraphContext, HomeData, HomeRecent, HomeSection, PageContext, Renderer, DEFAULT_PAGE_TEMPLATE,
31};
32
33/// The slug docgen treats as the site home (served at `/`).
34const HOME_SLUG: &str = "index";
35
36/// Lightweight per-phase build timer. Inert unless `DOCGEN_TIMINGS` is set in
37/// the environment, in which case each [`mark`](PhaseTimer::mark) records the
38/// elapsed time since the previous mark and [`report`](PhaseTimer::report)
39/// prints the breakdown to stderr. Used to profile where a (re)build spends its
40/// time — see the dev-server incremental-rebuild work.
41struct PhaseTimer {
42    enabled: bool,
43    last: std::time::Instant,
44    start: std::time::Instant,
45    rows: Vec<(&'static str, u128)>,
46}
47
48impl PhaseTimer {
49    fn new() -> Self {
50        let now = std::time::Instant::now();
51        Self {
52            enabled: std::env::var_os("DOCGEN_TIMINGS").is_some(),
53            last: now,
54            start: now,
55            rows: Vec::new(),
56        }
57    }
58
59    fn mark(&mut self, label: &'static str) {
60        if !self.enabled {
61            return;
62        }
63        let now = std::time::Instant::now();
64        self.rows
65            .push((label, now.duration_since(self.last).as_millis()));
66        self.last = now;
67    }
68
69    fn report(&self) {
70        if !self.enabled {
71            return;
72        }
73        let total = self.start.elapsed().as_millis();
74        eprintln!("── build timings (ms) ──");
75        for (label, ms) in &self.rows {
76            eprintln!("  {label:<16} {ms:>6}");
77        }
78        eprintln!("  {:<16} {total:>6}", "TOTAL");
79    }
80}
81
82/// Default per-doc commit-history depth (parity with the original `diffLimit`).
83const DEFAULT_DIFF_LIMIT: usize = 50;
84/// Hard cap so a pathological `DOC_DIFF_LIMIT` can't blow up build time.
85const MAX_DIFF_LIMIT: usize = 200;
86
87fn diff_limit() -> usize {
88    std::env::var("DOC_DIFF_LIMIT")
89        .ok()
90        .and_then(|v| v.parse::<usize>().ok())
91        .unwrap_or(DEFAULT_DIFF_LIMIT)
92        .clamp(1, MAX_DIFF_LIMIT)
93}
94
95/// Compute the doc path as git sees it, relative to the repo working directory.
96/// e.g. docs_dir `/repo/docs`, workdir `/repo/`, `rel_path` `guide/intro.md`
97/// -> `docs/guide/intro.md`. Returns `None` if `docs_dir` is not under `workdir`.
98fn git_rel_path(docs_dir: &Path, workdir: &Path, rel_path: &str) -> Option<String> {
99    // Canonicalize both sides: on macOS `env::temp_dir()` yields `/var/...`
100    // while git2's `workdir()` resolves the `/private/var` symlink, so a raw
101    // `strip_prefix` would spuriously fail.
102    let docs_canon = docs_dir.canonicalize().ok();
103    let work_canon = workdir.canonicalize().ok();
104    let (docs_ref, work_ref) = match (&docs_canon, &work_canon) {
105        (Some(d), Some(w)) => (d.as_path(), w.as_path()),
106        _ => (docs_dir, workdir),
107    };
108    let prefix = docs_ref.strip_prefix(work_ref).ok()?;
109    let mut parts: Vec<String> = prefix
110        .components()
111        .map(|c| c.as_os_str().to_string_lossy().into_owned())
112        .collect();
113    parts.push(rel_path.to_string());
114    Some(parts.join("/"))
115}
116
117/// Whether this build is for static distribution or for the dev server.
118///
119/// [`build_site`] emits ONLY production assets in BOTH modes; the dev server
120/// adds dev-only assets/HTML itself, AFTER `build_site` returns. The mode is
121/// recorded for logging + so the dev server can assert its build context.
122#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
123pub enum BuildMode {
124    #[default]
125    Production,
126    Dev,
127}
128
129/// Inputs to a full site build.
130pub struct BuildOptions<'a> {
131    /// Project root containing `docs/`.
132    pub project_root: &'a Path,
133    /// Where the static site is written. `docgen build` passes `project_root/dist`;
134    /// the dev server passes a temp dir it owns.
135    pub out_dir: &'a Path,
136    pub mode: BuildMode,
137}
138
139/// Result of a build (counts for logging; extend later if needed).
140#[derive(Debug, Clone)]
141pub struct BuildOutcome {
142    pub page_count: usize,
143    pub any_mermaid: bool,
144    pub out_dir: PathBuf,
145}
146
147/// Back-compat thin wrapper used by `docgen build`: builds `root/docs` into
148/// `root/dist` in Production mode. Equivalent to the old `build::build(root)`.
149pub fn build(project_root: &Path) -> Result<BuildOutcome> {
150    build_site(&BuildOptions {
151        project_root,
152        out_dir: &project_root.join("dist"),
153        mode: BuildMode::Production,
154    })
155}
156
157/// Whole-site inputs shared by every per-page render — everything a
158/// [`PageContext`] needs that is NOT derived from the individual [`Doc`]. Built
159/// once per (re)build and reused for every page, so the dev server's incremental
160/// path can re-render a single changed page (via [`render_one_page`]) without
161/// recomputing the rest of the site.
162pub(crate) struct PageShared<'a> {
163    pub tree: &'a [docgen_core::model::TreeNode],
164    pub graph: &'a docgen_core::graph::LinkGraph,
165    pub commit: &'a str,
166    pub built: &'a str,
167    pub base: &'a str,
168    pub site_title: &'a str,
169    pub search_enabled: bool,
170    pub has_diff: bool,
171    pub has_components_css: bool,
172    pub island_components: &'a std::collections::BTreeSet<String>,
173    pub graph_payload: &'a Option<(String, usize, usize)>,
174    pub home_sections: &'a [HomeSection<'a>],
175    pub home_recent: &'a [HomeRecent<'a>],
176    pub pages_count: usize,
177    pub total_links: usize,
178}
179
180/// Render ONE doc to its full-page HTML. The single source of truth shared by
181/// the full-build page loop and the dev server's incremental re-render, so a
182/// page rebuilt incrementally is byte-identical to the same page in a full
183/// build. Pure (no disk I/O); the caller writes the result.
184pub(crate) fn render_one_page(
185    renderer: &Renderer,
186    shared: &PageShared,
187    doc: &docgen_core::model::Doc,
188) -> Result<String> {
189    // A doc with no inbound links has no backlinks entry; borrow a shared empty.
190    static EMPTY: Vec<docgen_core::model::Backlink> = Vec::new();
191    let backlinks = shared.graph.backlinks.get(&doc.slug).unwrap_or(&EMPTY);
192    let is_home = doc.slug == HOME_SLUG;
193    // Only the home doc carries the graph payload (empty graph_json → the
194    // template skips the block + the graph island script).
195    let (graph_json, graph_node_count, graph_edge_count) = match (is_home, shared.graph_payload) {
196        (true, Some((json, nodes, edges))) => (json.as_str(), *nodes, *edges),
197        _ => ("", 0, 0),
198    };
199    Ok(renderer.render_page(&PageContext {
200        title: &doc.title,
201        // Frontmatter description → page header lede (non-home pages). The home
202        // dashboard consumes it via `HomeData.description` instead.
203        description: if is_home {
204            ""
205        } else {
206            doc.description.as_deref().unwrap_or("")
207        },
208        slug: &doc.slug,
209        body_html: &doc.body_html,
210        tree: shared.tree,
211        backlinks,
212        headings: &doc.headings,
213        commit: shared.commit,
214        built: shared.built,
215        has_history: false,
216        has_mermaid: doc.has_mermaid,
217        has_math: doc.has_math,
218        base: shared.base,
219        site_title: shared.site_title,
220        search_enabled: shared.search_enabled,
221        has_diff: shared.has_diff,
222        has_components_css: shared.has_components_css,
223        has_component_island: doc
224            .components_used
225            .iter()
226            .any(|c| shared.island_components.contains(c)),
227        is_home,
228        graph_json,
229        graph_node_count,
230        graph_edge_count,
231        home: if is_home {
232            Some(HomeData {
233                description: doc.description.as_deref().unwrap_or(""),
234                pages: shared.pages_count,
235                links: shared.total_links,
236                sections: shared.home_sections,
237                recent: shared.home_recent,
238            })
239        } else {
240            None
241        },
242    })?)
243}
244
245/// Compute the home dashboard's section + recent rows from the doc set (owned, so
246/// the caller can hold them across the borrowed [`HomeSection`]/[`HomeRecent`]
247/// views). "Sections" = top-level folders ordered by first appearance, each with
248/// a doc count and a link to its first page; "Recent" = the first 6 docs in build
249/// order (home excluded). Shared by the full build and the incremental engine.
250#[allow(clippy::type_complexity)]
251pub(crate) fn compute_home_rows(
252    docs: &[docgen_core::model::Doc],
253) -> (Vec<(String, String, usize)>, Vec<(String, String, String)>) {
254    let mut section_rows: Vec<(String, String, usize)> = Vec::new();
255    let mut section_idx: std::collections::BTreeMap<String, usize> =
256        std::collections::BTreeMap::new();
257    for doc in docs {
258        if doc.slug == HOME_SLUG || !doc.slug.contains('/') {
259            continue;
260        }
261        let label = doc.slug.split('/').next().unwrap_or("").to_string();
262        match section_idx.get(&label) {
263            Some(&i) => section_rows[i].2 += 1,
264            None => {
265                section_idx.insert(label.clone(), section_rows.len());
266                section_rows.push((label, doc.slug.clone(), 1));
267            }
268        }
269    }
270    let recent_rows: Vec<(String, String, String)> = docs
271        .iter()
272        .filter(|d| d.slug != HOME_SLUG)
273        .take(6)
274        .map(|d| {
275            let section = d
276                .slug
277                .split_once('/')
278                .map(|(s, _)| s.to_string())
279                .unwrap_or_default();
280            (d.title.clone(), d.slug.clone(), section)
281        })
282        .collect();
283    (section_rows, recent_rows)
284}
285
286/// Discover -> render -> emit the whole site into `opts.out_dir`. This is the
287/// single pipeline both `docgen build` and `docgen dev` call.
288///
289/// The build is **atomic**: the whole site is rendered into a fresh staging dir
290/// (a sibling temp dir) and only swapped into `out_dir` on full success. A
291/// failure anywhere in the pipeline therefore leaves any pre-existing `out_dir`
292/// untouched — so the dev server genuinely keeps serving the last good build
293/// (the swap is the only step that mutates `out_dir`). Emits NO dev-only surface
294/// (editor/livereload) in either mode — the dev server layers that on afterward.
295/// The one mode-dependent emission is the mermaid runtime: Dev ships it
296/// unconditionally so the editor preview can render a just-typed diagram.
297pub fn build_site(opts: &BuildOptions) -> Result<BuildOutcome> {
298    Ok(build_site_inner(opts, false)?.0)
299}
300
301/// The full-build implementation behind [`build_site`]. When `capture` is true it
302/// additionally returns a [`CapturedBuild`] holding the in-memory artifacts (docs,
303/// graph, layout, tree, …) so the dev server can seed its incremental engine from
304/// the initial build without a second pass. `capture = false` (the production
305/// path) clones nothing extra.
306pub(crate) fn build_site_inner(
307    opts: &BuildOptions,
308    capture: bool,
309) -> Result<(BuildOutcome, Option<crate::incremental::CapturedBuild>)> {
310    let docs_dir = opts.project_root.join("docs");
311    let final_dir = opts.out_dir;
312    let mut t = PhaseTimer::new();
313
314    let raws = discover_docs(&docs_dir)
315        .with_context(|| format!("reading docs from {}", docs_dir.display()))?;
316    t.mark("discover");
317
318    // Split include-only partials (`_*.md`) out of the page set; they render
319    // only where a `:include` transcludes them, never as standalone pages.
320    let (pages, partials) = docgen_core::pipeline::partition_partials(raws);
321    // Two-pass: prepare all pages, then render with full slug knowledge.
322    let prepared: Vec<_> = pages.into_iter().map(prepare).collect();
323    t.mark("prepare");
324    // Only the incremental engine needs the prepared docs preserved past the
325    // render pass; the production path skips the clone.
326    let prepared_cap = if capture {
327        Some(prepared.clone())
328    } else {
329        None
330    };
331    // Load `docgen.toml` (absent → defaults reproduce pre-P6 behaviour).
332    let mut config = docgen_config::load(opts.project_root)
333        .with_context(|| format!("loading docgen.toml from {}", opts.project_root.display()))?;
334    // Resolve the effective deploy base: DOCGEN_BASE override → docgen.toml `base`
335    // → GitLab Pages auto-detect (CI_PAGES_URL / CI_PROJECT_PATH) → root. This is
336    // what makes a sub-path Pages deploy work with no per-project CI config, and
337    // it normalizes hand-written `base` values too.
338    config.base = docgen_config::resolve_base(&config.base);
339    // Build the component registry: embedded built-ins first, then project
340    // `components/<name>/` (which override a built-in of the same name).
341    let builtins: Vec<docgen_components::Component> = docgen_assets::builtin_components()
342        .into_iter()
343        .map(|b| {
344            docgen_components::Component::from_parts(
345                b.name,
346                b.template,
347                b.island_js.map(str::to_string),
348                b.style_css.map(str::to_string),
349            )
350        })
351        .collect();
352    let components_dir = opts.project_root.join(&config.components.dir);
353    let registry = docgen_components::build_registry(builtins, &components_dir)
354        .with_context(|| format!("discovering components in {}", components_dir.display()))?;
355    t.mark("config+registry");
356    let site = render_docs(prepared, &partials, &config, &registry);
357    t.mark("render_docs");
358    let tree = build_tree(&site.docs);
359    t.mark("build_tree");
360
361    // Concatenate the component asset bundle. `components.css` carries every
362    // registry component's style (small + cacheable, linked on every page that
363    // has any style); `components.js` carries only the island.js of components
364    // actually *used* across the site (per-page link gating below decides which
365    // pages reference it). Emitted in BTreeMap name-key order (deterministic).
366    let has_components_css = !registry.styles().is_empty();
367    let used_components: std::collections::BTreeSet<&str> = site
368        .docs
369        .iter()
370        .flat_map(|d| d.components_used.iter().map(String::as_str))
371        .collect();
372    let component_css: String = registry
373        .styles()
374        .iter()
375        .filter_map(|c| c.style_css.as_deref())
376        .collect::<Vec<_>>()
377        .join("\n");
378    let component_js: String = registry
379        .islands()
380        .iter()
381        .filter(|c| used_components.contains(c.name.as_str()))
382        .filter_map(|c| c.island_js.as_deref())
383        .collect::<Vec<_>>()
384        .join("\n");
385    // Set of components that ship an island AND were used → drives per-page gating.
386    let island_components: std::collections::BTreeSet<String> = registry
387        .islands()
388        .iter()
389        .filter(|c| used_components.contains(c.name.as_str()))
390        .map(|c| c.name.clone())
391        .collect();
392
393    let renderer = Renderer::new(DEFAULT_PAGE_TEMPLATE)?;
394
395    // Render the whole site into a fresh staging dir; only swap it into
396    // `final_dir` once everything below succeeds. This makes the build atomic:
397    // a failure leaves any existing `final_dir` (the last good build) intact.
398    // Staging lives alongside `final_dir` so the final move is a same-filesystem
399    // rename (atomic) rather than a cross-device copy.
400    let staging = StagingDir::new(final_dir)?;
401    let dist_dir = staging.path();
402
403    // Phase 1: build the per-doc git history pages (graceful no-op outside a
404    // git repo or for docs with no commit history). Collect which slugs got a
405    // history page so the doc page can conditionally show its "History" link.
406    let repo = docgen_diff::discover_repo(&docs_dir)
407        .with_context(|| format!("discovering git repo for {}", docs_dir.display()))?;
408    // Build metadata for the right-rail "Additional info" section. Best-effort:
409    // the short HEAD hash is empty outside a git repo (the Commit row is then
410    // omitted by the template); `built` is the wall-clock build time.
411    let now = Local::now();
412    let built_stamp = now.format("%Y-%m-%d %H:%M").to_string();
413    let mut commit_hash = String::new();
414    // Whether the interactive `/diff/` workspace was emitted (repo has doc
415    // history) — drives the topbar diff icon + the diff asset slice.
416    let mut has_diff = false;
417    if let Some(repo) = repo {
418        if let Ok(head) = repo.head() {
419            if let Some(oid) = head.target() {
420                let s = oid.to_string();
421                commit_hash = s.chars().take(7).collect();
422            }
423        }
424        if let Some(workdir) = repo.workdir().map(Path::to_path_buf) {
425            // The docs dir as git sees it, e.g. `docs` (trailing slash trimmed —
426            // `git_rel_path` joins an empty leaf to the prefix).
427            if let Some(docs_prefix) =
428                git_rel_path(&docs_dir, &workdir, "").map(|p| p.trim_end_matches('/').to_string())
429            {
430                let limit = diff_limit();
431                // The global doc-diff report across all docs, with rendered block
432                // diffs — the analogue of the original `/docs/diff` payload.
433                let report =
434                    docgen_diff::build_global_doc_diff_report(&repo, &docs_prefix, limit, true)
435                        .with_context(|| {
436                            format!("building global doc diff report for {docs_prefix}")
437                        })?;
438                t.mark("diff_report");
439                if let Some(report) = report {
440                    let diff_dir = dist_dir.join("diff");
441                    fs::create_dir_all(diff_dir.join("revisions"))?;
442                    // timeline.json — the lightweight index (hunks/blocks stripped).
443                    let summary = docgen_diff::summarize_report(&report);
444                    fs::write(
445                        diff_dir.join("timeline.json"),
446                        serde_json::to_vec(&summary)?,
447                    )?;
448                    // revisions/<id>.json — each commit's full per-file block diff,
449                    // lazily fetched by the island when a commit is selected.
450                    for point in &report.timeline {
451                        fs::write(
452                            diff_dir
453                                .join("revisions")
454                                .join(format!("{}.json", point.id)),
455                            serde_json::to_vec(point)?,
456                        )?;
457                    }
458                    // The /diff workspace shell (hydrated client-side by diff.js).
459                    let diff_html = renderer.render_diff(&docgen_render::DiffContext {
460                        tree: &tree,
461                        base: &config.base,
462                        site_title: config.title.as_deref().unwrap_or(""),
463                        search_enabled: config.features.search,
464                    })?;
465                    fs::write(diff_dir.join("index.html"), diff_html)?;
466                    has_diff = true;
467                }
468            }
469        }
470    }
471
472    // Force-layout graph data, computed once when the graph feature is on, and
473    // reused by both the home-page embed (Phase 2) and the standalone /graph
474    // page (Phase 3). The original docgen surfaces the graph ON the home page
475    // (not in the sidebar), so the home doc gets the graph block.
476    let graph_payload: Option<(String, usize, usize)> = if config.features.graph {
477        let graph_data = site.graph_data(docgen_core::graphlayout::LayoutParams::default());
478        let graph_json = docgen_core::graphlayout::graph_data_json(&graph_data);
479        Some((graph_json, graph_data.nodes.len(), graph_data.edges.len()))
480    } else {
481        None
482    };
483    t.mark("graph_layout");
484
485    // Home dashboard data (mirrors the original home: title/stats/sections/recent).
486    // "Sections" = top-level folders (the sidebar's grouping), ordered by first
487    // appearance, each linking to its first page with a doc count. "Recent" = the
488    // first docs in build order (home excluded). Computed once; only the index
489    // doc's render consumes it (every other page passes `home: None`).
490    let total_links = site.graph.edges.len();
491    let (section_rows, recent_rows) = compute_home_rows(&site.docs);
492    let home_sections: Vec<HomeSection> = section_rows
493        .iter()
494        .map(|(label, slug, count)| HomeSection {
495            label,
496            slug,
497            count: *count,
498        })
499        .collect();
500    let home_recent: Vec<HomeRecent> = recent_rows
501        .iter()
502        .map(|(title, slug, section)| HomeRecent {
503            title,
504            slug,
505            section,
506        })
507        .collect();
508
509    let shared = PageShared {
510        tree: &tree,
511        graph: &site.graph,
512        commit: &commit_hash,
513        built: &built_stamp,
514        base: &config.base,
515        site_title: config.title.as_deref().unwrap_or(""),
516        search_enabled: config.features.search,
517        has_diff,
518        has_components_css,
519        island_components: &island_components,
520        graph_payload: &graph_payload,
521        home_sections: &home_sections,
522        home_recent: &home_recent,
523        pages_count: site.docs.len(),
524        total_links,
525    };
526
527    // Phase 2: render the doc pages via the shared per-page renderer.
528    let mut home_html: Option<String> = None;
529    for doc in &site.docs {
530        let html = render_one_page(&renderer, &shared, doc)?;
531        // `guide/intro` -> `dist/guide/intro/index.html` (clean URLs).
532        let out_dir = dist_dir.join(&doc.slug);
533        fs::create_dir_all(&out_dir)?;
534        fs::write(out_dir.join("index.html"), &html)?;
535        if doc.slug == HOME_SLUG {
536            home_html = Some(html);
537        }
538    }
539
540    t.mark("render_pages");
541
542    // Root page: serve the home doc at `/` too, so the site has a real index.
543    // The nested `dist/index/index.html` is still emitted above, so existing
544    // `/index` links keep working — this is purely additive.
545    if let Some(html) = home_html {
546        fs::write(dist_dir.join("index.html"), html)?;
547    }
548
549    // 404 page: a full app-shell page (sidebar + search + theme) so a miss lands
550    // somewhere navigable instead of bare "not found". The dev server serves this
551    // on any unresolved path; static hosts (GitHub Pages, Netlify, …) pick up
552    // `404.html` by convention too.
553    let not_found_html = renderer.render_page(&PageContext {
554        title: "404",
555        description: "This page could not be found.",
556        slug: "404",
557        body_html: "<p>The page you’re looking for doesn’t exist or was moved. \
558            Use the navigation sidebar or search (<kbd class=\"docgen-kbd\">⌘K</kbd>) \
559            to find your way.</p>",
560        tree: &tree,
561        backlinks: &[],
562        headings: &[],
563        commit: &commit_hash,
564        built: &built_stamp,
565        has_history: false,
566        has_mermaid: false,
567        has_math: false,
568        base: &config.base,
569        site_title: config.title.as_deref().unwrap_or(""),
570        search_enabled: config.features.search,
571        has_diff,
572        has_components_css: false,
573        has_component_island: false,
574        is_home: false,
575        graph_json: "",
576        graph_node_count: 0,
577        graph_edge_count: 0,
578        home: None,
579    })?;
580    fs::write(dist_dir.join("404.html"), not_found_html)?;
581
582    // Phase 3: the standalone /graph/ page (default-on, gated off by `[features]
583    // graph = false`). Reuses the force layout computed above — never recomputes.
584    if let Some((graph_json, node_count, edge_count)) = &graph_payload {
585        let graph_html = renderer.render_graph(&GraphContext {
586            tree: &tree,
587            graph_json,
588            node_count: *node_count,
589            edge_count: *edge_count,
590            base: &config.base,
591            site_title: config.title.as_deref().unwrap_or(""),
592            search_enabled: config.features.search,
593            has_diff,
594        })?;
595        let graph_dir = dist_dir.join("graph");
596        fs::create_dir_all(&graph_dir)?;
597        fs::write(graph_dir.join("index.html"), graph_html)?;
598    }
599
600    // Static search index (gated off by `[features] search = false`).
601    if config.features.search {
602        fs::write(
603            dist_dir.join("search-index.json"),
604            docgen_core::search::index_json(&site.search),
605        )?;
606    }
607
608    // All vendored + authored client assets flow through docgen-assets. Mermaid
609    // (lib + island) ships only when a page used a diagram; math renders at build
610    // time (the default), so no runtime KaTeX JS ships. The graph island ships
611    // only when the /graph/ page is emitted.
612    let emit_opts = docgen_assets::EmitOptions {
613        include_katex_runtime: false,
614        // Production gates the mermaid lib + island on actual usage. In Dev we
615        // ship them unconditionally so the editor's live preview can render a
616        // diagram the moment it's typed — before the first save+rebuild that would
617        // flip `any_mermaid`. These are production assets (no dev-only surface), so
618        // shipping a superset in dev keeps the build's "no dev assets on disk in a
619        // static build" invariant (editor/livereload) untouched.
620        include_mermaid: site.any_mermaid || opts.mode == BuildMode::Dev,
621        include_graph: config.features.graph,
622        include_diff: has_diff,
623        // Component bundles are written separately (B-8) via emit_component_bundle;
624        // these flags are inert in assets_for.
625        include_component_css: false,
626        include_component_js: false,
627        // Honour `[features] search = false`: the page template already gates the
628        // search trigger + script link, so the client script would otherwise be an
629        // orphan file in the dist.
630        include_search: config.features.search,
631    };
632    docgen_assets::emit(&docgen_assets::assets_for(&emit_opts), dist_dir)?;
633
634    // Authored component bundle (dynamic bytes concatenated from the registry).
635    // Empty strings skip their file.
636    docgen_assets::emit_component_bundle(dist_dir, &component_css, &component_js)?;
637
638    t.mark("emit_assets");
639
640    // Everything rendered cleanly: atomically swap staging into place. Only now
641    // is the previously-served `final_dir` replaced.
642    staging.commit(final_dir)?;
643    t.mark("commit");
644    t.report();
645
646    let page_count = site.docs.len();
647    let any_mermaid = site.any_mermaid;
648    let outcome = BuildOutcome {
649        page_count,
650        any_mermaid,
651        out_dir: final_dir.to_path_buf(),
652    };
653
654    // Seed the dev server's incremental engine from the artifacts this build
655    // already computed — no second render pass.
656    let captured = if capture {
657        Some(crate::incremental::CapturedBuild {
658            config,
659            registry,
660            partials,
661            prepared: prepared_cap.expect("prepared is cloned whenever capture is set"),
662            docs: site.docs,
663            outbound: site.outbound,
664            graph: site.graph,
665            tree,
666            graph_payload,
667            island_components,
668            has_components_css,
669            commit_hash,
670            built_stamp,
671            has_diff,
672            search: site.search,
673        })
674    } else {
675        None
676    };
677
678    Ok((outcome, captured))
679}
680
681/// A staging directory for an atomic build. Renders happen here; [`commit`]
682/// swaps it into the final location only on success. If dropped without
683/// committing (i.e. the build errored), the staging dir is best-effort removed,
684/// leaving the previous `final_dir` untouched.
685///
686/// [`commit`]: StagingDir::commit
687struct StagingDir {
688    path: PathBuf,
689    committed: bool,
690}
691
692impl StagingDir {
693    /// Create a fresh, empty staging dir as a sibling of `final_dir` (same
694    /// filesystem -> the final rename is atomic).
695    fn new(final_dir: &Path) -> Result<Self> {
696        let parent = final_dir
697            .parent()
698            .map(Path::to_path_buf)
699            .unwrap_or_else(|| PathBuf::from("."));
700        fs::create_dir_all(&parent)
701            .with_context(|| format!("creating staging parent {}", parent.display()))?;
702        let file_name = final_dir
703            .file_name()
704            .map(|n| n.to_string_lossy().into_owned())
705            .unwrap_or_else(|| "dist".to_string());
706        let path = parent.join(format!(".{file_name}.staging-{}", std::process::id()));
707        let _ = fs::remove_dir_all(&path);
708        fs::create_dir_all(&path)
709            .with_context(|| format!("creating staging dir {}", path.display()))?;
710        Ok(Self {
711            path,
712            committed: false,
713        })
714    }
715
716    fn path(&self) -> &Path {
717        &self.path
718    }
719
720    /// Atomically replace `final_dir` with the staging dir. Removes any existing
721    /// `final_dir` first, then renames staging into place.
722    fn commit(mut self, final_dir: &Path) -> Result<()> {
723        let _ = fs::remove_dir_all(final_dir);
724        if let Some(parent) = final_dir.parent() {
725            fs::create_dir_all(parent)?;
726        }
727        fs::rename(&self.path, final_dir).with_context(|| {
728            format!(
729                "swapping build {} -> {}",
730                self.path.display(),
731                final_dir.display()
732            )
733        })?;
734        self.committed = true;
735        Ok(())
736    }
737}
738
739impl Drop for StagingDir {
740    fn drop(&mut self) {
741        if !self.committed {
742            let _ = fs::remove_dir_all(&self.path);
743        }
744    }
745}