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