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    // --- S3 asset offload decision (only affects non-.md attachments) -------
382    // `s3_manifest` is Some only when the `s3` feature is on AND [s3] config is
383    // present. `s3_creds` additionally requires credentials in the environment.
384    #[cfg(feature = "s3")]
385    let (s3_manifest, s3_creds): (Option<docgen_s3::AssetManifest>, _) = match &config.s3 {
386        Some(s3cfg) if opts.mode == BuildMode::Production => {
387            let assets = discover_assets(&docs_dir)
388                .with_context(|| format!("discovering assets in {}", docs_dir.display()))?;
389            let manifest =
390                docgen_s3::build_manifest(&assets, s3cfg).context("building S3 asset manifest")?;
391            let creds = docgen_s3::credentials_from_env();
392            (Some(manifest), creds)
393        }
394        _ => (None, None),
395    };
396
397    #[cfg(not(feature = "s3"))]
398    if config.s3.is_some() && opts.mode == BuildMode::Production {
399        eprintln!(
400            "S3: [s3] configured but this docgen build was compiled without the `s3` \
401             feature — copying attachments into dist/ instead"
402        );
403    }
404
405    #[cfg(feature = "s3")]
406    let resolver: Option<&dyn docgen_core::asseturl::AssetUrlResolver> =
407        match (&s3_manifest, &s3_creds) {
408            // Upload active: rewrite to S3 URLs.
409            (Some(m), Some(_)) => Some(m),
410            // Config present but no creds: fall back to local URLs.
411            _ => None,
412        };
413    #[cfg(not(feature = "s3"))]
414    let resolver: Option<&dyn docgen_core::asseturl::AssetUrlResolver> = None;
415
416    let site = render_docs(prepared, &partials, &config, &registry, resolver);
417    t.mark("render_docs");
418    let tree = build_tree(&site.docs);
419    t.mark("build_tree");
420
421    // Concatenate the component asset bundle. `components.css` carries every
422    // registry component's style (small + cacheable, linked on every page that
423    // has any style); `components.js` carries only the island.js of components
424    // actually *used* across the site (per-page link gating below decides which
425    // pages reference it). Emitted in BTreeMap name-key order (deterministic).
426    let has_components_css = !registry.styles().is_empty();
427    let used_components: std::collections::BTreeSet<&str> = site
428        .docs
429        .iter()
430        .flat_map(|d| d.components_used.iter().map(String::as_str))
431        .collect();
432    let component_css: String = registry
433        .styles()
434        .iter()
435        .filter_map(|c| c.style_css.as_deref())
436        .collect::<Vec<_>>()
437        .join("\n");
438    let component_js: String = registry
439        .islands()
440        .iter()
441        .filter(|c| used_components.contains(c.name.as_str()))
442        .filter_map(|c| c.island_js.as_deref())
443        .collect::<Vec<_>>()
444        .join("\n");
445    // Set of components that ship an island AND were used → drives per-page gating.
446    let island_components: std::collections::BTreeSet<String> = registry
447        .islands()
448        .iter()
449        .filter(|c| used_components.contains(c.name.as_str()))
450        .map(|c| c.name.clone())
451        .collect();
452
453    let renderer = Renderer::new(DEFAULT_PAGE_TEMPLATE)?;
454
455    // Render the whole site into a fresh staging dir; only swap it into
456    // `final_dir` once everything below succeeds. This makes the build atomic:
457    // a failure leaves any existing `final_dir` (the last good build) intact.
458    // Staging lives alongside `final_dir` so the final move is a same-filesystem
459    // rename (atomic) rather than a cross-device copy.
460    let staging = StagingDir::new(final_dir)?;
461    let dist_dir = staging.path();
462
463    // Phase 1: build the per-doc git history pages (graceful no-op outside a
464    // git repo or for docs with no commit history). Collect which slugs got a
465    // history page so the doc page can conditionally show its "History" link.
466    let repo = docgen_diff::discover_repo(&docs_dir)
467        .with_context(|| format!("discovering git repo for {}", docs_dir.display()))?;
468    // Build metadata for the right-rail "Additional info" section. Best-effort:
469    // the short HEAD hash is empty outside a git repo (the Commit row is then
470    // omitted by the template); `built` is the wall-clock build time.
471    let now = Local::now();
472    let built_stamp = now.format("%Y-%m-%d %H:%M").to_string();
473    let mut commit_hash = String::new();
474    // Whether the interactive `/diff/` workspace was emitted (repo has doc
475    // history) — drives the topbar diff icon + the diff asset slice.
476    let mut has_diff = false;
477    if let Some(repo) = repo {
478        if let Ok(head) = repo.head() {
479            if let Some(oid) = head.target() {
480                let s = oid.to_string();
481                commit_hash = s.chars().take(7).collect();
482            }
483        }
484        if let Some(workdir) = repo.workdir().map(Path::to_path_buf) {
485            // The docs dir as git sees it, e.g. `docs` (trailing slash trimmed —
486            // `git_rel_path` joins an empty leaf to the prefix).
487            if let Some(docs_prefix) =
488                git_rel_path(&docs_dir, &workdir, "").map(|p| p.trim_end_matches('/').to_string())
489            {
490                let limit = diff_limit();
491                // The global doc-diff report across all docs, with rendered block
492                // diffs — the analogue of the original `/docs/diff` payload.
493                let report =
494                    docgen_diff::build_global_doc_diff_report(&repo, &docs_prefix, limit, true)
495                        .with_context(|| {
496                            format!("building global doc diff report for {docs_prefix}")
497                        })?;
498                t.mark("diff_report");
499                if let Some(report) = report {
500                    let diff_dir = dist_dir.join("diff");
501                    fs::create_dir_all(diff_dir.join("revisions"))?;
502                    // timeline.json — the lightweight index (hunks/blocks stripped).
503                    let summary = docgen_diff::summarize_report(&report);
504                    fs::write(
505                        diff_dir.join("timeline.json"),
506                        serde_json::to_vec(&summary)?,
507                    )?;
508                    // revisions/<id>.json — each commit's full per-file block diff,
509                    // lazily fetched by the island when a commit is selected.
510                    for point in &report.timeline {
511                        fs::write(
512                            diff_dir
513                                .join("revisions")
514                                .join(format!("{}.json", point.id)),
515                            serde_json::to_vec(point)?,
516                        )?;
517                    }
518                    // The /diff workspace shell (hydrated client-side by diff.js).
519                    let diff_html = renderer.render_diff(&docgen_render::DiffContext {
520                        tree: &tree,
521                        base: &config.base,
522                        site_title: config.title.as_deref().unwrap_or(""),
523                        search_enabled: config.features.search,
524                    })?;
525                    fs::write(diff_dir.join("index.html"), diff_html)?;
526                    has_diff = true;
527                }
528            }
529        }
530    }
531
532    // Force-layout graph data, computed once when the graph feature is on, and
533    // reused by both the home-page embed (Phase 2) and the standalone /graph
534    // page (Phase 3). The original docgen surfaces the graph ON the home page
535    // (not in the sidebar), so the home doc gets the graph block.
536    let graph_payload: Option<(String, usize, usize)> = if config.features.graph {
537        let graph_data = site.graph_data(docgen_core::graphlayout::LayoutParams::default());
538        let graph_json = docgen_core::graphlayout::graph_data_json(&graph_data);
539        Some((graph_json, graph_data.nodes.len(), graph_data.edges.len()))
540    } else {
541        None
542    };
543    t.mark("graph_layout");
544
545    // Home dashboard data (mirrors the original home: title/stats/sections/recent).
546    // "Sections" = top-level folders (the sidebar's grouping), ordered by first
547    // appearance, each linking to its first page with a doc count. "Recent" = the
548    // first docs in build order (home excluded). Computed once; only the index
549    // doc's render consumes it (every other page passes `home: None`).
550    let total_links = site.graph.edges.len();
551    let (section_rows, recent_rows) = compute_home_rows(&site.docs);
552    let home_sections: Vec<HomeSection> = section_rows
553        .iter()
554        .map(|(label, slug, count)| HomeSection {
555            label,
556            slug,
557            count: *count,
558        })
559        .collect();
560    let home_recent: Vec<HomeRecent> = recent_rows
561        .iter()
562        .map(|(title, slug, section)| HomeRecent {
563            title,
564            slug,
565            section,
566        })
567        .collect();
568
569    let shared = PageShared {
570        tree: &tree,
571        graph: &site.graph,
572        commit: &commit_hash,
573        built: &built_stamp,
574        base: &config.base,
575        site_title: config.title.as_deref().unwrap_or(""),
576        search_enabled: config.features.search,
577        has_diff,
578        has_components_css,
579        island_components: &island_components,
580        graph_payload: &graph_payload,
581        home_sections: &home_sections,
582        home_recent: &home_recent,
583        pages_count: site.docs.len(),
584        total_links,
585    };
586
587    // Phase 2: render the doc pages via the shared per-page renderer.
588    let mut home_html: Option<String> = None;
589    for doc in &site.docs {
590        let html = render_one_page(&renderer, &shared, doc)?;
591        // `guide/intro` -> `dist/guide/intro/index.html` (clean URLs).
592        let out_dir = dist_dir.join(&doc.slug);
593        fs::create_dir_all(&out_dir)?;
594        fs::write(out_dir.join("index.html"), &html)?;
595        if doc.slug == HOME_SLUG {
596            home_html = Some(html);
597        }
598    }
599
600    t.mark("render_pages");
601
602    // Copy authored static assets (images, PDFs, …) from the docs tree into the
603    // output, mirroring their relative path. Pages reference these relatively
604    // (`![](./attachments/img.png)`); the asset pass rewrote those refs to
605    // base-absolute URLs pointing exactly here (`/system/attachments/img.png`).
606    #[cfg(feature = "s3")]
607    {
608        match (&s3_manifest, &s3_creds) {
609            (Some(manifest), Some(creds)) => {
610                let s3cfg = config
611                    .s3
612                    .as_ref()
613                    .expect("s3 config present when manifest is");
614                let stats =
615                    docgen_s3::upload(manifest, s3cfg, creds).context("uploading assets to S3")?;
616                let prefix = s3cfg.prefix.as_deref().unwrap_or("");
617                eprintln!(
618                    "S3: {} uploaded, {} already present -> s3://{}/{} (public: {})",
619                    stats.uploaded, stats.skipped, s3cfg.bucket, prefix, s3cfg.public_url
620                );
621                // Attachments intentionally NOT copied into dist/ (that is the point).
622            }
623            (Some(manifest), None) => {
624                eprintln!(
625                    "S3: [s3] configured but AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY not \
626                     set — copying {} attachments into dist/ instead",
627                    manifest.entries().len()
628                );
629                copy_assets(&docs_dir, dist_dir)?;
630            }
631            (None, _) => copy_assets(&docs_dir, dist_dir)?,
632        }
633    }
634    #[cfg(not(feature = "s3"))]
635    copy_assets(&docs_dir, dist_dir)?;
636    t.mark("copy_assets");
637
638    // Root page: serve the home doc at `/` too, so the site has a real index.
639    // The nested `dist/index/index.html` is still emitted above, so existing
640    // `/index` links keep working — this is purely additive.
641    if let Some(html) = home_html {
642        fs::write(dist_dir.join("index.html"), html)?;
643    }
644
645    // 404 page: a full app-shell page (sidebar + search + theme) so a miss lands
646    // somewhere navigable instead of bare "not found". The dev server serves this
647    // on any unresolved path; static hosts (GitHub Pages, Netlify, …) pick up
648    // `404.html` by convention too.
649    let not_found_html = renderer.render_page(&PageContext {
650        title: "404",
651        description: "This page could not be found.",
652        slug: "404",
653        body_html: "<p>The page you’re looking for doesn’t exist or was moved. \
654            Use the navigation sidebar or search (<kbd class=\"docgen-kbd\">⌘K</kbd>) \
655            to find your way.</p>",
656        tree: &tree,
657        backlinks: &[],
658        headings: &[],
659        commit: &commit_hash,
660        built: &built_stamp,
661        has_history: false,
662        has_mermaid: false,
663        has_math: false,
664        base: &config.base,
665        site_title: config.title.as_deref().unwrap_or(""),
666        search_enabled: config.features.search,
667        has_diff,
668        has_components_css: false,
669        has_component_island: false,
670        is_home: false,
671        graph_json: "",
672        graph_node_count: 0,
673        graph_edge_count: 0,
674        home: None,
675    })?;
676    fs::write(dist_dir.join("404.html"), not_found_html)?;
677
678    // Phase 3: the standalone /graph/ page (default-on, gated off by `[features]
679    // graph = false`). Reuses the force layout computed above — never recomputes.
680    if let Some((graph_json, node_count, edge_count)) = &graph_payload {
681        let graph_html = renderer.render_graph(&GraphContext {
682            tree: &tree,
683            graph_json,
684            node_count: *node_count,
685            edge_count: *edge_count,
686            base: &config.base,
687            site_title: config.title.as_deref().unwrap_or(""),
688            search_enabled: config.features.search,
689            has_diff,
690        })?;
691        let graph_dir = dist_dir.join("graph");
692        fs::create_dir_all(&graph_dir)?;
693        fs::write(graph_dir.join("index.html"), graph_html)?;
694    }
695
696    // Static search index (gated off by `[features] search = false`).
697    if config.features.search {
698        fs::write(
699            dist_dir.join("search-index.json"),
700            docgen_core::search::index_json(&site.search),
701        )?;
702    }
703
704    // All vendored + authored client assets flow through docgen-assets. Mermaid
705    // (lib + island) ships only when a page used a diagram; math renders at build
706    // time (the default), so no runtime KaTeX JS ships. The graph island ships
707    // only when the /graph/ page is emitted.
708    let emit_opts = docgen_assets::EmitOptions {
709        include_katex_runtime: false,
710        // Production gates the mermaid lib + island on actual usage. In Dev we
711        // ship them unconditionally so the editor's live preview can render a
712        // diagram the moment it's typed — before the first save+rebuild that would
713        // flip `any_mermaid`. These are production assets (no dev-only surface), so
714        // shipping a superset in dev keeps the build's "no dev assets on disk in a
715        // static build" invariant (editor/livereload) untouched.
716        include_mermaid: site.any_mermaid || opts.mode == BuildMode::Dev,
717        include_graph: config.features.graph,
718        include_diff: has_diff,
719        // Component bundles are written separately (B-8) via emit_component_bundle;
720        // these flags are inert in assets_for.
721        include_component_css: false,
722        include_component_js: false,
723        // Honour `[features] search = false`: the page template already gates the
724        // search trigger + script link, so the client script would otherwise be an
725        // orphan file in the dist.
726        include_search: config.features.search,
727    };
728    docgen_assets::emit(&docgen_assets::assets_for(&emit_opts), dist_dir)?;
729
730    // Authored component bundle (dynamic bytes concatenated from the registry).
731    // Empty strings skip their file.
732    docgen_assets::emit_component_bundle(dist_dir, &component_css, &component_js)?;
733
734    t.mark("emit_assets");
735
736    // Everything rendered cleanly: atomically swap staging into place. Only now
737    // is the previously-served `final_dir` replaced.
738    staging.commit(final_dir)?;
739    t.mark("commit");
740    t.report();
741
742    let page_count = site.docs.len();
743    let any_mermaid = site.any_mermaid;
744    let outcome = BuildOutcome {
745        page_count,
746        any_mermaid,
747        out_dir: final_dir.to_path_buf(),
748    };
749
750    // Seed the dev server's incremental engine from the artifacts this build
751    // already computed — no second render pass.
752    let captured = if capture {
753        Some(crate::incremental::CapturedBuild {
754            config,
755            registry,
756            partials,
757            prepared: prepared_cap.expect("prepared is cloned whenever capture is set"),
758            docs: site.docs,
759            outbound: site.outbound,
760            graph: site.graph,
761            tree,
762            graph_payload,
763            island_components,
764            has_components_css,
765            commit_hash,
766            built_stamp,
767            has_diff,
768            search: site.search,
769        })
770    } else {
771        None
772    };
773
774    Ok((outcome, captured))
775}
776
777/// A staging directory for an atomic build. Renders happen here; [`commit`]
778/// swaps it into the final location only on success. If dropped without
779/// committing (i.e. the build errored), the staging dir is best-effort removed,
780/// leaving the previous `final_dir` untouched.
781///
782/// [`commit`]: StagingDir::commit
783struct StagingDir {
784    path: PathBuf,
785    committed: bool,
786}
787
788impl StagingDir {
789    /// Create a fresh, empty staging dir as a sibling of `final_dir` (same
790    /// filesystem -> the final rename is atomic).
791    fn new(final_dir: &Path) -> Result<Self> {
792        let parent = final_dir
793            .parent()
794            .map(Path::to_path_buf)
795            .unwrap_or_else(|| PathBuf::from("."));
796        fs::create_dir_all(&parent)
797            .with_context(|| format!("creating staging parent {}", parent.display()))?;
798        let file_name = final_dir
799            .file_name()
800            .map(|n| n.to_string_lossy().into_owned())
801            .unwrap_or_else(|| "dist".to_string());
802        let path = parent.join(format!(".{file_name}.staging-{}", std::process::id()));
803        let _ = fs::remove_dir_all(&path);
804        fs::create_dir_all(&path)
805            .with_context(|| format!("creating staging dir {}", path.display()))?;
806        Ok(Self {
807            path,
808            committed: false,
809        })
810    }
811
812    fn path(&self) -> &Path {
813        &self.path
814    }
815
816    /// Atomically replace `final_dir` with the staging dir. Removes any existing
817    /// `final_dir` first, then renames staging into place.
818    fn commit(mut self, final_dir: &Path) -> Result<()> {
819        let _ = fs::remove_dir_all(final_dir);
820        if let Some(parent) = final_dir.parent() {
821            fs::create_dir_all(parent)?;
822        }
823        fs::rename(&self.path, final_dir).with_context(|| {
824            format!(
825                "swapping build {} -> {}",
826                self.path.display(),
827                final_dir.display()
828            )
829        })?;
830        self.committed = true;
831        Ok(())
832    }
833}
834
835impl Drop for StagingDir {
836    fn drop(&mut self) {
837        if !self.committed {
838            let _ = fs::remove_dir_all(&self.path);
839        }
840    }
841}