#[allow(dead_code)]
mod history;
pub mod incremental;
pub use incremental::{DevState, RebuildKind, Rebuilt};
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use chrono::Local;
use docgen_core::discover::{discover_assets, discover_docs};
use docgen_core::pipeline::{prepare, render_docs};
use docgen_core::tree::build_tree;
use docgen_render::{
GraphContext, HomeData, HomeRecent, HomeSection, PageContext, Renderer, DEFAULT_PAGE_TEMPLATE,
};
const HOME_SLUG: &str = "index";
struct PhaseTimer {
enabled: bool,
last: std::time::Instant,
start: std::time::Instant,
rows: Vec<(&'static str, u128)>,
}
impl PhaseTimer {
fn new() -> Self {
let now = std::time::Instant::now();
Self {
enabled: std::env::var_os("DOCGEN_TIMINGS").is_some(),
last: now,
start: now,
rows: Vec::new(),
}
}
fn mark(&mut self, label: &'static str) {
if !self.enabled {
return;
}
let now = std::time::Instant::now();
self.rows
.push((label, now.duration_since(self.last).as_millis()));
self.last = now;
}
fn report(&self) {
if !self.enabled {
return;
}
let total = self.start.elapsed().as_millis();
eprintln!("── build timings (ms) ──");
for (label, ms) in &self.rows {
eprintln!(" {label:<16} {ms:>6}");
}
eprintln!(" {:<16} {total:>6}", "TOTAL");
}
}
const DEFAULT_DIFF_LIMIT: usize = 50;
const MAX_DIFF_LIMIT: usize = 200;
fn diff_limit() -> usize {
std::env::var("DOC_DIFF_LIMIT")
.ok()
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(DEFAULT_DIFF_LIMIT)
.clamp(1, MAX_DIFF_LIMIT)
}
pub(crate) fn copy_assets(docs_dir: &Path, out_dir: &Path) -> Result<()> {
let assets = discover_assets(docs_dir)
.with_context(|| format!("discovering assets in {}", docs_dir.display()))?;
for asset in &assets {
let dest = out_dir.join(&asset.rel_path);
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("creating asset dir {}", parent.display()))?;
}
fs::copy(&asset.src_path, &dest).with_context(|| {
format!(
"copying asset {} -> {}",
asset.src_path.display(),
dest.display()
)
})?;
}
Ok(())
}
fn git_rel_path(docs_dir: &Path, workdir: &Path, rel_path: &str) -> Option<String> {
let docs_canon = docs_dir.canonicalize().ok();
let work_canon = workdir.canonicalize().ok();
let (docs_ref, work_ref) = match (&docs_canon, &work_canon) {
(Some(d), Some(w)) => (d.as_path(), w.as_path()),
_ => (docs_dir, workdir),
};
let prefix = docs_ref.strip_prefix(work_ref).ok()?;
let mut parts: Vec<String> = prefix
.components()
.map(|c| c.as_os_str().to_string_lossy().into_owned())
.collect();
parts.push(rel_path.to_string());
Some(parts.join("/"))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum BuildMode {
#[default]
Production,
Dev,
}
pub struct BuildOptions<'a> {
pub project_root: &'a Path,
pub out_dir: &'a Path,
pub mode: BuildMode,
}
#[derive(Debug, Clone)]
pub struct BuildOutcome {
pub page_count: usize,
pub any_mermaid: bool,
pub out_dir: PathBuf,
}
pub fn build(project_root: &Path) -> Result<BuildOutcome> {
build_site(&BuildOptions {
project_root,
out_dir: &project_root.join("dist"),
mode: BuildMode::Production,
})
}
pub(crate) struct PageShared<'a> {
pub tree: &'a [docgen_core::model::TreeNode],
pub graph: &'a docgen_core::graph::LinkGraph,
pub commit: &'a str,
pub built: &'a str,
pub base: &'a str,
pub site_title: &'a str,
pub search_enabled: bool,
pub has_diff: bool,
pub has_components_css: bool,
pub island_components: &'a std::collections::BTreeSet<String>,
pub graph_payload: &'a Option<(String, usize, usize)>,
pub home_sections: &'a [HomeSection<'a>],
pub home_recent: &'a [HomeRecent<'a>],
pub pages_count: usize,
pub total_links: usize,
}
pub(crate) fn render_one_page(
renderer: &Renderer,
shared: &PageShared,
doc: &docgen_core::model::Doc,
) -> Result<String> {
static EMPTY: Vec<docgen_core::model::Backlink> = Vec::new();
let backlinks = shared.graph.backlinks.get(&doc.slug).unwrap_or(&EMPTY);
let is_home = doc.slug == HOME_SLUG;
let (graph_json, graph_node_count, graph_edge_count) = match (is_home, shared.graph_payload) {
(true, Some((json, nodes, edges))) => (json.as_str(), *nodes, *edges),
_ => ("", 0, 0),
};
Ok(renderer.render_page(&PageContext {
title: &doc.title,
description: if is_home {
""
} else {
doc.description.as_deref().unwrap_or("")
},
slug: &doc.slug,
body_html: &doc.body_html,
tree: shared.tree,
backlinks,
headings: &doc.headings,
commit: shared.commit,
built: shared.built,
has_history: false,
has_mermaid: doc.has_mermaid,
has_math: doc.has_math,
base: shared.base,
site_title: shared.site_title,
search_enabled: shared.search_enabled,
has_diff: shared.has_diff,
has_components_css: shared.has_components_css,
has_component_island: doc
.components_used
.iter()
.any(|c| shared.island_components.contains(c)),
is_home,
graph_json,
graph_node_count,
graph_edge_count,
home: if is_home {
Some(HomeData {
description: doc.description.as_deref().unwrap_or(""),
pages: shared.pages_count,
links: shared.total_links,
sections: shared.home_sections,
recent: shared.home_recent,
})
} else {
None
},
})?)
}
#[allow(clippy::type_complexity)]
pub(crate) fn compute_home_rows(
docs: &[docgen_core::model::Doc],
) -> (Vec<(String, String, usize)>, Vec<(String, String, String)>) {
let mut section_rows: Vec<(String, String, usize)> = Vec::new();
let mut section_idx: std::collections::BTreeMap<String, usize> =
std::collections::BTreeMap::new();
for doc in docs {
if doc.slug == HOME_SLUG || !doc.slug.contains('/') {
continue;
}
let label = doc.slug.split('/').next().unwrap_or("").to_string();
match section_idx.get(&label) {
Some(&i) => section_rows[i].2 += 1,
None => {
section_idx.insert(label.clone(), section_rows.len());
section_rows.push((label, doc.slug.clone(), 1));
}
}
}
let recent_rows: Vec<(String, String, String)> = docs
.iter()
.filter(|d| d.slug != HOME_SLUG)
.take(6)
.map(|d| {
let section = d
.slug
.split_once('/')
.map(|(s, _)| s.to_string())
.unwrap_or_default();
(d.title.clone(), d.slug.clone(), section)
})
.collect();
(section_rows, recent_rows)
}
pub fn build_site(opts: &BuildOptions) -> Result<BuildOutcome> {
Ok(build_site_inner(opts, false)?.0)
}
pub(crate) fn build_site_inner(
opts: &BuildOptions,
capture: bool,
) -> Result<(BuildOutcome, Option<crate::incremental::CapturedBuild>)> {
let docs_dir = opts.project_root.join("docs");
let final_dir = opts.out_dir;
let mut t = PhaseTimer::new();
let raws = discover_docs(&docs_dir)
.with_context(|| format!("reading docs from {}", docs_dir.display()))?;
t.mark("discover");
let (pages, partials) = docgen_core::pipeline::partition_partials(raws);
let prepared: Vec<_> = pages.into_iter().map(prepare).collect();
t.mark("prepare");
let prepared_cap = if capture {
Some(prepared.clone())
} else {
None
};
let mut config = docgen_config::load(opts.project_root)
.with_context(|| format!("loading docgen.toml from {}", opts.project_root.display()))?;
config.base = docgen_config::resolve_base(&config.base);
let builtins: Vec<docgen_components::Component> = docgen_assets::builtin_components()
.into_iter()
.map(|b| {
docgen_components::Component::from_parts(
b.name,
b.template,
b.island_js.map(str::to_string),
b.style_css.map(str::to_string),
)
})
.collect();
let components_dir = opts.project_root.join(&config.components.dir);
let registry = docgen_components::build_registry(builtins, &components_dir)
.with_context(|| format!("discovering components in {}", components_dir.display()))?;
t.mark("config+registry");
#[cfg(feature = "s3")]
let (s3_manifest, s3_creds): (Option<docgen_s3::AssetManifest>, _) = match &config.s3 {
Some(s3cfg) if opts.mode == BuildMode::Production => {
let assets = discover_assets(&docs_dir)
.with_context(|| format!("discovering assets in {}", docs_dir.display()))?;
let manifest =
docgen_s3::build_manifest(&assets, s3cfg).context("building S3 asset manifest")?;
let creds = docgen_s3::credentials_from_env();
(Some(manifest), creds)
}
_ => (None, None),
};
#[cfg(not(feature = "s3"))]
if config.s3.is_some() && opts.mode == BuildMode::Production {
eprintln!(
"S3: [s3] configured but this docgen build was compiled without the `s3` \
feature — copying attachments into dist/ instead"
);
}
#[cfg(feature = "s3")]
let resolver: Option<&dyn docgen_core::asseturl::AssetUrlResolver> =
match (&s3_manifest, &s3_creds) {
(Some(m), Some(_)) => Some(m),
_ => None,
};
#[cfg(not(feature = "s3"))]
let resolver: Option<&dyn docgen_core::asseturl::AssetUrlResolver> = None;
let diagrams = docgen_core::discover::discover_diagrams(&docs_dir)
.with_context(|| format!("loading PlantUML sources in {}", docs_dir.display()))?;
let plantuml_server = if config.features.plantuml {
Some(docgen_config::resolve_plantuml_server(
&config.plantuml.server,
))
} else {
None
};
let site = {
let plantuml_renderer = plantuml_server.as_ref().map(|server| {
docgen_plantuml::HttpRenderer::new(server.clone(), opts.project_root.join(".docgen"))
});
let plantuml_support =
plantuml_renderer
.as_ref()
.map(|r| docgen_core::plantuml::PlantumlSupport {
diagrams: &diagrams,
renderer: Some(r as &dyn docgen_core::PlantumlRenderer),
});
render_docs(
prepared,
&partials,
&config,
®istry,
resolver,
plantuml_support.as_ref(),
)
};
t.mark("render_docs");
let tree = build_tree(&site.docs);
t.mark("build_tree");
let has_components_css = !registry.styles().is_empty();
let used_components: std::collections::BTreeSet<&str> = site
.docs
.iter()
.flat_map(|d| d.components_used.iter().map(String::as_str))
.collect();
let component_css: String = registry
.styles()
.iter()
.filter_map(|c| c.style_css.as_deref())
.collect::<Vec<_>>()
.join("\n");
let component_js: String = registry
.islands()
.iter()
.filter(|c| used_components.contains(c.name.as_str()))
.filter_map(|c| c.island_js.as_deref())
.collect::<Vec<_>>()
.join("\n");
let island_components: std::collections::BTreeSet<String> = registry
.islands()
.iter()
.filter(|c| used_components.contains(c.name.as_str()))
.map(|c| c.name.clone())
.collect();
let renderer = Renderer::new(DEFAULT_PAGE_TEMPLATE)?;
let staging = StagingDir::new(final_dir)?;
let dist_dir = staging.path();
let repo = docgen_diff::discover_repo(&docs_dir)
.with_context(|| format!("discovering git repo for {}", docs_dir.display()))?;
let now = Local::now();
let built_stamp = now.format("%Y-%m-%d %H:%M").to_string();
let mut commit_hash = String::new();
let mut has_diff = false;
if let Some(repo) = repo {
if let Ok(head) = repo.head() {
if let Some(oid) = head.target() {
let s = oid.to_string();
commit_hash = s.chars().take(7).collect();
}
}
if let Some(workdir) = repo.workdir().map(Path::to_path_buf) {
if let Some(docs_prefix) =
git_rel_path(&docs_dir, &workdir, "").map(|p| p.trim_end_matches('/').to_string())
{
let limit = diff_limit();
let report =
docgen_diff::build_global_doc_diff_report(&repo, &docs_prefix, limit, true)
.with_context(|| {
format!("building global doc diff report for {docs_prefix}")
})?;
t.mark("diff_report");
if let Some(report) = report {
let diff_dir = dist_dir.join("diff");
fs::create_dir_all(diff_dir.join("revisions"))?;
let summary = docgen_diff::summarize_report(&report);
fs::write(
diff_dir.join("timeline.json"),
serde_json::to_vec(&summary)?,
)?;
for point in &report.timeline {
fs::write(
diff_dir
.join("revisions")
.join(format!("{}.json", point.id)),
serde_json::to_vec(point)?,
)?;
}
let diff_html = renderer.render_diff(&docgen_render::DiffContext {
tree: &tree,
base: &config.base,
site_title: config.title.as_deref().unwrap_or(""),
search_enabled: config.features.search,
})?;
fs::write(diff_dir.join("index.html"), diff_html)?;
has_diff = true;
}
}
}
}
let graph_payload: Option<(String, usize, usize)> = if config.features.graph {
let graph_data = site.graph_data(docgen_core::graphlayout::LayoutParams::default());
let graph_json = docgen_core::graphlayout::graph_data_json(&graph_data);
Some((graph_json, graph_data.nodes.len(), graph_data.edges.len()))
} else {
None
};
t.mark("graph_layout");
let total_links = site.graph.edges.len();
let (section_rows, recent_rows) = compute_home_rows(&site.docs);
let home_sections: Vec<HomeSection> = section_rows
.iter()
.map(|(label, slug, count)| HomeSection {
label,
slug,
count: *count,
})
.collect();
let home_recent: Vec<HomeRecent> = recent_rows
.iter()
.map(|(title, slug, section)| HomeRecent {
title,
slug,
section,
})
.collect();
let shared = PageShared {
tree: &tree,
graph: &site.graph,
commit: &commit_hash,
built: &built_stamp,
base: &config.base,
site_title: config.title.as_deref().unwrap_or(""),
search_enabled: config.features.search,
has_diff,
has_components_css,
island_components: &island_components,
graph_payload: &graph_payload,
home_sections: &home_sections,
home_recent: &home_recent,
pages_count: site.docs.len(),
total_links,
};
let mut home_html: Option<String> = None;
for doc in &site.docs {
let html = render_one_page(&renderer, &shared, doc)?;
let out_dir = dist_dir.join(&doc.slug);
fs::create_dir_all(&out_dir)?;
fs::write(out_dir.join("index.html"), &html)?;
if doc.slug == HOME_SLUG {
home_html = Some(html);
}
}
t.mark("render_pages");
#[cfg(feature = "s3")]
{
match (&s3_manifest, &s3_creds) {
(Some(manifest), Some(creds)) => {
let s3cfg = config
.s3
.as_ref()
.expect("s3 config present when manifest is");
let stats =
docgen_s3::upload(manifest, s3cfg, creds).context("uploading assets to S3")?;
let prefix = s3cfg.prefix.as_deref().unwrap_or("");
eprintln!(
"S3: {} uploaded, {} already present -> s3://{}/{} (public: {})",
stats.uploaded, stats.skipped, s3cfg.bucket, prefix, s3cfg.public_url
);
}
(Some(manifest), None) => {
eprintln!(
"S3: [s3] configured but AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY not \
set — copying {} attachments into dist/ instead",
manifest.entries().len()
);
copy_assets(&docs_dir, dist_dir)?;
}
(None, _) => copy_assets(&docs_dir, dist_dir)?,
}
}
#[cfg(not(feature = "s3"))]
copy_assets(&docs_dir, dist_dir)?;
t.mark("copy_assets");
if let Some(html) = home_html {
fs::write(dist_dir.join("index.html"), html)?;
}
let not_found_html = renderer.render_page(&PageContext {
title: "404",
description: "This page could not be found.",
slug: "404",
body_html: "<p>The page you’re looking for doesn’t exist or was moved. \
Use the navigation sidebar or search (<kbd class=\"docgen-kbd\">⌘K</kbd>) \
to find your way.</p>",
tree: &tree,
backlinks: &[],
headings: &[],
commit: &commit_hash,
built: &built_stamp,
has_history: false,
has_mermaid: false,
has_math: false,
base: &config.base,
site_title: config.title.as_deref().unwrap_or(""),
search_enabled: config.features.search,
has_diff,
has_components_css: false,
has_component_island: false,
is_home: false,
graph_json: "",
graph_node_count: 0,
graph_edge_count: 0,
home: None,
})?;
fs::write(dist_dir.join("404.html"), not_found_html)?;
if let Some((graph_json, node_count, edge_count)) = &graph_payload {
let graph_html = renderer.render_graph(&GraphContext {
tree: &tree,
graph_json,
node_count: *node_count,
edge_count: *edge_count,
base: &config.base,
site_title: config.title.as_deref().unwrap_or(""),
search_enabled: config.features.search,
has_diff,
})?;
let graph_dir = dist_dir.join("graph");
fs::create_dir_all(&graph_dir)?;
fs::write(graph_dir.join("index.html"), graph_html)?;
}
if config.features.search {
fs::write(
dist_dir.join("search-index.json"),
docgen_core::search::index_json(&site.search),
)?;
}
let emit_opts = docgen_assets::EmitOptions {
include_katex_runtime: false,
include_mermaid: site.any_mermaid || opts.mode == BuildMode::Dev,
include_graph: config.features.graph,
include_diff: has_diff,
include_component_css: false,
include_component_js: false,
include_search: config.features.search,
};
docgen_assets::emit(&docgen_assets::assets_for(&emit_opts), dist_dir)?;
docgen_assets::emit_component_bundle(dist_dir, &component_css, &component_js)?;
t.mark("emit_assets");
staging.commit(final_dir)?;
t.mark("commit");
t.report();
let page_count = site.docs.len();
let any_mermaid = site.any_mermaid;
let outcome = BuildOutcome {
page_count,
any_mermaid,
out_dir: final_dir.to_path_buf(),
};
let captured = if capture {
Some(crate::incremental::CapturedBuild {
diagrams,
plantuml_server,
config,
registry,
partials,
prepared: prepared_cap.expect("prepared is cloned whenever capture is set"),
docs: site.docs,
outbound: site.outbound,
graph: site.graph,
tree,
graph_payload,
island_components,
has_components_css,
commit_hash,
built_stamp,
has_diff,
search: site.search,
})
} else {
None
};
Ok((outcome, captured))
}
struct StagingDir {
path: PathBuf,
committed: bool,
}
impl StagingDir {
fn new(final_dir: &Path) -> Result<Self> {
let parent = final_dir
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."));
fs::create_dir_all(&parent)
.with_context(|| format!("creating staging parent {}", parent.display()))?;
let file_name = final_dir
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "dist".to_string());
let path = parent.join(format!(".{file_name}.staging-{}", std::process::id()));
let _ = fs::remove_dir_all(&path);
fs::create_dir_all(&path)
.with_context(|| format!("creating staging dir {}", path.display()))?;
Ok(Self {
path,
committed: false,
})
}
fn path(&self) -> &Path {
&self.path
}
fn commit(mut self, final_dir: &Path) -> Result<()> {
let _ = fs::remove_dir_all(final_dir);
if let Some(parent) = final_dir.parent() {
fs::create_dir_all(parent)?;
}
fs::rename(&self.path, final_dir).with_context(|| {
format!(
"swapping build {} -> {}",
self.path.display(),
final_dir.display()
)
})?;
self.committed = true;
Ok(())
}
}
impl Drop for StagingDir {
fn drop(&mut self) {
if !self.committed {
let _ = fs::remove_dir_all(&self.path);
}
}
}