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, ®istry);
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}