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_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
95fn git_rel_path(docs_dir: &Path, workdir: &Path, rel_path: &str) -> Option<String> {
99 let docs_canon = docs_dir.canonicalize().ok();
103 let work_canon = workdir.canonicalize().ok();
104 let (docs_ref, work_ref) = match (&docs_canon, &work_canon) {
105 (Some(d), Some(w)) => (d.as_path(), w.as_path()),
106 _ => (docs_dir, workdir),
107 };
108 let prefix = docs_ref.strip_prefix(work_ref).ok()?;
109 let mut parts: Vec<String> = prefix
110 .components()
111 .map(|c| c.as_os_str().to_string_lossy().into_owned())
112 .collect();
113 parts.push(rel_path.to_string());
114 Some(parts.join("/"))
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
123pub enum BuildMode {
124 #[default]
125 Production,
126 Dev,
127}
128
129pub struct BuildOptions<'a> {
131 pub project_root: &'a Path,
133 pub out_dir: &'a Path,
136 pub mode: BuildMode,
137}
138
139#[derive(Debug, Clone)]
141pub struct BuildOutcome {
142 pub page_count: usize,
143 pub any_mermaid: bool,
144 pub out_dir: PathBuf,
145}
146
147pub fn build(project_root: &Path) -> Result<BuildOutcome> {
150 build_site(&BuildOptions {
151 project_root,
152 out_dir: &project_root.join("dist"),
153 mode: BuildMode::Production,
154 })
155}
156
157pub(crate) struct PageShared<'a> {
163 pub tree: &'a [docgen_core::model::TreeNode],
164 pub graph: &'a docgen_core::graph::LinkGraph,
165 pub commit: &'a str,
166 pub built: &'a str,
167 pub base: &'a str,
168 pub site_title: &'a str,
169 pub search_enabled: bool,
170 pub has_diff: bool,
171 pub has_components_css: bool,
172 pub island_components: &'a std::collections::BTreeSet<String>,
173 pub graph_payload: &'a Option<(String, usize, usize)>,
174 pub home_sections: &'a [HomeSection<'a>],
175 pub home_recent: &'a [HomeRecent<'a>],
176 pub pages_count: usize,
177 pub total_links: usize,
178}
179
180pub(crate) fn render_one_page(
185 renderer: &Renderer,
186 shared: &PageShared,
187 doc: &docgen_core::model::Doc,
188) -> Result<String> {
189 static EMPTY: Vec<docgen_core::model::Backlink> = Vec::new();
191 let backlinks = shared.graph.backlinks.get(&doc.slug).unwrap_or(&EMPTY);
192 let is_home = doc.slug == HOME_SLUG;
193 let (graph_json, graph_node_count, graph_edge_count) = match (is_home, shared.graph_payload) {
196 (true, Some((json, nodes, edges))) => (json.as_str(), *nodes, *edges),
197 _ => ("", 0, 0),
198 };
199 Ok(renderer.render_page(&PageContext {
200 title: &doc.title,
201 description: if is_home {
204 ""
205 } else {
206 doc.description.as_deref().unwrap_or("")
207 },
208 slug: &doc.slug,
209 body_html: &doc.body_html,
210 tree: shared.tree,
211 backlinks,
212 headings: &doc.headings,
213 commit: shared.commit,
214 built: shared.built,
215 has_history: false,
216 has_mermaid: doc.has_mermaid,
217 has_math: doc.has_math,
218 base: shared.base,
219 site_title: shared.site_title,
220 search_enabled: shared.search_enabled,
221 has_diff: shared.has_diff,
222 has_components_css: shared.has_components_css,
223 has_component_island: doc
224 .components_used
225 .iter()
226 .any(|c| shared.island_components.contains(c)),
227 is_home,
228 graph_json,
229 graph_node_count,
230 graph_edge_count,
231 home: if is_home {
232 Some(HomeData {
233 description: doc.description.as_deref().unwrap_or(""),
234 pages: shared.pages_count,
235 links: shared.total_links,
236 sections: shared.home_sections,
237 recent: shared.home_recent,
238 })
239 } else {
240 None
241 },
242 })?)
243}
244
245#[allow(clippy::type_complexity)]
251pub(crate) fn compute_home_rows(
252 docs: &[docgen_core::model::Doc],
253) -> (Vec<(String, String, usize)>, Vec<(String, String, String)>) {
254 let mut section_rows: Vec<(String, String, usize)> = Vec::new();
255 let mut section_idx: std::collections::BTreeMap<String, usize> =
256 std::collections::BTreeMap::new();
257 for doc in docs {
258 if doc.slug == HOME_SLUG || !doc.slug.contains('/') {
259 continue;
260 }
261 let label = doc.slug.split('/').next().unwrap_or("").to_string();
262 match section_idx.get(&label) {
263 Some(&i) => section_rows[i].2 += 1,
264 None => {
265 section_idx.insert(label.clone(), section_rows.len());
266 section_rows.push((label, doc.slug.clone(), 1));
267 }
268 }
269 }
270 let recent_rows: Vec<(String, String, String)> = docs
271 .iter()
272 .filter(|d| d.slug != HOME_SLUG)
273 .take(6)
274 .map(|d| {
275 let section = d
276 .slug
277 .split_once('/')
278 .map(|(s, _)| s.to_string())
279 .unwrap_or_default();
280 (d.title.clone(), d.slug.clone(), section)
281 })
282 .collect();
283 (section_rows, recent_rows)
284}
285
286pub fn build_site(opts: &BuildOptions) -> Result<BuildOutcome> {
298 Ok(build_site_inner(opts, false)?.0)
299}
300
301pub(crate) fn build_site_inner(
307 opts: &BuildOptions,
308 capture: bool,
309) -> Result<(BuildOutcome, Option<crate::incremental::CapturedBuild>)> {
310 let docs_dir = opts.project_root.join("docs");
311 let final_dir = opts.out_dir;
312 let mut t = PhaseTimer::new();
313
314 let raws = discover_docs(&docs_dir)
315 .with_context(|| format!("reading docs from {}", docs_dir.display()))?;
316 t.mark("discover");
317
318 let (pages, partials) = docgen_core::pipeline::partition_partials(raws);
321 let prepared: Vec<_> = pages.into_iter().map(prepare).collect();
323 t.mark("prepare");
324 let prepared_cap = if capture {
327 Some(prepared.clone())
328 } else {
329 None
330 };
331 let mut config = docgen_config::load(opts.project_root)
333 .with_context(|| format!("loading docgen.toml from {}", opts.project_root.display()))?;
334 config.base = docgen_config::resolve_base(&config.base);
339 let builtins: Vec<docgen_components::Component> = docgen_assets::builtin_components()
342 .into_iter()
343 .map(|b| {
344 docgen_components::Component::from_parts(
345 b.name,
346 b.template,
347 b.island_js.map(str::to_string),
348 b.style_css.map(str::to_string),
349 )
350 })
351 .collect();
352 let components_dir = opts.project_root.join(&config.components.dir);
353 let registry = docgen_components::build_registry(builtins, &components_dir)
354 .with_context(|| format!("discovering components in {}", components_dir.display()))?;
355 t.mark("config+registry");
356 let site = render_docs(prepared, &partials, &config, ®istry);
357 t.mark("render_docs");
358 let tree = build_tree(&site.docs);
359 t.mark("build_tree");
360
361 let has_components_css = !registry.styles().is_empty();
367 let used_components: std::collections::BTreeSet<&str> = site
368 .docs
369 .iter()
370 .flat_map(|d| d.components_used.iter().map(String::as_str))
371 .collect();
372 let component_css: String = registry
373 .styles()
374 .iter()
375 .filter_map(|c| c.style_css.as_deref())
376 .collect::<Vec<_>>()
377 .join("\n");
378 let component_js: String = registry
379 .islands()
380 .iter()
381 .filter(|c| used_components.contains(c.name.as_str()))
382 .filter_map(|c| c.island_js.as_deref())
383 .collect::<Vec<_>>()
384 .join("\n");
385 let island_components: std::collections::BTreeSet<String> = registry
387 .islands()
388 .iter()
389 .filter(|c| used_components.contains(c.name.as_str()))
390 .map(|c| c.name.clone())
391 .collect();
392
393 let renderer = Renderer::new(DEFAULT_PAGE_TEMPLATE)?;
394
395 let staging = StagingDir::new(final_dir)?;
401 let dist_dir = staging.path();
402
403 let repo = docgen_diff::discover_repo(&docs_dir)
407 .with_context(|| format!("discovering git repo for {}", docs_dir.display()))?;
408 let now = Local::now();
412 let built_stamp = now.format("%Y-%m-%d %H:%M").to_string();
413 let mut commit_hash = String::new();
414 let mut has_diff = false;
417 if let Some(repo) = repo {
418 if let Ok(head) = repo.head() {
419 if let Some(oid) = head.target() {
420 let s = oid.to_string();
421 commit_hash = s.chars().take(7).collect();
422 }
423 }
424 if let Some(workdir) = repo.workdir().map(Path::to_path_buf) {
425 if let Some(docs_prefix) =
428 git_rel_path(&docs_dir, &workdir, "").map(|p| p.trim_end_matches('/').to_string())
429 {
430 let limit = diff_limit();
431 let report =
434 docgen_diff::build_global_doc_diff_report(&repo, &docs_prefix, limit, true)
435 .with_context(|| {
436 format!("building global doc diff report for {docs_prefix}")
437 })?;
438 t.mark("diff_report");
439 if let Some(report) = report {
440 let diff_dir = dist_dir.join("diff");
441 fs::create_dir_all(diff_dir.join("revisions"))?;
442 let summary = docgen_diff::summarize_report(&report);
444 fs::write(
445 diff_dir.join("timeline.json"),
446 serde_json::to_vec(&summary)?,
447 )?;
448 for point in &report.timeline {
451 fs::write(
452 diff_dir
453 .join("revisions")
454 .join(format!("{}.json", point.id)),
455 serde_json::to_vec(point)?,
456 )?;
457 }
458 let diff_html = renderer.render_diff(&docgen_render::DiffContext {
460 tree: &tree,
461 base: &config.base,
462 site_title: config.title.as_deref().unwrap_or(""),
463 search_enabled: config.features.search,
464 })?;
465 fs::write(diff_dir.join("index.html"), diff_html)?;
466 has_diff = true;
467 }
468 }
469 }
470 }
471
472 let graph_payload: Option<(String, usize, usize)> = if config.features.graph {
477 let graph_data = site.graph_data(docgen_core::graphlayout::LayoutParams::default());
478 let graph_json = docgen_core::graphlayout::graph_data_json(&graph_data);
479 Some((graph_json, graph_data.nodes.len(), graph_data.edges.len()))
480 } else {
481 None
482 };
483 t.mark("graph_layout");
484
485 let total_links = site.graph.edges.len();
491 let (section_rows, recent_rows) = compute_home_rows(&site.docs);
492 let home_sections: Vec<HomeSection> = section_rows
493 .iter()
494 .map(|(label, slug, count)| HomeSection {
495 label,
496 slug,
497 count: *count,
498 })
499 .collect();
500 let home_recent: Vec<HomeRecent> = recent_rows
501 .iter()
502 .map(|(title, slug, section)| HomeRecent {
503 title,
504 slug,
505 section,
506 })
507 .collect();
508
509 let shared = PageShared {
510 tree: &tree,
511 graph: &site.graph,
512 commit: &commit_hash,
513 built: &built_stamp,
514 base: &config.base,
515 site_title: config.title.as_deref().unwrap_or(""),
516 search_enabled: config.features.search,
517 has_diff,
518 has_components_css,
519 island_components: &island_components,
520 graph_payload: &graph_payload,
521 home_sections: &home_sections,
522 home_recent: &home_recent,
523 pages_count: site.docs.len(),
524 total_links,
525 };
526
527 let mut home_html: Option<String> = None;
529 for doc in &site.docs {
530 let html = render_one_page(&renderer, &shared, doc)?;
531 let out_dir = dist_dir.join(&doc.slug);
533 fs::create_dir_all(&out_dir)?;
534 fs::write(out_dir.join("index.html"), &html)?;
535 if doc.slug == HOME_SLUG {
536 home_html = Some(html);
537 }
538 }
539
540 t.mark("render_pages");
541
542 if let Some(html) = home_html {
546 fs::write(dist_dir.join("index.html"), html)?;
547 }
548
549 let not_found_html = renderer.render_page(&PageContext {
554 title: "404",
555 description: "This page could not be found.",
556 slug: "404",
557 body_html: "<p>The page you’re looking for doesn’t exist or was moved. \
558 Use the navigation sidebar or search (<kbd class=\"docgen-kbd\">⌘K</kbd>) \
559 to find your way.</p>",
560 tree: &tree,
561 backlinks: &[],
562 headings: &[],
563 commit: &commit_hash,
564 built: &built_stamp,
565 has_history: false,
566 has_mermaid: false,
567 has_math: false,
568 base: &config.base,
569 site_title: config.title.as_deref().unwrap_or(""),
570 search_enabled: config.features.search,
571 has_diff,
572 has_components_css: false,
573 has_component_island: false,
574 is_home: false,
575 graph_json: "",
576 graph_node_count: 0,
577 graph_edge_count: 0,
578 home: None,
579 })?;
580 fs::write(dist_dir.join("404.html"), not_found_html)?;
581
582 if let Some((graph_json, node_count, edge_count)) = &graph_payload {
585 let graph_html = renderer.render_graph(&GraphContext {
586 tree: &tree,
587 graph_json,
588 node_count: *node_count,
589 edge_count: *edge_count,
590 base: &config.base,
591 site_title: config.title.as_deref().unwrap_or(""),
592 search_enabled: config.features.search,
593 has_diff,
594 })?;
595 let graph_dir = dist_dir.join("graph");
596 fs::create_dir_all(&graph_dir)?;
597 fs::write(graph_dir.join("index.html"), graph_html)?;
598 }
599
600 if config.features.search {
602 fs::write(
603 dist_dir.join("search-index.json"),
604 docgen_core::search::index_json(&site.search),
605 )?;
606 }
607
608 let emit_opts = docgen_assets::EmitOptions {
613 include_katex_runtime: false,
614 include_mermaid: site.any_mermaid || opts.mode == BuildMode::Dev,
621 include_graph: config.features.graph,
622 include_diff: has_diff,
623 include_component_css: false,
626 include_component_js: false,
627 include_search: config.features.search,
631 };
632 docgen_assets::emit(&docgen_assets::assets_for(&emit_opts), dist_dir)?;
633
634 docgen_assets::emit_component_bundle(dist_dir, &component_css, &component_js)?;
637
638 t.mark("emit_assets");
639
640 staging.commit(final_dir)?;
643 t.mark("commit");
644 t.report();
645
646 let page_count = site.docs.len();
647 let any_mermaid = site.any_mermaid;
648 let outcome = BuildOutcome {
649 page_count,
650 any_mermaid,
651 out_dir: final_dir.to_path_buf(),
652 };
653
654 let captured = if capture {
657 Some(crate::incremental::CapturedBuild {
658 config,
659 registry,
660 partials,
661 prepared: prepared_cap.expect("prepared is cloned whenever capture is set"),
662 docs: site.docs,
663 outbound: site.outbound,
664 graph: site.graph,
665 tree,
666 graph_payload,
667 island_components,
668 has_components_css,
669 commit_hash,
670 built_stamp,
671 has_diff,
672 search: site.search,
673 })
674 } else {
675 None
676 };
677
678 Ok((outcome, captured))
679}
680
681struct StagingDir {
688 path: PathBuf,
689 committed: bool,
690}
691
692impl StagingDir {
693 fn new(final_dir: &Path) -> Result<Self> {
696 let parent = final_dir
697 .parent()
698 .map(Path::to_path_buf)
699 .unwrap_or_else(|| PathBuf::from("."));
700 fs::create_dir_all(&parent)
701 .with_context(|| format!("creating staging parent {}", parent.display()))?;
702 let file_name = final_dir
703 .file_name()
704 .map(|n| n.to_string_lossy().into_owned())
705 .unwrap_or_else(|| "dist".to_string());
706 let path = parent.join(format!(".{file_name}.staging-{}", std::process::id()));
707 let _ = fs::remove_dir_all(&path);
708 fs::create_dir_all(&path)
709 .with_context(|| format!("creating staging dir {}", path.display()))?;
710 Ok(Self {
711 path,
712 committed: false,
713 })
714 }
715
716 fn path(&self) -> &Path {
717 &self.path
718 }
719
720 fn commit(mut self, final_dir: &Path) -> Result<()> {
723 let _ = fs::remove_dir_all(final_dir);
724 if let Some(parent) = final_dir.parent() {
725 fs::create_dir_all(parent)?;
726 }
727 fs::rename(&self.path, final_dir).with_context(|| {
728 format!(
729 "swapping build {} -> {}",
730 self.path.display(),
731 final_dir.display()
732 )
733 })?;
734 self.committed = true;
735 Ok(())
736 }
737}
738
739impl Drop for StagingDir {
740 fn drop(&mut self) {
741 if !self.committed {
742 let _ = fs::remove_dir_all(&self.path);
743 }
744 }
745}