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 let site = render_docs(prepared, &partials, &config, ®istry);
382 t.mark("render_docs");
383 let tree = build_tree(&site.docs);
384 t.mark("build_tree");
385
386 let has_components_css = !registry.styles().is_empty();
392 let used_components: std::collections::BTreeSet<&str> = site
393 .docs
394 .iter()
395 .flat_map(|d| d.components_used.iter().map(String::as_str))
396 .collect();
397 let component_css: String = registry
398 .styles()
399 .iter()
400 .filter_map(|c| c.style_css.as_deref())
401 .collect::<Vec<_>>()
402 .join("\n");
403 let component_js: String = registry
404 .islands()
405 .iter()
406 .filter(|c| used_components.contains(c.name.as_str()))
407 .filter_map(|c| c.island_js.as_deref())
408 .collect::<Vec<_>>()
409 .join("\n");
410 let island_components: std::collections::BTreeSet<String> = registry
412 .islands()
413 .iter()
414 .filter(|c| used_components.contains(c.name.as_str()))
415 .map(|c| c.name.clone())
416 .collect();
417
418 let renderer = Renderer::new(DEFAULT_PAGE_TEMPLATE)?;
419
420 let staging = StagingDir::new(final_dir)?;
426 let dist_dir = staging.path();
427
428 let repo = docgen_diff::discover_repo(&docs_dir)
432 .with_context(|| format!("discovering git repo for {}", docs_dir.display()))?;
433 let now = Local::now();
437 let built_stamp = now.format("%Y-%m-%d %H:%M").to_string();
438 let mut commit_hash = String::new();
439 let mut has_diff = false;
442 if let Some(repo) = repo {
443 if let Ok(head) = repo.head() {
444 if let Some(oid) = head.target() {
445 let s = oid.to_string();
446 commit_hash = s.chars().take(7).collect();
447 }
448 }
449 if let Some(workdir) = repo.workdir().map(Path::to_path_buf) {
450 if let Some(docs_prefix) =
453 git_rel_path(&docs_dir, &workdir, "").map(|p| p.trim_end_matches('/').to_string())
454 {
455 let limit = diff_limit();
456 let report =
459 docgen_diff::build_global_doc_diff_report(&repo, &docs_prefix, limit, true)
460 .with_context(|| {
461 format!("building global doc diff report for {docs_prefix}")
462 })?;
463 t.mark("diff_report");
464 if let Some(report) = report {
465 let diff_dir = dist_dir.join("diff");
466 fs::create_dir_all(diff_dir.join("revisions"))?;
467 let summary = docgen_diff::summarize_report(&report);
469 fs::write(
470 diff_dir.join("timeline.json"),
471 serde_json::to_vec(&summary)?,
472 )?;
473 for point in &report.timeline {
476 fs::write(
477 diff_dir
478 .join("revisions")
479 .join(format!("{}.json", point.id)),
480 serde_json::to_vec(point)?,
481 )?;
482 }
483 let diff_html = renderer.render_diff(&docgen_render::DiffContext {
485 tree: &tree,
486 base: &config.base,
487 site_title: config.title.as_deref().unwrap_or(""),
488 search_enabled: config.features.search,
489 })?;
490 fs::write(diff_dir.join("index.html"), diff_html)?;
491 has_diff = true;
492 }
493 }
494 }
495 }
496
497 let graph_payload: Option<(String, usize, usize)> = if config.features.graph {
502 let graph_data = site.graph_data(docgen_core::graphlayout::LayoutParams::default());
503 let graph_json = docgen_core::graphlayout::graph_data_json(&graph_data);
504 Some((graph_json, graph_data.nodes.len(), graph_data.edges.len()))
505 } else {
506 None
507 };
508 t.mark("graph_layout");
509
510 let total_links = site.graph.edges.len();
516 let (section_rows, recent_rows) = compute_home_rows(&site.docs);
517 let home_sections: Vec<HomeSection> = section_rows
518 .iter()
519 .map(|(label, slug, count)| HomeSection {
520 label,
521 slug,
522 count: *count,
523 })
524 .collect();
525 let home_recent: Vec<HomeRecent> = recent_rows
526 .iter()
527 .map(|(title, slug, section)| HomeRecent {
528 title,
529 slug,
530 section,
531 })
532 .collect();
533
534 let shared = PageShared {
535 tree: &tree,
536 graph: &site.graph,
537 commit: &commit_hash,
538 built: &built_stamp,
539 base: &config.base,
540 site_title: config.title.as_deref().unwrap_or(""),
541 search_enabled: config.features.search,
542 has_diff,
543 has_components_css,
544 island_components: &island_components,
545 graph_payload: &graph_payload,
546 home_sections: &home_sections,
547 home_recent: &home_recent,
548 pages_count: site.docs.len(),
549 total_links,
550 };
551
552 let mut home_html: Option<String> = None;
554 for doc in &site.docs {
555 let html = render_one_page(&renderer, &shared, doc)?;
556 let out_dir = dist_dir.join(&doc.slug);
558 fs::create_dir_all(&out_dir)?;
559 fs::write(out_dir.join("index.html"), &html)?;
560 if doc.slug == HOME_SLUG {
561 home_html = Some(html);
562 }
563 }
564
565 t.mark("render_pages");
566
567 copy_assets(&docs_dir, dist_dir)?;
572 t.mark("copy_assets");
573
574 if let Some(html) = home_html {
578 fs::write(dist_dir.join("index.html"), html)?;
579 }
580
581 let not_found_html = renderer.render_page(&PageContext {
586 title: "404",
587 description: "This page could not be found.",
588 slug: "404",
589 body_html: "<p>The page you’re looking for doesn’t exist or was moved. \
590 Use the navigation sidebar or search (<kbd class=\"docgen-kbd\">⌘K</kbd>) \
591 to find your way.</p>",
592 tree: &tree,
593 backlinks: &[],
594 headings: &[],
595 commit: &commit_hash,
596 built: &built_stamp,
597 has_history: false,
598 has_mermaid: false,
599 has_math: false,
600 base: &config.base,
601 site_title: config.title.as_deref().unwrap_or(""),
602 search_enabled: config.features.search,
603 has_diff,
604 has_components_css: false,
605 has_component_island: false,
606 is_home: false,
607 graph_json: "",
608 graph_node_count: 0,
609 graph_edge_count: 0,
610 home: None,
611 })?;
612 fs::write(dist_dir.join("404.html"), not_found_html)?;
613
614 if let Some((graph_json, node_count, edge_count)) = &graph_payload {
617 let graph_html = renderer.render_graph(&GraphContext {
618 tree: &tree,
619 graph_json,
620 node_count: *node_count,
621 edge_count: *edge_count,
622 base: &config.base,
623 site_title: config.title.as_deref().unwrap_or(""),
624 search_enabled: config.features.search,
625 has_diff,
626 })?;
627 let graph_dir = dist_dir.join("graph");
628 fs::create_dir_all(&graph_dir)?;
629 fs::write(graph_dir.join("index.html"), graph_html)?;
630 }
631
632 if config.features.search {
634 fs::write(
635 dist_dir.join("search-index.json"),
636 docgen_core::search::index_json(&site.search),
637 )?;
638 }
639
640 let emit_opts = docgen_assets::EmitOptions {
645 include_katex_runtime: false,
646 include_mermaid: site.any_mermaid || opts.mode == BuildMode::Dev,
653 include_graph: config.features.graph,
654 include_diff: has_diff,
655 include_component_css: false,
658 include_component_js: false,
659 include_search: config.features.search,
663 };
664 docgen_assets::emit(&docgen_assets::assets_for(&emit_opts), dist_dir)?;
665
666 docgen_assets::emit_component_bundle(dist_dir, &component_css, &component_js)?;
669
670 t.mark("emit_assets");
671
672 staging.commit(final_dir)?;
675 t.mark("commit");
676 t.report();
677
678 let page_count = site.docs.len();
679 let any_mermaid = site.any_mermaid;
680 let outcome = BuildOutcome {
681 page_count,
682 any_mermaid,
683 out_dir: final_dir.to_path_buf(),
684 };
685
686 let captured = if capture {
689 Some(crate::incremental::CapturedBuild {
690 config,
691 registry,
692 partials,
693 prepared: prepared_cap.expect("prepared is cloned whenever capture is set"),
694 docs: site.docs,
695 outbound: site.outbound,
696 graph: site.graph,
697 tree,
698 graph_payload,
699 island_components,
700 has_components_css,
701 commit_hash,
702 built_stamp,
703 has_diff,
704 search: site.search,
705 })
706 } else {
707 None
708 };
709
710 Ok((outcome, captured))
711}
712
713struct StagingDir {
720 path: PathBuf,
721 committed: bool,
722}
723
724impl StagingDir {
725 fn new(final_dir: &Path) -> Result<Self> {
728 let parent = final_dir
729 .parent()
730 .map(Path::to_path_buf)
731 .unwrap_or_else(|| PathBuf::from("."));
732 fs::create_dir_all(&parent)
733 .with_context(|| format!("creating staging parent {}", parent.display()))?;
734 let file_name = final_dir
735 .file_name()
736 .map(|n| n.to_string_lossy().into_owned())
737 .unwrap_or_else(|| "dist".to_string());
738 let path = parent.join(format!(".{file_name}.staging-{}", std::process::id()));
739 let _ = fs::remove_dir_all(&path);
740 fs::create_dir_all(&path)
741 .with_context(|| format!("creating staging dir {}", path.display()))?;
742 Ok(Self {
743 path,
744 committed: false,
745 })
746 }
747
748 fn path(&self) -> &Path {
749 &self.path
750 }
751
752 fn commit(mut self, final_dir: &Path) -> Result<()> {
755 let _ = fs::remove_dir_all(final_dir);
756 if let Some(parent) = final_dir.parent() {
757 fs::create_dir_all(parent)?;
758 }
759 fs::rename(&self.path, final_dir).with_context(|| {
760 format!(
761 "swapping build {} -> {}",
762 self.path.display(),
763 final_dir.display()
764 )
765 })?;
766 self.committed = true;
767 Ok(())
768 }
769}
770
771impl Drop for StagingDir {
772 fn drop(&mut self) {
773 if !self.committed {
774 let _ = fs::remove_dir_all(&self.path);
775 }
776 }
777}