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