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_docs};
27use docgen_core::pipeline::{prepare, render_docs};
28use docgen_core::tree::build_tree;
29use docgen_render::{
30 GraphContext, HomeData, HomeRecent, HomeSection, PageContext, Renderer, DEFAULT_PAGE_TEMPLATE,
31};
32
33const HOME_SLUG: &str = "index";
35
36struct PhaseTimer {
42 enabled: bool,
43 last: std::time::Instant,
44 start: std::time::Instant,
45 rows: Vec<(&'static str, u128)>,
46}
47
48impl PhaseTimer {
49 fn new() -> Self {
50 let now = std::time::Instant::now();
51 Self {
52 enabled: std::env::var_os("DOCGEN_TIMINGS").is_some(),
53 last: now,
54 start: now,
55 rows: Vec::new(),
56 }
57 }
58
59 fn mark(&mut self, label: &'static str) {
60 if !self.enabled {
61 return;
62 }
63 let now = std::time::Instant::now();
64 self.rows
65 .push((label, now.duration_since(self.last).as_millis()));
66 self.last = now;
67 }
68
69 fn report(&self) {
70 if !self.enabled {
71 return;
72 }
73 let total = self.start.elapsed().as_millis();
74 eprintln!("── build timings (ms) ──");
75 for (label, ms) in &self.rows {
76 eprintln!(" {label:<16} {ms:>6}");
77 }
78 eprintln!(" {:<16} {total:>6}", "TOTAL");
79 }
80}
81
82const DEFAULT_DIFF_LIMIT: usize = 50;
84const MAX_DIFF_LIMIT: usize = 200;
86
87fn diff_limit() -> usize {
88 std::env::var("DOC_DIFF_LIMIT")
89 .ok()
90 .and_then(|v| v.parse::<usize>().ok())
91 .unwrap_or(DEFAULT_DIFF_LIMIT)
92 .clamp(1, MAX_DIFF_LIMIT)
93}
94
95pub(crate) fn copy_assets(docs_dir: &Path, out_dir: &Path) -> Result<()> {
101 let assets = discover_assets(docs_dir)
102 .with_context(|| format!("discovering assets in {}", docs_dir.display()))?;
103 for asset in &assets {
104 let dest = out_dir.join(&asset.rel_path);
105 if let Some(parent) = dest.parent() {
106 fs::create_dir_all(parent)
107 .with_context(|| format!("creating asset dir {}", parent.display()))?;
108 }
109 fs::copy(&asset.src_path, &dest).with_context(|| {
110 format!(
111 "copying asset {} -> {}",
112 asset.src_path.display(),
113 dest.display()
114 )
115 })?;
116 }
117 Ok(())
118}
119
120fn git_rel_path(docs_dir: &Path, workdir: &Path, rel_path: &str) -> Option<String> {
124 let docs_canon = docs_dir.canonicalize().ok();
128 let work_canon = workdir.canonicalize().ok();
129 let (docs_ref, work_ref) = match (&docs_canon, &work_canon) {
130 (Some(d), Some(w)) => (d.as_path(), w.as_path()),
131 _ => (docs_dir, workdir),
132 };
133 let prefix = docs_ref.strip_prefix(work_ref).ok()?;
134 let mut parts: Vec<String> = prefix
135 .components()
136 .map(|c| c.as_os_str().to_string_lossy().into_owned())
137 .collect();
138 parts.push(rel_path.to_string());
139 Some(parts.join("/"))
140}
141
142#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
148pub enum BuildMode {
149 #[default]
150 Production,
151 Dev,
152}
153
154pub struct BuildOptions<'a> {
156 pub project_root: &'a Path,
158 pub out_dir: &'a Path,
161 pub mode: BuildMode,
162}
163
164#[derive(Debug, Clone)]
166pub struct BuildOutcome {
167 pub page_count: usize,
168 pub any_mermaid: bool,
169 pub out_dir: PathBuf,
170}
171
172pub fn build(project_root: &Path) -> Result<BuildOutcome> {
175 build_site(&BuildOptions {
176 project_root,
177 out_dir: &project_root.join("dist"),
178 mode: BuildMode::Production,
179 })
180}
181
182pub(crate) struct PageShared<'a> {
188 pub tree: &'a [docgen_core::model::TreeNode],
189 pub graph: &'a docgen_core::graph::LinkGraph,
190 pub commit: &'a str,
191 pub built: &'a str,
192 pub base: &'a str,
193 pub site_title: &'a str,
194 pub search_enabled: bool,
195 pub has_diff: bool,
196 pub has_components_css: bool,
197 pub island_components: &'a std::collections::BTreeSet<String>,
198 pub graph_payload: &'a Option<(String, usize, usize)>,
199 pub home_sections: &'a [HomeSection<'a>],
200 pub home_recent: &'a [HomeRecent<'a>],
201 pub pages_count: usize,
202 pub total_links: usize,
203}
204
205pub(crate) fn render_one_page(
210 renderer: &Renderer,
211 shared: &PageShared,
212 doc: &docgen_core::model::Doc,
213) -> Result<String> {
214 static EMPTY: Vec<docgen_core::model::Backlink> = Vec::new();
216 let backlinks = shared.graph.backlinks.get(&doc.slug).unwrap_or(&EMPTY);
217 let is_home = doc.slug == HOME_SLUG;
218 let (graph_json, graph_node_count, graph_edge_count) = match (is_home, shared.graph_payload) {
221 (true, Some((json, nodes, edges))) => (json.as_str(), *nodes, *edges),
222 _ => ("", 0, 0),
223 };
224 Ok(renderer.render_page(&PageContext {
225 title: &doc.title,
226 description: if is_home {
229 ""
230 } else {
231 doc.description.as_deref().unwrap_or("")
232 },
233 slug: &doc.slug,
234 body_html: &doc.body_html,
235 tree: shared.tree,
236 backlinks,
237 headings: &doc.headings,
238 commit: shared.commit,
239 built: shared.built,
240 has_history: false,
241 has_mermaid: doc.has_mermaid,
242 has_math: doc.has_math,
243 base: shared.base,
244 site_title: shared.site_title,
245 search_enabled: shared.search_enabled,
246 has_diff: shared.has_diff,
247 has_components_css: shared.has_components_css,
248 has_component_island: doc
249 .components_used
250 .iter()
251 .any(|c| shared.island_components.contains(c)),
252 is_home,
253 graph_json,
254 graph_node_count,
255 graph_edge_count,
256 home: if is_home {
257 Some(HomeData {
258 description: doc.description.as_deref().unwrap_or(""),
259 pages: shared.pages_count,
260 links: shared.total_links,
261 sections: shared.home_sections,
262 recent: shared.home_recent,
263 })
264 } else {
265 None
266 },
267 })?)
268}
269
270#[allow(clippy::type_complexity)]
276pub(crate) fn compute_home_rows(
277 docs: &[docgen_core::model::Doc],
278) -> (Vec<(String, String, usize)>, Vec<(String, String, String)>) {
279 let mut section_rows: Vec<(String, String, usize)> = Vec::new();
280 let mut section_idx: std::collections::BTreeMap<String, usize> =
281 std::collections::BTreeMap::new();
282 for doc in docs {
283 if doc.slug == HOME_SLUG || !doc.slug.contains('/') {
284 continue;
285 }
286 let label = doc.slug.split('/').next().unwrap_or("").to_string();
287 match section_idx.get(&label) {
288 Some(&i) => section_rows[i].2 += 1,
289 None => {
290 section_idx.insert(label.clone(), section_rows.len());
291 section_rows.push((label, doc.slug.clone(), 1));
292 }
293 }
294 }
295 let recent_rows: Vec<(String, String, String)> = docs
296 .iter()
297 .filter(|d| d.slug != HOME_SLUG)
298 .take(6)
299 .map(|d| {
300 let section = d
301 .slug
302 .split_once('/')
303 .map(|(s, _)| s.to_string())
304 .unwrap_or_default();
305 (d.title.clone(), d.slug.clone(), section)
306 })
307 .collect();
308 (section_rows, recent_rows)
309}
310
311pub fn build_site(opts: &BuildOptions) -> Result<BuildOutcome> {
323 Ok(build_site_inner(opts, false)?.0)
324}
325
326pub(crate) fn build_site_inner(
332 opts: &BuildOptions,
333 capture: bool,
334) -> Result<(BuildOutcome, Option<crate::incremental::CapturedBuild>)> {
335 let docs_dir = opts.project_root.join("docs");
336 let final_dir = opts.out_dir;
337 let mut t = PhaseTimer::new();
338
339 let raws = discover_docs(&docs_dir)
340 .with_context(|| format!("reading docs from {}", docs_dir.display()))?;
341 t.mark("discover");
342
343 let (pages, partials) = docgen_core::pipeline::partition_partials(raws);
346 let prepared: Vec<_> = pages.into_iter().map(prepare).collect();
348 t.mark("prepare");
349 let prepared_cap = if capture {
352 Some(prepared.clone())
353 } else {
354 None
355 };
356 let mut config = docgen_config::load(opts.project_root)
358 .with_context(|| format!("loading docgen.toml from {}", opts.project_root.display()))?;
359 config.base = docgen_config::resolve_base(&config.base);
364 let builtins: Vec<docgen_components::Component> = docgen_assets::builtin_components()
367 .into_iter()
368 .map(|b| {
369 docgen_components::Component::from_parts(
370 b.name,
371 b.template,
372 b.island_js.map(str::to_string),
373 b.style_css.map(str::to_string),
374 )
375 })
376 .collect();
377 let components_dir = opts.project_root.join(&config.components.dir);
378 let registry = docgen_components::build_registry(builtins, &components_dir)
379 .with_context(|| format!("discovering components in {}", components_dir.display()))?;
380 t.mark("config+registry");
381 #[cfg(feature = "s3")]
385 let (s3_manifest, s3_creds): (Option<docgen_s3::AssetManifest>, _) = match &config.s3 {
386 Some(s3cfg) if opts.mode == BuildMode::Production => {
387 let assets = discover_assets(&docs_dir)
388 .with_context(|| format!("discovering assets in {}", docs_dir.display()))?;
389 let manifest =
390 docgen_s3::build_manifest(&assets, s3cfg).context("building S3 asset manifest")?;
391 let creds = docgen_s3::credentials_from_env();
392 (Some(manifest), creds)
393 }
394 _ => (None, None),
395 };
396
397 #[cfg(not(feature = "s3"))]
398 if config.s3.is_some() && opts.mode == BuildMode::Production {
399 eprintln!(
400 "S3: [s3] configured but this docgen build was compiled without the `s3` \
401 feature — copying attachments into dist/ instead"
402 );
403 }
404
405 #[cfg(feature = "s3")]
406 let resolver: Option<&dyn docgen_core::asseturl::AssetUrlResolver> =
407 match (&s3_manifest, &s3_creds) {
408 (Some(m), Some(_)) => Some(m),
410 _ => None,
412 };
413 #[cfg(not(feature = "s3"))]
414 let resolver: Option<&dyn docgen_core::asseturl::AssetUrlResolver> = None;
415
416 let site = render_docs(prepared, &partials, &config, ®istry, resolver);
417 t.mark("render_docs");
418 let tree = build_tree(&site.docs);
419 t.mark("build_tree");
420
421 let has_components_css = !registry.styles().is_empty();
427 let used_components: std::collections::BTreeSet<&str> = site
428 .docs
429 .iter()
430 .flat_map(|d| d.components_used.iter().map(String::as_str))
431 .collect();
432 let component_css: String = registry
433 .styles()
434 .iter()
435 .filter_map(|c| c.style_css.as_deref())
436 .collect::<Vec<_>>()
437 .join("\n");
438 let component_js: String = registry
439 .islands()
440 .iter()
441 .filter(|c| used_components.contains(c.name.as_str()))
442 .filter_map(|c| c.island_js.as_deref())
443 .collect::<Vec<_>>()
444 .join("\n");
445 let island_components: std::collections::BTreeSet<String> = registry
447 .islands()
448 .iter()
449 .filter(|c| used_components.contains(c.name.as_str()))
450 .map(|c| c.name.clone())
451 .collect();
452
453 let renderer = Renderer::new(DEFAULT_PAGE_TEMPLATE)?;
454
455 let staging = StagingDir::new(final_dir)?;
461 let dist_dir = staging.path();
462
463 let repo = docgen_diff::discover_repo(&docs_dir)
467 .with_context(|| format!("discovering git repo for {}", docs_dir.display()))?;
468 let now = Local::now();
472 let built_stamp = now.format("%Y-%m-%d %H:%M").to_string();
473 let mut commit_hash = String::new();
474 let mut has_diff = false;
477 if let Some(repo) = repo {
478 if let Ok(head) = repo.head() {
479 if let Some(oid) = head.target() {
480 let s = oid.to_string();
481 commit_hash = s.chars().take(7).collect();
482 }
483 }
484 if let Some(workdir) = repo.workdir().map(Path::to_path_buf) {
485 if let Some(docs_prefix) =
488 git_rel_path(&docs_dir, &workdir, "").map(|p| p.trim_end_matches('/').to_string())
489 {
490 let limit = diff_limit();
491 let report =
494 docgen_diff::build_global_doc_diff_report(&repo, &docs_prefix, limit, true)
495 .with_context(|| {
496 format!("building global doc diff report for {docs_prefix}")
497 })?;
498 t.mark("diff_report");
499 if let Some(report) = report {
500 let diff_dir = dist_dir.join("diff");
501 fs::create_dir_all(diff_dir.join("revisions"))?;
502 let summary = docgen_diff::summarize_report(&report);
504 fs::write(
505 diff_dir.join("timeline.json"),
506 serde_json::to_vec(&summary)?,
507 )?;
508 for point in &report.timeline {
511 fs::write(
512 diff_dir
513 .join("revisions")
514 .join(format!("{}.json", point.id)),
515 serde_json::to_vec(point)?,
516 )?;
517 }
518 let diff_html = renderer.render_diff(&docgen_render::DiffContext {
520 tree: &tree,
521 base: &config.base,
522 site_title: config.title.as_deref().unwrap_or(""),
523 search_enabled: config.features.search,
524 })?;
525 fs::write(diff_dir.join("index.html"), diff_html)?;
526 has_diff = true;
527 }
528 }
529 }
530 }
531
532 let graph_payload: Option<(String, usize, usize)> = if config.features.graph {
537 let graph_data = site.graph_data(docgen_core::graphlayout::LayoutParams::default());
538 let graph_json = docgen_core::graphlayout::graph_data_json(&graph_data);
539 Some((graph_json, graph_data.nodes.len(), graph_data.edges.len()))
540 } else {
541 None
542 };
543 t.mark("graph_layout");
544
545 let total_links = site.graph.edges.len();
551 let (section_rows, recent_rows) = compute_home_rows(&site.docs);
552 let home_sections: Vec<HomeSection> = section_rows
553 .iter()
554 .map(|(label, slug, count)| HomeSection {
555 label,
556 slug,
557 count: *count,
558 })
559 .collect();
560 let home_recent: Vec<HomeRecent> = recent_rows
561 .iter()
562 .map(|(title, slug, section)| HomeRecent {
563 title,
564 slug,
565 section,
566 })
567 .collect();
568
569 let shared = PageShared {
570 tree: &tree,
571 graph: &site.graph,
572 commit: &commit_hash,
573 built: &built_stamp,
574 base: &config.base,
575 site_title: config.title.as_deref().unwrap_or(""),
576 search_enabled: config.features.search,
577 has_diff,
578 has_components_css,
579 island_components: &island_components,
580 graph_payload: &graph_payload,
581 home_sections: &home_sections,
582 home_recent: &home_recent,
583 pages_count: site.docs.len(),
584 total_links,
585 };
586
587 let mut home_html: Option<String> = None;
589 for doc in &site.docs {
590 let html = render_one_page(&renderer, &shared, doc)?;
591 let out_dir = dist_dir.join(&doc.slug);
593 fs::create_dir_all(&out_dir)?;
594 fs::write(out_dir.join("index.html"), &html)?;
595 if doc.slug == HOME_SLUG {
596 home_html = Some(html);
597 }
598 }
599
600 t.mark("render_pages");
601
602 #[cfg(feature = "s3")]
607 {
608 match (&s3_manifest, &s3_creds) {
609 (Some(manifest), Some(creds)) => {
610 let s3cfg = config
611 .s3
612 .as_ref()
613 .expect("s3 config present when manifest is");
614 let stats =
615 docgen_s3::upload(manifest, s3cfg, creds).context("uploading assets to S3")?;
616 let prefix = s3cfg.prefix.as_deref().unwrap_or("");
617 eprintln!(
618 "S3: {} uploaded, {} already present -> s3://{}/{} (public: {})",
619 stats.uploaded, stats.skipped, s3cfg.bucket, prefix, s3cfg.public_url
620 );
621 }
623 (Some(manifest), None) => {
624 eprintln!(
625 "S3: [s3] configured but AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY not \
626 set — copying {} attachments into dist/ instead",
627 manifest.entries().len()
628 );
629 copy_assets(&docs_dir, dist_dir)?;
630 }
631 (None, _) => copy_assets(&docs_dir, dist_dir)?,
632 }
633 }
634 #[cfg(not(feature = "s3"))]
635 copy_assets(&docs_dir, dist_dir)?;
636 t.mark("copy_assets");
637
638 if let Some(html) = home_html {
642 fs::write(dist_dir.join("index.html"), html)?;
643 }
644
645 let not_found_html = renderer.render_page(&PageContext {
650 title: "404",
651 description: "This page could not be found.",
652 slug: "404",
653 body_html: "<p>The page you’re looking for doesn’t exist or was moved. \
654 Use the navigation sidebar or search (<kbd class=\"docgen-kbd\">⌘K</kbd>) \
655 to find your way.</p>",
656 tree: &tree,
657 backlinks: &[],
658 headings: &[],
659 commit: &commit_hash,
660 built: &built_stamp,
661 has_history: false,
662 has_mermaid: false,
663 has_math: false,
664 base: &config.base,
665 site_title: config.title.as_deref().unwrap_or(""),
666 search_enabled: config.features.search,
667 has_diff,
668 has_components_css: false,
669 has_component_island: false,
670 is_home: false,
671 graph_json: "",
672 graph_node_count: 0,
673 graph_edge_count: 0,
674 home: None,
675 })?;
676 fs::write(dist_dir.join("404.html"), not_found_html)?;
677
678 if let Some((graph_json, node_count, edge_count)) = &graph_payload {
681 let graph_html = renderer.render_graph(&GraphContext {
682 tree: &tree,
683 graph_json,
684 node_count: *node_count,
685 edge_count: *edge_count,
686 base: &config.base,
687 site_title: config.title.as_deref().unwrap_or(""),
688 search_enabled: config.features.search,
689 has_diff,
690 })?;
691 let graph_dir = dist_dir.join("graph");
692 fs::create_dir_all(&graph_dir)?;
693 fs::write(graph_dir.join("index.html"), graph_html)?;
694 }
695
696 if config.features.search {
698 fs::write(
699 dist_dir.join("search-index.json"),
700 docgen_core::search::index_json(&site.search),
701 )?;
702 }
703
704 let emit_opts = docgen_assets::EmitOptions {
709 include_katex_runtime: false,
710 include_mermaid: site.any_mermaid || opts.mode == BuildMode::Dev,
717 include_graph: config.features.graph,
718 include_diff: has_diff,
719 include_component_css: false,
722 include_component_js: false,
723 include_search: config.features.search,
727 };
728 docgen_assets::emit(&docgen_assets::assets_for(&emit_opts), dist_dir)?;
729
730 docgen_assets::emit_component_bundle(dist_dir, &component_css, &component_js)?;
733
734 t.mark("emit_assets");
735
736 staging.commit(final_dir)?;
739 t.mark("commit");
740 t.report();
741
742 let page_count = site.docs.len();
743 let any_mermaid = site.any_mermaid;
744 let outcome = BuildOutcome {
745 page_count,
746 any_mermaid,
747 out_dir: final_dir.to_path_buf(),
748 };
749
750 let captured = if capture {
753 Some(crate::incremental::CapturedBuild {
754 config,
755 registry,
756 partials,
757 prepared: prepared_cap.expect("prepared is cloned whenever capture is set"),
758 docs: site.docs,
759 outbound: site.outbound,
760 graph: site.graph,
761 tree,
762 graph_payload,
763 island_components,
764 has_components_css,
765 commit_hash,
766 built_stamp,
767 has_diff,
768 search: site.search,
769 })
770 } else {
771 None
772 };
773
774 Ok((outcome, captured))
775}
776
777struct StagingDir {
784 path: PathBuf,
785 committed: bool,
786}
787
788impl StagingDir {
789 fn new(final_dir: &Path) -> Result<Self> {
792 let parent = final_dir
793 .parent()
794 .map(Path::to_path_buf)
795 .unwrap_or_else(|| PathBuf::from("."));
796 fs::create_dir_all(&parent)
797 .with_context(|| format!("creating staging parent {}", parent.display()))?;
798 let file_name = final_dir
799 .file_name()
800 .map(|n| n.to_string_lossy().into_owned())
801 .unwrap_or_else(|| "dist".to_string());
802 let path = parent.join(format!(".{file_name}.staging-{}", std::process::id()));
803 let _ = fs::remove_dir_all(&path);
804 fs::create_dir_all(&path)
805 .with_context(|| format!("creating staging dir {}", path.display()))?;
806 Ok(Self {
807 path,
808 committed: false,
809 })
810 }
811
812 fn path(&self) -> &Path {
813 &self.path
814 }
815
816 fn commit(mut self, final_dir: &Path) -> Result<()> {
819 let _ = fs::remove_dir_all(final_dir);
820 if let Some(parent) = final_dir.parent() {
821 fs::create_dir_all(parent)?;
822 }
823 fs::rename(&self.path, final_dir).with_context(|| {
824 format!(
825 "swapping build {} -> {}",
826 self.path.display(),
827 final_dir.display()
828 )
829 })?;
830 self.committed = true;
831 Ok(())
832 }
833}
834
835impl Drop for StagingDir {
836 fn drop(&mut self) {
837 if !self.committed {
838 let _ = fs::remove_dir_all(&self.path);
839 }
840 }
841}