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 = 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 hidden_from_sidebar: false,
195 });
196 search.push(SearchEntry {
197 slug: b.slug.clone(),
198 title,
199 text: String::new(),
201 });
202 }
203 (docs, search)
204}
205
206fn git_rel_path(docs_dir: &Path, workdir: &Path, rel_path: &str) -> Option<String> {
210 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
234pub enum BuildMode {
235 #[default]
236 Production,
237 Dev,
238}
239
240pub struct BuildOptions<'a> {
242 pub project_root: &'a Path,
244 pub out_dir: &'a Path,
247 pub mode: BuildMode,
248}
249
250#[derive(Debug, Clone)]
252pub struct BuildOutcome {
253 pub page_count: usize,
254 pub any_mermaid: bool,
255 pub out_dir: PathBuf,
256}
257
258pub 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
268pub(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
291pub(crate) fn render_one_page(
296 renderer: &Renderer,
297 shared: &PageShared,
298 doc: &docgen_core::model::Doc,
299) -> Result<String> {
300 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 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 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 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#[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
404pub fn build_site(opts: &BuildOptions) -> Result<BuildOutcome> {
416 Ok(build_site_inner(opts, false)?.0)
417}
418
419pub(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 let (pages, partials) = docgen_core::pipeline::partition_partials(raws);
439 let prepared: Vec<_> = pages.into_iter().map(prepare).collect();
441 t.mark("prepare");
442 let prepared_cap = if capture {
445 Some(prepared.clone())
446 } else {
447 None
448 };
449 let mut config = docgen_config::load(opts.project_root)
451 .with_context(|| format!("loading docgen.toml from {}", opts.project_root.display()))?;
452 config.base = docgen_config::resolve_base(&config.base);
457 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 #[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 (Some(m), Some(_)) => Some(m),
503 _ => None,
505 };
506 #[cfg(not(feature = "s3"))]
507 let resolver: Option<&dyn docgen_core::asseturl::AssetUrlResolver> = None;
508
509 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 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 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 ®istry,
560 resolver,
561 plantuml_support.as_ref(),
562 bases_corpus.as_ref(),
563 )
564 };
565 t.mark("render_docs");
566
567 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 let (base_pages, base_search): (Vec<Doc>, Vec<SearchEntry>) = match &bases_corpus {
584 Some(corpus) => {
585 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 site.search.extend(base_search);
594 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 let all_docs: Vec<Doc> = site
609 .docs
610 .iter()
611 .cloned()
612 .chain(base_pages.iter().cloned())
613 .collect();
614 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 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 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 let staging = StagingDir::new(final_dir)?;
665 let dist_dir = staging.path();
666
667 let repo = docgen_diff::discover_repo(&docs_dir)
671 .with_context(|| format!("discovering git repo for {}", docs_dir.display()))?;
672 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 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 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 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 let summary = docgen_diff::summarize_report(&report);
709 fs::write(
710 diff_dir.join("timeline.json"),
711 serde_json::to_vec(&summary)?,
712 )?;
713 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 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 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 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 let mut home_html: Option<String> = None;
796 for doc in &all_docs {
797 let html = render_one_page(&renderer, &shared, doc)?;
798 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 #[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 }
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 if let Some(html) = home_html {
849 fs::write(dist_dir.join("index.html"), html)?;
850 }
851
852 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 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 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 let emit_opts = docgen_assets::EmitOptions {
917 include_katex_runtime: false,
918 include_mermaid: site.any_mermaid || opts.mode == BuildMode::Dev,
925 include_graph: config.features.graph,
926 include_bases: any_base,
929 include_diff: has_diff,
930 include_component_css: false,
933 include_component_js: false,
934 include_search: config.features.search,
938 };
939 docgen_assets::emit(&docgen_assets::assets_for(&emit_opts), dist_dir)?;
940
941 docgen_assets::emit_component_bundle(dist_dir, &component_css, &component_js)?;
944
945 t.mark("emit_assets");
946
947 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 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
994struct StagingDir {
1001 path: PathBuf,
1002 committed: bool,
1003}
1004
1005impl StagingDir {
1006 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 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 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 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 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 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 assert!(base.contains(">Dune<"));
1112 assert!(base.contains(">Neuromancer<"));
1113 assert!(!base.contains("NotABook"));
1114 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 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 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 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 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 assert!(!out.out_dir.join("Bases/X/index.html").exists());
1179 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}