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_bases, discover_docs, BaseFileInput};
27use docgen_core::model::{Doc, SearchEntry};
28use docgen_core::pipeline::{prepare, render_docs};
29use docgen_core::tree::build_tree;
30use docgen_render::{
31    GraphContext, HomeData, HomeRecent, HomeSection, PageContext, Renderer, DEFAULT_PAGE_TEMPLATE,
32};
33
34/// The slug docgen treats as the site home (served at `/`).
35const HOME_SLUG: &str = "index";
36
37/// Lightweight per-phase build timer. Inert unless `DOCGEN_TIMINGS` is set in
38/// the environment, in which case each [`mark`](PhaseTimer::mark) records the
39/// elapsed time since the previous mark and [`report`](PhaseTimer::report)
40/// prints the breakdown to stderr. Used to profile where a (re)build spends its
41/// time — see the dev-server incremental-rebuild work.
42struct PhaseTimer {
43    enabled: bool,
44    last: std::time::Instant,
45    start: std::time::Instant,
46    rows: Vec<(&'static str, u128)>,
47}
48
49impl PhaseTimer {
50    fn new() -> Self {
51        let now = std::time::Instant::now();
52        Self {
53            enabled: std::env::var_os("DOCGEN_TIMINGS").is_some(),
54            last: now,
55            start: now,
56            rows: Vec::new(),
57        }
58    }
59
60    fn mark(&mut self, label: &'static str) {
61        if !self.enabled {
62            return;
63        }
64        let now = std::time::Instant::now();
65        self.rows
66            .push((label, now.duration_since(self.last).as_millis()));
67        self.last = now;
68    }
69
70    fn report(&self) {
71        if !self.enabled {
72            return;
73        }
74        let total = self.start.elapsed().as_millis();
75        eprintln!("── build timings (ms) ──");
76        for (label, ms) in &self.rows {
77            eprintln!("  {label:<16} {ms:>6}");
78        }
79        eprintln!("  {:<16} {total:>6}", "TOTAL");
80    }
81}
82
83/// Default per-doc commit-history depth (parity with the original `diffLimit`).
84const DEFAULT_DIFF_LIMIT: usize = 50;
85/// Hard cap so a pathological `DOC_DIFF_LIMIT` can't blow up build time.
86const MAX_DIFF_LIMIT: usize = 200;
87
88fn diff_limit() -> usize {
89    std::env::var("DOC_DIFF_LIMIT")
90        .ok()
91        .and_then(|v| v.parse::<usize>().ok())
92        .unwrap_or(DEFAULT_DIFF_LIMIT)
93        .clamp(1, MAX_DIFF_LIMIT)
94}
95
96/// Copy every authored static asset (non-`.md` file) from `docs_dir` into
97/// `out_dir`, mirroring its relative path. Parent dirs are created as needed. A
98/// clean-URL page like `docs/system/index.md` (served at `/system/index/`) can
99/// then reference `./attachments/img.png`, which the asset pass rewrote to
100/// `/system/attachments/img.png` — exactly where this writes the file.
101pub(crate) fn copy_assets(docs_dir: &Path, out_dir: &Path) -> Result<()> {
102    let assets = discover_assets(docs_dir)
103        .with_context(|| format!("discovering assets in {}", docs_dir.display()))?;
104    for asset in &assets {
105        let dest = out_dir.join(&asset.rel_path);
106        if let Some(parent) = dest.parent() {
107            fs::create_dir_all(parent)
108                .with_context(|| format!("creating asset dir {}", parent.display()))?;
109        }
110        fs::copy(&asset.src_path, &dest).with_context(|| {
111            format!(
112                "copying asset {} -> {}",
113                asset.src_path.display(),
114                dest.display()
115            )
116        })?;
117    }
118    Ok(())
119}
120
121/// Read filesystem facts for a doc (size + creation/modification time) for the
122/// bases `file.size`/`file.ctime`/`file.mtime` properties. Best-effort: any field
123/// that can't be read (or isn't available on the platform) is left absent. Times
124/// are epoch-milliseconds.
125fn file_facts(docs_dir: &Path, rel_path: &str) -> docgen_core::FileFacts {
126    let path = docs_dir.join(rel_path);
127    let Ok(meta) = fs::metadata(&path) else {
128        return docgen_core::FileFacts::default();
129    };
130    let to_ms = |t: std::io::Result<std::time::SystemTime>| -> Option<i64> {
131        t.ok()
132            .and_then(|st| st.duration_since(std::time::UNIX_EPOCH).ok())
133            .map(|d| d.as_millis() as i64)
134    };
135    docgen_core::FileFacts {
136        size: meta.len(),
137        ctime_ms: to_ms(meta.created()),
138        mtime_ms: to_ms(meta.modified()),
139    }
140}
141
142/// Render every discovered `.base` file to a synthetic [`Doc`] (its views as
143/// static HTML) plus a search entry. The pages flow through the same tree/page
144/// pipeline as markdown docs, so a `.base` appears in the sidebar and gets a
145/// clean-URL page. `corpus` is the note set the bases query (markdown docs only —
146/// bases never query other bases).
147fn render_base_pages(
148    base_inputs: &[BaseFileInput],
149    corpus: &docgen_bases::Corpus,
150    base_path: &str,
151    taken_slugs: &std::collections::BTreeSet<String>,
152) -> (Vec<Doc>, Vec<SearchEntry>) {
153    let opts = docgen_bases::RenderOptions {
154        base: base_path.to_string(),
155        default_view_name: String::new(),
156        interactive: true,
157        // A standalone `.base` file is the only base on its own page.
158        block_index: 0,
159    };
160    let mut docs = Vec::with_capacity(base_inputs.len());
161    let mut search = Vec::with_capacity(base_inputs.len());
162    let mut seen: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
163    for b in base_inputs {
164        // A `.base` whose slug collides with a markdown page (or a previously seen
165        // base, or the site home) would overwrite that page's output. Skip it with
166        // a warning rather than corrupt the site.
167        if taken_slugs.contains(&b.slug) || !seen.insert(b.slug.clone()) {
168            eprintln!(
169                "bases: skipping {} — its slug `{}` collides with another page",
170                b.rel_path, b.slug
171            );
172            continue;
173        }
174        // Prefer an explicit `title:` in the base; else fall back to the file name.
175        // (Parsing here is cheap and keeps `render_base_source`'s graceful
176        // error-on-bad-YAML behaviour below intact.)
177        let title = docgen_bases::parse_base(&b.source)
178            .ok()
179            .and_then(|bf| bf.title)
180            .unwrap_or_else(|| b.slug.rsplit('/').next().unwrap_or(&b.slug).to_string());
181        let body_html = docgen_bases::render_base_source(&b.source, corpus, &opts);
182        docs.push(Doc {
183            rel_path: b.rel_path.clone(),
184            slug: b.slug.clone(),
185            title: title.clone(),
186            description: None,
187            body_html,
188            has_math: false,
189            has_mermaid: false,
190            components_used: Default::default(),
191            headings: Vec::new(),
192            // A `.base` page is always shown in the sidebar; it's typically the
193            // one entry meant to surface a directory whose pages are hidden.
194            hidden_from_sidebar: false,
195        });
196        search.push(SearchEntry {
197            slug: b.slug.clone(),
198            title,
199            // The base's data lives in the rendered HTML; index the title only.
200            text: String::new(),
201        });
202    }
203    (docs, search)
204}
205
206/// Compute the doc path as git sees it, relative to the repo working directory.
207/// e.g. docs_dir `/repo/docs`, workdir `/repo/`, `rel_path` `guide/intro.md`
208/// -> `docs/guide/intro.md`. Returns `None` if `docs_dir` is not under `workdir`.
209fn git_rel_path(docs_dir: &Path, workdir: &Path, rel_path: &str) -> Option<String> {
210    // Canonicalize both sides: on macOS `env::temp_dir()` yields `/var/...`
211    // while git2's `workdir()` resolves the `/private/var` symlink, so a raw
212    // `strip_prefix` would spuriously fail.
213    let docs_canon = docs_dir.canonicalize().ok();
214    let work_canon = workdir.canonicalize().ok();
215    let (docs_ref, work_ref) = match (&docs_canon, &work_canon) {
216        (Some(d), Some(w)) => (d.as_path(), w.as_path()),
217        _ => (docs_dir, workdir),
218    };
219    let prefix = docs_ref.strip_prefix(work_ref).ok()?;
220    let mut parts: Vec<String> = prefix
221        .components()
222        .map(|c| c.as_os_str().to_string_lossy().into_owned())
223        .collect();
224    parts.push(rel_path.to_string());
225    Some(parts.join("/"))
226}
227
228/// Whether this build is for static distribution or for the dev server.
229///
230/// [`build_site`] emits ONLY production assets in BOTH modes; the dev server
231/// adds dev-only assets/HTML itself, AFTER `build_site` returns. The mode is
232/// recorded for logging + so the dev server can assert its build context.
233#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
234pub enum BuildMode {
235    #[default]
236    Production,
237    Dev,
238}
239
240/// Inputs to a full site build.
241pub struct BuildOptions<'a> {
242    /// Project root containing `docs/`.
243    pub project_root: &'a Path,
244    /// Where the static site is written. `docgen build` passes `project_root/dist`;
245    /// the dev server passes a temp dir it owns.
246    pub out_dir: &'a Path,
247    pub mode: BuildMode,
248}
249
250/// Result of a build (counts for logging; extend later if needed).
251#[derive(Debug, Clone)]
252pub struct BuildOutcome {
253    pub page_count: usize,
254    pub any_mermaid: bool,
255    pub out_dir: PathBuf,
256}
257
258/// Back-compat thin wrapper used by `docgen build`: builds `root/docs` into
259/// `root/dist` in Production mode. Equivalent to the old `build::build(root)`.
260pub fn build(project_root: &Path) -> Result<BuildOutcome> {
261    build_site(&BuildOptions {
262        project_root,
263        out_dir: &project_root.join("dist"),
264        mode: BuildMode::Production,
265    })
266}
267
268/// Whole-site inputs shared by every per-page render — everything a
269/// [`PageContext`] needs that is NOT derived from the individual [`Doc`]. Built
270/// once per (re)build and reused for every page, so the dev server's incremental
271/// path can re-render a single changed page (via [`render_one_page`]) without
272/// recomputing the rest of the site.
273pub(crate) struct PageShared<'a> {
274    pub tree: &'a [docgen_core::model::TreeNode],
275    pub graph: &'a docgen_core::graph::LinkGraph,
276    pub commit: &'a str,
277    pub built: &'a str,
278    pub base: &'a str,
279    pub site_title: &'a str,
280    pub search_enabled: bool,
281    pub has_diff: bool,
282    pub has_components_css: bool,
283    pub island_components: &'a std::collections::BTreeSet<String>,
284    pub graph_payload: &'a Option<(String, usize, usize)>,
285    pub home_sections: &'a [HomeSection<'a>],
286    pub home_recent: &'a [HomeRecent<'a>],
287    pub pages_count: usize,
288    pub total_links: usize,
289}
290
291/// Render ONE doc to its full-page HTML. The single source of truth shared by
292/// the full-build page loop and the dev server's incremental re-render, so a
293/// page rebuilt incrementally is byte-identical to the same page in a full
294/// build. Pure (no disk I/O); the caller writes the result.
295pub(crate) fn render_one_page(
296    renderer: &Renderer,
297    shared: &PageShared,
298    doc: &docgen_core::model::Doc,
299) -> Result<String> {
300    // A doc with no inbound links has no backlinks entry; borrow a shared empty.
301    static EMPTY: Vec<docgen_core::model::Backlink> = Vec::new();
302    let backlinks = shared.graph.backlinks.get(&doc.slug).unwrap_or(&EMPTY);
303    let is_home = doc.slug == HOME_SLUG;
304    // Only the home doc carries the graph payload (empty graph_json → the
305    // template skips the block + the graph island script).
306    let (graph_json, graph_node_count, graph_edge_count) = match (is_home, shared.graph_payload) {
307        (true, Some((json, nodes, edges))) => (json.as_str(), *nodes, *edges),
308        _ => ("", 0, 0),
309    };
310    Ok(renderer.render_page(&PageContext {
311        title: &doc.title,
312        // Frontmatter description → page header lede (non-home pages). The home
313        // dashboard consumes it via `HomeData.description` instead.
314        description: if is_home {
315            ""
316        } else {
317            doc.description.as_deref().unwrap_or("")
318        },
319        slug: &doc.slug,
320        body_html: &doc.body_html,
321        tree: shared.tree,
322        backlinks,
323        headings: &doc.headings,
324        commit: shared.commit,
325        built: shared.built,
326        has_history: false,
327        has_mermaid: doc.has_mermaid,
328        // Interactive base marker: the emitter's payload wrapper. Match the full
329        // opening tag (literal `<`) so prose/code that merely mentions the class
330        // name — where `<` is HTML-escaped to `&lt;` — never false-positives.
331        // Covers both `.base` pages and regular docs embedding a ```base block.
332        has_base_island: doc
333            .body_html
334            .contains("<script type=\"application/json\" class=\"docgen-base-data\">"),
335        has_math: doc.has_math,
336        base: shared.base,
337        site_title: shared.site_title,
338        search_enabled: shared.search_enabled,
339        has_diff: shared.has_diff,
340        has_components_css: shared.has_components_css,
341        has_component_island: doc
342            .components_used
343            .iter()
344            .any(|c| shared.island_components.contains(c)),
345        is_home,
346        graph_json,
347        graph_node_count,
348        graph_edge_count,
349        home: if is_home {
350            Some(HomeData {
351                description: doc.description.as_deref().unwrap_or(""),
352                pages: shared.pages_count,
353                links: shared.total_links,
354                sections: shared.home_sections,
355                recent: shared.home_recent,
356            })
357        } else {
358            None
359        },
360    })?)
361}
362
363/// Compute the home dashboard's section + recent rows from the doc set (owned, so
364/// the caller can hold them across the borrowed [`HomeSection`]/[`HomeRecent`]
365/// views). "Sections" = top-level folders ordered by first appearance, each with
366/// a doc count and a link to its first page; "Recent" = the first 6 docs in build
367/// order (home excluded). Shared by the full build and the incremental engine.
368#[allow(clippy::type_complexity)]
369pub(crate) fn compute_home_rows(
370    docs: &[docgen_core::model::Doc],
371) -> (Vec<(String, String, usize)>, Vec<(String, String, String)>) {
372    let mut section_rows: Vec<(String, String, usize)> = Vec::new();
373    let mut section_idx: std::collections::BTreeMap<String, usize> =
374        std::collections::BTreeMap::new();
375    for doc in docs {
376        if doc.slug == HOME_SLUG || !doc.slug.contains('/') {
377            continue;
378        }
379        let label = doc.slug.split('/').next().unwrap_or("").to_string();
380        match section_idx.get(&label) {
381            Some(&i) => section_rows[i].2 += 1,
382            None => {
383                section_idx.insert(label.clone(), section_rows.len());
384                section_rows.push((label, doc.slug.clone(), 1));
385            }
386        }
387    }
388    let recent_rows: Vec<(String, String, String)> = docs
389        .iter()
390        .filter(|d| d.slug != HOME_SLUG)
391        .take(6)
392        .map(|d| {
393            let section = d
394                .slug
395                .split_once('/')
396                .map(|(s, _)| s.to_string())
397                .unwrap_or_default();
398            (d.title.clone(), d.slug.clone(), section)
399        })
400        .collect();
401    (section_rows, recent_rows)
402}
403
404/// Discover -> render -> emit the whole site into `opts.out_dir`. This is the
405/// single pipeline both `docgen build` and `docgen dev` call.
406///
407/// The build is **atomic**: the whole site is rendered into a fresh staging dir
408/// (a sibling temp dir) and only swapped into `out_dir` on full success. A
409/// failure anywhere in the pipeline therefore leaves any pre-existing `out_dir`
410/// untouched — so the dev server genuinely keeps serving the last good build
411/// (the swap is the only step that mutates `out_dir`). Emits NO dev-only surface
412/// (editor/livereload) in either mode — the dev server layers that on afterward.
413/// The one mode-dependent emission is the mermaid runtime: Dev ships it
414/// unconditionally so the editor preview can render a just-typed diagram.
415pub fn build_site(opts: &BuildOptions) -> Result<BuildOutcome> {
416    Ok(build_site_inner(opts, false)?.0)
417}
418
419/// The full-build implementation behind [`build_site`]. When `capture` is true it
420/// additionally returns a [`CapturedBuild`] holding the in-memory artifacts (docs,
421/// graph, layout, tree, …) so the dev server can seed its incremental engine from
422/// the initial build without a second pass. `capture = false` (the production
423/// path) clones nothing extra.
424pub(crate) fn build_site_inner(
425    opts: &BuildOptions,
426    capture: bool,
427) -> Result<(BuildOutcome, Option<crate::incremental::CapturedBuild>)> {
428    let docs_dir = opts.project_root.join("docs");
429    let final_dir = opts.out_dir;
430    let mut t = PhaseTimer::new();
431
432    let raws = discover_docs(&docs_dir)
433        .with_context(|| format!("reading docs from {}", docs_dir.display()))?;
434    t.mark("discover");
435
436    // Split include-only partials (`_*.md`) out of the page set; they render
437    // only where a `:include` transcludes them, never as standalone pages.
438    let (pages, partials) = docgen_core::pipeline::partition_partials(raws);
439    // Two-pass: prepare all pages, then render with full slug knowledge.
440    let prepared: Vec<_> = pages.into_iter().map(prepare).collect();
441    t.mark("prepare");
442    // Only the incremental engine needs the prepared docs preserved past the
443    // render pass; the production path skips the clone.
444    let prepared_cap = if capture {
445        Some(prepared.clone())
446    } else {
447        None
448    };
449    // Load `docgen.toml` (absent → defaults reproduce pre-P6 behaviour).
450    let mut config = docgen_config::load(opts.project_root)
451        .with_context(|| format!("loading docgen.toml from {}", opts.project_root.display()))?;
452    // Resolve the effective deploy base: DOCGEN_BASE override → docgen.toml `base`
453    // → GitLab Pages auto-detect (CI_PAGES_URL / CI_PROJECT_PATH) → root. This is
454    // what makes a sub-path Pages deploy work with no per-project CI config, and
455    // it normalizes hand-written `base` values too.
456    config.base = docgen_config::resolve_base(&config.base);
457    // Build the component registry: embedded built-ins first, then project
458    // `components/<name>/` (which override a built-in of the same name).
459    let builtins: Vec<docgen_components::Component> = docgen_assets::builtin_components()
460        .into_iter()
461        .map(|b| {
462            docgen_components::Component::from_parts(
463                b.name,
464                b.template,
465                b.island_js.map(str::to_string),
466                b.style_css.map(str::to_string),
467            )
468        })
469        .collect();
470    let components_dir = opts.project_root.join(&config.components.dir);
471    let registry = docgen_components::build_registry(builtins, &components_dir)
472        .with_context(|| format!("discovering components in {}", components_dir.display()))?;
473    t.mark("config+registry");
474    // --- S3 asset offload decision (only affects non-.md attachments) -------
475    // `s3_manifest` is Some only when the `s3` feature is on AND [s3] config is
476    // present. `s3_creds` additionally requires credentials in the environment.
477    #[cfg(feature = "s3")]
478    let (s3_manifest, s3_creds): (Option<docgen_s3::AssetManifest>, _) = match &config.s3 {
479        Some(s3cfg) if opts.mode == BuildMode::Production => {
480            let assets = discover_assets(&docs_dir)
481                .with_context(|| format!("discovering assets in {}", docs_dir.display()))?;
482            let manifest =
483                docgen_s3::build_manifest(&assets, s3cfg).context("building S3 asset manifest")?;
484            let creds = docgen_s3::credentials_from_env();
485            (Some(manifest), creds)
486        }
487        _ => (None, None),
488    };
489
490    #[cfg(not(feature = "s3"))]
491    if config.s3.is_some() && opts.mode == BuildMode::Production {
492        eprintln!(
493            "S3: [s3] configured but this docgen build was compiled without the `s3` \
494             feature — copying attachments into dist/ instead"
495        );
496    }
497
498    #[cfg(feature = "s3")]
499    let resolver: Option<&dyn docgen_core::asseturl::AssetUrlResolver> =
500        match (&s3_manifest, &s3_creds) {
501            // Upload active: rewrite to S3 URLs.
502            (Some(m), Some(_)) => Some(m),
503            // Config present but no creds: fall back to local URLs.
504            _ => None,
505        };
506    #[cfg(not(feature = "s3"))]
507    let resolver: Option<&dyn docgen_core::asseturl::AssetUrlResolver> = None;
508
509    // --- PlantUML build-time rendering ------------------------------------
510    // Load `.puml` sources (docs-relative → source), and, when the feature is on,
511    // construct the networked renderer pointed at the resolved server. `support`
512    // is `None` when the feature is off, which makes every `:::plantuml` emit a
513    // "disabled" notice instead of contacting a server.
514    let diagrams = docgen_core::discover::discover_diagrams(&docs_dir)
515        .with_context(|| format!("loading PlantUML sources in {}", docs_dir.display()))?;
516    let plantuml_server = if config.features.plantuml {
517        Some(docgen_config::resolve_plantuml_server(
518            &config.plantuml.server,
519        ))
520    } else {
521        None
522    };
523    // --- Obsidian Bases ---------------------------------------------------
524    // Build the corpus (notes queryable by `.base` files and ```base blocks) from
525    // the prepared docs plus filesystem metadata, and load the `.base` files. Both
526    // are feature-gated: when `bases` is off the corpus is `None` (embedded blocks
527    // render as plain code) and no `.base` pages are emitted.
528    let mut bases_corpus = if config.features.bases {
529        Some(docgen_core::build_corpus(&prepared, &|rel| {
530            file_facts(&docs_dir, rel)
531        }))
532    } else {
533        None
534    };
535    let base_inputs = if config.features.bases {
536        discover_bases(&docs_dir)
537            .with_context(|| format!("loading .base files in {}", docs_dir.display()))?
538    } else {
539        Vec::new()
540    };
541
542    // Render in a scope so the renderer/support borrows of `diagrams` end before
543    // `diagrams` is (optionally) moved into the captured build below.
544    let mut site = {
545        let plantuml_renderer = plantuml_server.as_ref().map(|server| {
546            docgen_plantuml::HttpRenderer::new(server.clone(), opts.project_root.join(".docgen"))
547        });
548        let plantuml_support =
549            plantuml_renderer
550                .as_ref()
551                .map(|r| docgen_core::plantuml::PlantumlSupport {
552                    diagrams: &diagrams,
553                    renderer: Some(r as &dyn docgen_core::PlantumlRenderer),
554                });
555        render_docs(
556            prepared,
557            &partials,
558            &config,
559            &registry,
560            resolver,
561            plantuml_support.as_ref(),
562            bases_corpus.as_ref(),
563        )
564    };
565    t.mark("render_docs");
566
567    // Now that bodies are rendered, feed them back into the corpus so `.base`
568    // pages (rendered just below) can surface `note.body` — the Releases page
569    // shows each release's full body in a card this way.
570    if let Some(corpus) = bases_corpus.as_mut() {
571        let bodies: std::collections::HashMap<&str, &str> = site
572            .docs
573            .iter()
574            .map(|d| (d.slug.as_str(), d.body_html.as_str()))
575            .collect();
576        docgen_core::set_note_bodies(corpus, &|slug| bodies.get(slug).map(|s| s.to_string()));
577    }
578
579    // Render each `.base` file to a page (its views as static HTML) and a search
580    // entry, querying the markdown corpus. Kept as a separate list from `site.docs`
581    // so the link graph (and the dev server's incremental equivalence check) stays
582    // computed from markdown docs alone; base pages carry no links.
583    let (base_pages, base_search): (Vec<Doc>, Vec<SearchEntry>) = match &bases_corpus {
584        Some(corpus) => {
585            // Slugs already claimed by markdown pages — a `.base` must not overwrite one.
586            let taken: std::collections::BTreeSet<String> =
587                site.docs.iter().map(|d| d.slug.clone()).collect();
588            render_base_pages(&base_inputs, corpus, &config.base, &taken)
589        }
590        None => (Vec::new(), Vec::new()),
591    };
592    // Fold base search entries into the site index.
593    site.search.extend(base_search);
594    // Whether any base consumer exists (a `.base` page or an embedded ```base
595    // block). Used by the dev server: because a base queries the whole corpus
596    // (frontmatter + body tags/links), any content edit invalidates it, so a
597    // consumer present forces a full rebuild instead of the incremental fast path.
598    let has_base_consumers = config.features.bases
599        && (!base_inputs.is_empty()
600            || site
601                .docs
602                .iter()
603                .any(|d| d.body_html.contains("docgen-base")));
604    t.mark("render_bases");
605
606    // Every downstream consumer (sidebar tree, page loop, home rows, page count)
607    // treats markdown docs and base pages uniformly.
608    let all_docs: Vec<Doc> = site
609        .docs
610        .iter()
611        .cloned()
612        .chain(base_pages.iter().cloned())
613        .collect();
614    // Whether any rendered doc carries an interactive base payload (a `.base`
615    // page or an embedded ```base block). Gates shipping the bases island asset.
616    // Match the full payload wrapper tag (literal `<`) so a doc that only mentions
617    // the class name in prose/code (where `<` is escaped to `&lt;`) can't trip it.
618    let any_base = all_docs.iter().any(|d| {
619        d.body_html
620            .contains("<script type=\"application/json\" class=\"docgen-base-data\">")
621    });
622    let tree = build_tree(&all_docs);
623    t.mark("build_tree");
624
625    // Concatenate the component asset bundle. `components.css` carries every
626    // registry component's style (small + cacheable, linked on every page that
627    // has any style); `components.js` carries only the island.js of components
628    // actually *used* across the site (per-page link gating below decides which
629    // pages reference it). Emitted in BTreeMap name-key order (deterministic).
630    let has_components_css = !registry.styles().is_empty();
631    let used_components: std::collections::BTreeSet<&str> = site
632        .docs
633        .iter()
634        .flat_map(|d| d.components_used.iter().map(String::as_str))
635        .collect();
636    let component_css: String = registry
637        .styles()
638        .iter()
639        .filter_map(|c| c.style_css.as_deref())
640        .collect::<Vec<_>>()
641        .join("\n");
642    let component_js: String = registry
643        .islands()
644        .iter()
645        .filter(|c| used_components.contains(c.name.as_str()))
646        .filter_map(|c| c.island_js.as_deref())
647        .collect::<Vec<_>>()
648        .join("\n");
649    // Set of components that ship an island AND were used → drives per-page gating.
650    let island_components: std::collections::BTreeSet<String> = registry
651        .islands()
652        .iter()
653        .filter(|c| used_components.contains(c.name.as_str()))
654        .map(|c| c.name.clone())
655        .collect();
656
657    let renderer = Renderer::new(DEFAULT_PAGE_TEMPLATE)?;
658
659    // Render the whole site into a fresh staging dir; only swap it into
660    // `final_dir` once everything below succeeds. This makes the build atomic:
661    // a failure leaves any existing `final_dir` (the last good build) intact.
662    // Staging lives alongside `final_dir` so the final move is a same-filesystem
663    // rename (atomic) rather than a cross-device copy.
664    let staging = StagingDir::new(final_dir)?;
665    let dist_dir = staging.path();
666
667    // Phase 1: build the per-doc git history pages (graceful no-op outside a
668    // git repo or for docs with no commit history). Collect which slugs got a
669    // history page so the doc page can conditionally show its "History" link.
670    let repo = docgen_diff::discover_repo(&docs_dir)
671        .with_context(|| format!("discovering git repo for {}", docs_dir.display()))?;
672    // Build metadata for the right-rail "Additional info" section. Best-effort:
673    // the short HEAD hash is empty outside a git repo (the Commit row is then
674    // omitted by the template); `built` is the wall-clock build time.
675    let now = Local::now();
676    let built_stamp = now.format("%Y-%m-%d %H:%M").to_string();
677    let mut commit_hash = String::new();
678    // Whether the interactive `/diff/` workspace was emitted (repo has doc
679    // history) — drives the topbar diff icon + the diff asset slice.
680    let mut has_diff = false;
681    if let Some(repo) = repo {
682        if let Ok(head) = repo.head() {
683            if let Some(oid) = head.target() {
684                let s = oid.to_string();
685                commit_hash = s.chars().take(7).collect();
686            }
687        }
688        if config.features.diff {
689            if let Some(workdir) = repo.workdir().map(Path::to_path_buf) {
690                // The docs dir as git sees it, e.g. `docs` (trailing slash trimmed —
691                // `git_rel_path` joins an empty leaf to the prefix).
692                if let Some(docs_prefix) = git_rel_path(&docs_dir, &workdir, "")
693                    .map(|p| p.trim_end_matches('/').to_string())
694                {
695                    let limit = diff_limit();
696                    // The global doc-diff report across all docs, with rendered block
697                    // diffs — the analogue of the original `/docs/diff` payload.
698                    let report =
699                        docgen_diff::build_global_doc_diff_report(&repo, &docs_prefix, limit, true)
700                            .with_context(|| {
701                                format!("building global doc diff report for {docs_prefix}")
702                            })?;
703                    t.mark("diff_report");
704                    if let Some(report) = report {
705                        let diff_dir = dist_dir.join("diff");
706                        fs::create_dir_all(diff_dir.join("revisions"))?;
707                        // timeline.json — the lightweight index (hunks/blocks stripped).
708                        let summary = docgen_diff::summarize_report(&report);
709                        fs::write(
710                            diff_dir.join("timeline.json"),
711                            serde_json::to_vec(&summary)?,
712                        )?;
713                        // revisions/<id>.json — each commit's full per-file block diff,
714                        // lazily fetched by the island when a commit is selected.
715                        for point in &report.timeline {
716                            fs::write(
717                                diff_dir
718                                    .join("revisions")
719                                    .join(format!("{}.json", point.id)),
720                                serde_json::to_vec(point)?,
721                            )?;
722                        }
723                        // The /diff workspace shell (hydrated client-side by diff.js).
724                        let diff_html = renderer.render_diff(&docgen_render::DiffContext {
725                            tree: &tree,
726                            base: &config.base,
727                            site_title: config.title.as_deref().unwrap_or(""),
728                            search_enabled: config.features.search,
729                        })?;
730                        fs::write(diff_dir.join("index.html"), diff_html)?;
731                        has_diff = true;
732                    }
733                }
734            }
735        }
736    }
737
738    // Force-layout graph data, computed once when the graph feature is on, and
739    // reused by both the home-page embed (Phase 2) and the standalone /graph
740    // page (Phase 3). The original docgen surfaces the graph ON the home page
741    // (not in the sidebar), so the home doc gets the graph block.
742    let graph_payload: Option<(String, usize, usize)> = if config.features.graph {
743        let graph_data = site.graph_data(docgen_core::graphlayout::LayoutParams::default());
744        let graph_json = docgen_core::graphlayout::graph_data_json(&graph_data);
745        Some((graph_json, graph_data.nodes.len(), graph_data.edges.len()))
746    } else {
747        None
748    };
749    t.mark("graph_layout");
750
751    // Home dashboard data (mirrors the original home: title/stats/sections/recent).
752    // "Sections" = top-level folders (the sidebar's grouping), ordered by first
753    // appearance, each linking to its first page with a doc count. "Recent" = the
754    // first docs in build order (home excluded). Computed once; only the index
755    // doc's render consumes it (every other page passes `home: None`).
756    let total_links = site.graph.edges.len();
757    let (section_rows, recent_rows) = compute_home_rows(&all_docs);
758    let home_sections: Vec<HomeSection> = section_rows
759        .iter()
760        .map(|(label, slug, count)| HomeSection {
761            label,
762            slug,
763            count: *count,
764        })
765        .collect();
766    let home_recent: Vec<HomeRecent> = recent_rows
767        .iter()
768        .map(|(title, slug, section)| HomeRecent {
769            title,
770            slug,
771            section,
772        })
773        .collect();
774
775    let shared = PageShared {
776        tree: &tree,
777        graph: &site.graph,
778        commit: &commit_hash,
779        built: &built_stamp,
780        base: &config.base,
781        site_title: config.title.as_deref().unwrap_or(""),
782        search_enabled: config.features.search,
783        has_diff,
784        has_components_css,
785        island_components: &island_components,
786        graph_payload: &graph_payload,
787        home_sections: &home_sections,
788        home_recent: &home_recent,
789        pages_count: all_docs.len(),
790        total_links,
791    };
792
793    // Phase 2: render the doc pages via the shared per-page renderer (markdown
794    // pages + `.base` pages alike).
795    let mut home_html: Option<String> = None;
796    for doc in &all_docs {
797        let html = render_one_page(&renderer, &shared, doc)?;
798        // `guide/intro` -> `dist/guide/intro/index.html` (clean URLs).
799        let out_dir = dist_dir.join(&doc.slug);
800        fs::create_dir_all(&out_dir)?;
801        fs::write(out_dir.join("index.html"), &html)?;
802        if doc.slug == HOME_SLUG {
803            home_html = Some(html);
804        }
805    }
806
807    t.mark("render_pages");
808
809    // Copy authored static assets (images, PDFs, …) from the docs tree into the
810    // output, mirroring their relative path. Pages reference these relatively
811    // (`![](./attachments/img.png)`); the asset pass rewrote those refs to
812    // base-absolute URLs pointing exactly here (`/system/attachments/img.png`).
813    #[cfg(feature = "s3")]
814    {
815        match (&s3_manifest, &s3_creds) {
816            (Some(manifest), Some(creds)) => {
817                let s3cfg = config
818                    .s3
819                    .as_ref()
820                    .expect("s3 config present when manifest is");
821                let stats =
822                    docgen_s3::upload(manifest, s3cfg, creds).context("uploading assets to S3")?;
823                let prefix = s3cfg.prefix.as_deref().unwrap_or("");
824                eprintln!(
825                    "S3: {} uploaded, {} already present -> s3://{}/{} (public: {})",
826                    stats.uploaded, stats.skipped, s3cfg.bucket, prefix, s3cfg.public_url
827                );
828                // Attachments intentionally NOT copied into dist/ (that is the point).
829            }
830            (Some(manifest), None) => {
831                eprintln!(
832                    "S3: [s3] configured but AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY not \
833                     set — copying {} attachments into dist/ instead",
834                    manifest.entries().len()
835                );
836                copy_assets(&docs_dir, dist_dir)?;
837            }
838            (None, _) => copy_assets(&docs_dir, dist_dir)?,
839        }
840    }
841    #[cfg(not(feature = "s3"))]
842    copy_assets(&docs_dir, dist_dir)?;
843    t.mark("copy_assets");
844
845    // Root page: serve the home doc at `/` too, so the site has a real index.
846    // The nested `dist/index/index.html` is still emitted above, so existing
847    // `/index` links keep working — this is purely additive.
848    if let Some(html) = home_html {
849        fs::write(dist_dir.join("index.html"), html)?;
850    }
851
852    // 404 page: a full app-shell page (sidebar + search + theme) so a miss lands
853    // somewhere navigable instead of bare "not found". The dev server serves this
854    // on any unresolved path; static hosts (GitHub Pages, Netlify, …) pick up
855    // `404.html` by convention too.
856    let not_found_html = renderer.render_page(&PageContext {
857        title: "404",
858        description: "This page could not be found.",
859        slug: "404",
860        body_html: "<p>The page you’re looking for doesn’t exist or was moved. \
861            Use the navigation sidebar or search (<kbd class=\"docgen-kbd\">⌘K</kbd>) \
862            to find your way.</p>",
863        tree: &tree,
864        backlinks: &[],
865        headings: &[],
866        commit: &commit_hash,
867        built: &built_stamp,
868        has_history: false,
869        has_mermaid: false,
870        has_base_island: false,
871        has_math: false,
872        base: &config.base,
873        site_title: config.title.as_deref().unwrap_or(""),
874        search_enabled: config.features.search,
875        has_diff,
876        has_components_css: false,
877        has_component_island: false,
878        is_home: false,
879        graph_json: "",
880        graph_node_count: 0,
881        graph_edge_count: 0,
882        home: None,
883    })?;
884    fs::write(dist_dir.join("404.html"), not_found_html)?;
885
886    // Phase 3: the standalone /graph/ page (default-on, gated off by `[features]
887    // graph = false`). Reuses the force layout computed above — never recomputes.
888    if let Some((graph_json, node_count, edge_count)) = &graph_payload {
889        let graph_html = renderer.render_graph(&GraphContext {
890            tree: &tree,
891            graph_json,
892            node_count: *node_count,
893            edge_count: *edge_count,
894            base: &config.base,
895            site_title: config.title.as_deref().unwrap_or(""),
896            search_enabled: config.features.search,
897            has_diff,
898        })?;
899        let graph_dir = dist_dir.join("graph");
900        fs::create_dir_all(&graph_dir)?;
901        fs::write(graph_dir.join("index.html"), graph_html)?;
902    }
903
904    // Static search index (gated off by `[features] search = false`).
905    if config.features.search {
906        fs::write(
907            dist_dir.join("search-index.json"),
908            docgen_core::search::index_json(&site.search),
909        )?;
910    }
911
912    // All vendored + authored client assets flow through docgen-assets. Mermaid
913    // (lib + island) ships only when a page used a diagram; math renders at build
914    // time (the default), so no runtime KaTeX JS ships. The graph island ships
915    // only when the /graph/ page is emitted.
916    let emit_opts = docgen_assets::EmitOptions {
917        include_katex_runtime: false,
918        // Production gates the mermaid lib + island on actual usage. In Dev we
919        // ship them unconditionally so the editor's live preview can render a
920        // diagram the moment it's typed — before the first save+rebuild that would
921        // flip `any_mermaid`. These are production assets (no dev-only surface), so
922        // shipping a superset in dev keeps the build's "no dev assets on disk in a
923        // static build" invariant (editor/livereload) untouched.
924        include_mermaid: site.any_mermaid || opts.mode == BuildMode::Dev,
925        include_graph: config.features.graph,
926        // Ship the bases island only when a rendered page carries an interactive
927        // base payload (marker-based, matching the emitter).
928        include_bases: any_base,
929        include_diff: has_diff,
930        // Component bundles are written separately (B-8) via emit_component_bundle;
931        // these flags are inert in assets_for.
932        include_component_css: false,
933        include_component_js: false,
934        // Honour `[features] search = false`: the page template already gates the
935        // search trigger + script link, so the client script would otherwise be an
936        // orphan file in the dist.
937        include_search: config.features.search,
938    };
939    docgen_assets::emit(&docgen_assets::assets_for(&emit_opts), dist_dir)?;
940
941    // Authored component bundle (dynamic bytes concatenated from the registry).
942    // Empty strings skip their file.
943    docgen_assets::emit_component_bundle(dist_dir, &component_css, &component_js)?;
944
945    t.mark("emit_assets");
946
947    // Everything rendered cleanly: atomically swap staging into place. Only now
948    // is the previously-served `final_dir` replaced.
949    staging.commit(final_dir)?;
950    t.mark("commit");
951    t.report();
952
953    let page_count = all_docs.len();
954    let any_mermaid = site.any_mermaid;
955    let outcome = BuildOutcome {
956        page_count,
957        any_mermaid,
958        out_dir: final_dir.to_path_buf(),
959    };
960
961    // Seed the dev server's incremental engine from the artifacts this build
962    // already computed — no second render pass.
963    let captured = if capture {
964        Some(crate::incremental::CapturedBuild {
965            diagrams,
966            plantuml_server,
967            bases_corpus,
968            base_inputs,
969            base_pages,
970            has_base_consumers,
971            config,
972            registry,
973            partials,
974            prepared: prepared_cap.expect("prepared is cloned whenever capture is set"),
975            docs: site.docs,
976            outbound: site.outbound,
977            graph: site.graph,
978            tree,
979            graph_payload,
980            island_components,
981            has_components_css,
982            commit_hash,
983            built_stamp,
984            has_diff,
985            search: site.search,
986        })
987    } else {
988        None
989    };
990
991    Ok((outcome, captured))
992}
993
994/// A staging directory for an atomic build. Renders happen here; [`commit`]
995/// swaps it into the final location only on success. If dropped without
996/// committing (i.e. the build errored), the staging dir is best-effort removed,
997/// leaving the previous `final_dir` untouched.
998///
999/// [`commit`]: StagingDir::commit
1000struct StagingDir {
1001    path: PathBuf,
1002    committed: bool,
1003}
1004
1005impl StagingDir {
1006    /// Create a fresh, empty staging dir as a sibling of `final_dir` (same
1007    /// filesystem -> the final rename is atomic).
1008    fn new(final_dir: &Path) -> Result<Self> {
1009        let parent = final_dir
1010            .parent()
1011            .map(Path::to_path_buf)
1012            .unwrap_or_else(|| PathBuf::from("."));
1013        fs::create_dir_all(&parent)
1014            .with_context(|| format!("creating staging parent {}", parent.display()))?;
1015        let file_name = final_dir
1016            .file_name()
1017            .map(|n| n.to_string_lossy().into_owned())
1018            .unwrap_or_else(|| "dist".to_string());
1019        let path = parent.join(format!(".{file_name}.staging-{}", std::process::id()));
1020        let _ = fs::remove_dir_all(&path);
1021        fs::create_dir_all(&path)
1022            .with_context(|| format!("creating staging dir {}", path.display()))?;
1023        Ok(Self {
1024            path,
1025            committed: false,
1026        })
1027    }
1028
1029    fn path(&self) -> &Path {
1030        &self.path
1031    }
1032
1033    /// Atomically replace `final_dir` with the staging dir. Removes any existing
1034    /// `final_dir` first, then renames staging into place.
1035    fn commit(mut self, final_dir: &Path) -> Result<()> {
1036        let _ = fs::remove_dir_all(final_dir);
1037        if let Some(parent) = final_dir.parent() {
1038            fs::create_dir_all(parent)?;
1039        }
1040        fs::rename(&self.path, final_dir).with_context(|| {
1041            format!(
1042                "swapping build {} -> {}",
1043                self.path.display(),
1044                final_dir.display()
1045            )
1046        })?;
1047        self.committed = true;
1048        Ok(())
1049    }
1050}
1051
1052impl Drop for StagingDir {
1053    fn drop(&mut self) {
1054        if !self.committed {
1055            let _ = fs::remove_dir_all(&self.path);
1056        }
1057    }
1058}
1059
1060#[cfg(test)]
1061mod bases_tests {
1062    use super::*;
1063
1064    /// Write a small vault with two book notes, a standalone `.base` file, and a
1065    /// page that embeds a ```base block, then build it.
1066    fn build_fixture() -> (tempfile::TempDir, PathBuf) {
1067        let tmp = tempfile::tempdir().unwrap();
1068        let root = tmp.path();
1069        let docs = root.join("docs");
1070        fs::create_dir_all(docs.join("Books")).unwrap();
1071        fs::create_dir_all(docs.join("Bases")).unwrap();
1072        fs::write(docs.join("index.md"), "# Home\n").unwrap();
1073        fs::write(
1074            docs.join("Books/Dune.md"),
1075            "---\ntags: [book]\nrating: 5\n---\n# Dune\n",
1076        )
1077        .unwrap();
1078        fs::write(
1079            docs.join("Books/Neuromancer.md"),
1080            "---\ntags: [book]\nrating: 4\n---\n# Neuromancer\n",
1081        )
1082        .unwrap();
1083        fs::write(docs.join("Books/NotABook.md"), "# NotABook\n").unwrap();
1084        // A standalone `.base` file → its own page.
1085        fs::write(
1086            docs.join("Bases/Books.base"),
1087            "filters:\n  and:\n    - file.hasTag(\"book\")\nviews:\n  - type: table\n    name: All books\n    order: [file.name, note.rating]\n    sort:\n      - property: note.rating\n        direction: DESC\n",
1088        )
1089        .unwrap();
1090        // A page embedding a ```base block.
1091        fs::write(
1092            docs.join("gallery.md"),
1093            "# Gallery\n\n```base\nfilters:\n  and:\n    - file.hasTag(\"book\")\nviews:\n  - type: cards\n    order: [file.name]\n```\n",
1094        )
1095        .unwrap();
1096        let out = build(root).unwrap();
1097        (tmp, out.out_dir)
1098    }
1099
1100    #[test]
1101    fn standalone_base_file_becomes_a_page() {
1102        let (_tmp, dist) = build_fixture();
1103        let page = dist.join("Bases/Books/index.html");
1104        let html = fs::read_to_string(&page).expect("base page emitted");
1105        // Scope assertions to the base content (the sidebar nav lists every doc,
1106        // including the filtered-out note, so check inside the base view only).
1107        let base = &html[html.find("class=\"docgen-base\"").expect("base present")..];
1108        assert!(base.contains("docgen-base-table"));
1109        assert!(base.contains("All books"));
1110        // Both books present, filtered note excluded from the table.
1111        assert!(base.contains(">Dune<"));
1112        assert!(base.contains(">Neuromancer<"));
1113        assert!(!base.contains("NotABook"));
1114        // Descending rating sort: Dune (5) before Neuromancer (4).
1115        assert!(base.find("Dune").unwrap() < base.find("Neuromancer").unwrap());
1116    }
1117
1118    #[test]
1119    fn base_file_is_not_copied_as_an_asset() {
1120        let (_tmp, dist) = build_fixture();
1121        assert!(
1122            !dist.join("Bases/Books.base").exists(),
1123            ".base files are build inputs, never published assets"
1124        );
1125    }
1126
1127    #[test]
1128    fn embedded_base_block_renders_inline() {
1129        let (_tmp, dist) = build_fixture();
1130        let html = fs::read_to_string(dist.join("gallery/index.html")).unwrap();
1131        assert!(html.contains("docgen-base-cards"));
1132        assert!(html.contains(">Dune<"));
1133        // The raw fence is gone (rendered, not a code block).
1134        assert!(!html.contains("<code class=\"language-base\""));
1135    }
1136
1137    #[test]
1138    fn base_page_appears_in_sidebar_nav() {
1139        let (_tmp, dist) = build_fixture();
1140        // The sidebar tree is embedded in every page; the base page's slug links.
1141        let home = fs::read_to_string(dist.join("index.html")).unwrap();
1142        assert!(home.contains("/Bases/Books"));
1143    }
1144
1145    #[test]
1146    fn base_slug_colliding_with_a_markdown_page_is_skipped() {
1147        let tmp = tempfile::tempdir().unwrap();
1148        let root = tmp.path();
1149        let docs = root.join("docs");
1150        fs::create_dir_all(&docs).unwrap();
1151        fs::write(docs.join("index.md"), "# Home\n").unwrap();
1152        // A markdown page and a .base with the SAME slug (`Foo`).
1153        fs::write(docs.join("Foo.md"), "# Foo Markdown\n").unwrap();
1154        fs::write(docs.join("Foo.base"), "views:\n  - type: table\n").unwrap();
1155        let out = build(root).unwrap();
1156        // The markdown page's content wins; the base did not overwrite it.
1157        let html = fs::read_to_string(out.out_dir.join("Foo/index.html")).unwrap();
1158        assert!(html.contains("Foo Markdown"));
1159        assert!(!html.contains("docgen-base-table"));
1160    }
1161
1162    #[test]
1163    fn bases_feature_off_skips_pages_and_leaves_block_as_code() {
1164        let tmp = tempfile::tempdir().unwrap();
1165        let root = tmp.path();
1166        let docs = root.join("docs");
1167        fs::create_dir_all(docs.join("Bases")).unwrap();
1168        fs::write(root.join("docgen.toml"), "[features]\nbases = false\n").unwrap();
1169        fs::write(docs.join("index.md"), "# Home\n").unwrap();
1170        fs::write(docs.join("Bases/X.base"), "views:\n  - type: table\n").unwrap();
1171        fs::write(
1172            docs.join("p.md"),
1173            "# P\n\n```base\nviews:\n  - type: table\n```\n",
1174        )
1175        .unwrap();
1176        let out = build(root).unwrap();
1177        // No base page emitted.
1178        assert!(!out.out_dir.join("Bases/X/index.html").exists());
1179        // The block stays a plain code block.
1180        let html = fs::read_to_string(out.out_dir.join("p/index.html")).unwrap();
1181        assert!(!html.contains("docgen-base-table"));
1182        assert!(html.contains("<code"));
1183    }
1184}