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