1#[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
34const HOME_SLUG: &str = "index";
36
37struct 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
83const DEFAULT_DIFF_LIMIT: usize = 50;
85const 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
96pub(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
121fn 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
142fn 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 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 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 text: String::new(),
192 });
193 }
194 (docs, search)
195}
196
197fn git_rel_path(docs_dir: &Path, workdir: &Path, rel_path: &str) -> Option<String> {
201 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
225pub enum BuildMode {
226 #[default]
227 Production,
228 Dev,
229}
230
231pub struct BuildOptions<'a> {
233 pub project_root: &'a Path,
235 pub out_dir: &'a Path,
238 pub mode: BuildMode,
239}
240
241#[derive(Debug, Clone)]
243pub struct BuildOutcome {
244 pub page_count: usize,
245 pub any_mermaid: bool,
246 pub out_dir: PathBuf,
247}
248
249pub 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
259pub(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
282pub(crate) fn render_one_page(
287 renderer: &Renderer,
288 shared: &PageShared,
289 doc: &docgen_core::model::Doc,
290) -> Result<String> {
291 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 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 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 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#[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
395pub fn build_site(opts: &BuildOptions) -> Result<BuildOutcome> {
407 Ok(build_site_inner(opts, false)?.0)
408}
409
410pub(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 let (pages, partials) = docgen_core::pipeline::partition_partials(raws);
430 let prepared: Vec<_> = pages.into_iter().map(prepare).collect();
432 t.mark("prepare");
433 let prepared_cap = if capture {
436 Some(prepared.clone())
437 } else {
438 None
439 };
440 let mut config = docgen_config::load(opts.project_root)
442 .with_context(|| format!("loading docgen.toml from {}", opts.project_root.display()))?;
443 config.base = docgen_config::resolve_base(&config.base);
448 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 #[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 (Some(m), Some(_)) => Some(m),
494 _ => None,
496 };
497 #[cfg(not(feature = "s3"))]
498 let resolver: Option<&dyn docgen_core::asseturl::AssetUrlResolver> = None;
499
500 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 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 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 ®istry,
551 resolver,
552 plantuml_support.as_ref(),
553 bases_corpus.as_ref(),
554 )
555 };
556 t.mark("render_docs");
557
558 let (base_pages, base_search): (Vec<Doc>, Vec<SearchEntry>) = match &bases_corpus {
563 Some(corpus) => {
564 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 site.search.extend(base_search);
573 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 let all_docs: Vec<Doc> = site
588 .docs
589 .iter()
590 .cloned()
591 .chain(base_pages.iter().cloned())
592 .collect();
593 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 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 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 let staging = StagingDir::new(final_dir)?;
644 let dist_dir = staging.path();
645
646 let repo = docgen_diff::discover_repo(&docs_dir)
650 .with_context(|| format!("discovering git repo for {}", docs_dir.display()))?;
651 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 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 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 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 let summary = docgen_diff::summarize_report(&report);
687 fs::write(
688 diff_dir.join("timeline.json"),
689 serde_json::to_vec(&summary)?,
690 )?;
691 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 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 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 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 let mut home_html: Option<String> = None;
773 for doc in &all_docs {
774 let html = render_one_page(&renderer, &shared, doc)?;
775 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 #[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 }
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 if let Some(html) = home_html {
826 fs::write(dist_dir.join("index.html"), html)?;
827 }
828
829 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 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 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 let emit_opts = docgen_assets::EmitOptions {
894 include_katex_runtime: false,
895 include_mermaid: site.any_mermaid || opts.mode == BuildMode::Dev,
902 include_graph: config.features.graph,
903 include_bases: any_base,
906 include_diff: has_diff,
907 include_component_css: false,
910 include_component_js: false,
911 include_search: config.features.search,
915 };
916 docgen_assets::emit(&docgen_assets::assets_for(&emit_opts), dist_dir)?;
917
918 docgen_assets::emit_component_bundle(dist_dir, &component_css, &component_js)?;
921
922 t.mark("emit_assets");
923
924 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 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
971struct StagingDir {
978 path: PathBuf,
979 committed: bool,
980}
981
982impl StagingDir {
983 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 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 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 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 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 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 assert!(base.contains(">Dune<"));
1089 assert!(base.contains(">Neuromancer<"));
1090 assert!(!base.contains("NotABook"));
1091 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 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 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 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 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 assert!(!out.out_dir.join("Bases/X/index.html").exists());
1156 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}