Commit
Message
Changed Files (8)
-
modified src/builders/markdown.rs
diff --git a/src/builders/markdown.rs b/src/builders/markdown.rs index bc4075e..f5d0b88 100644 --- a/src/builders/markdown.rs +++ b/src/builders/markdown.rs @@ -231,7 +231,7 @@ async fn build_directory( let tmpl_name = format!("markdown.{suffix}"); let content = match format { OutputFormat::Html => render_markdown(&md), - OutputFormat::Gemtext => crate::site::render_markdown_gemtext(&md), + OutputFormat::Gemtext => crate::render::render_markdown_gemtext(&md), }; let document = render_template(tera, &tmpl_name, &title, &content)?; -
modified src/builders/mod.rs
diff --git a/src/builders/mod.rs b/src/builders/mod.rs index 69a07d2..62ce38f 100644 --- a/src/builders/mod.rs +++ b/src/builders/mod.rs @@ -182,6 +182,7 @@ use tokio::sync::mpsc::UnboundedSender; pub mod archive; pub mod cargo; pub mod markdown; +pub(crate) mod orchestrator; pub mod script; use archive::{ArchiveBuilder, ArchiveBuilderConfig}; -
added src/builders/orchestrator.rs
diff --git a/src/builders/orchestrator.rs b/src/builders/orchestrator.rs new file mode 100644 index 0000000..84e1a3f --- /dev/null +++ b/src/builders/orchestrator.rs @@ -0,0 +1,252 @@ +use std::collections::HashMap; +use std::time::Duration; + +use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; +use miette::{IntoDiagnostic, Result}; +use tokio::sync::{mpsc, watch}; +use tokio::task::JoinSet; + +use crate::builders::{ArtifactPath, BuilderEntry, LogEvent}; +use crate::cli::{COLOURS, GREEN, RED, RESET, YELLOW}; +use crate::site::spinner_style; + +/// Run all builders in parallel, respecting `depends_on` ordering. +/// +/// Returns `(dist_artifacts, doc_artifacts)` – file-type artifacts go into +/// the dist/ directory while directory-type artifacts go into docs/. +pub(crate) async fn run_builders( + builders: &[BuilderEntry], + version: &str, +) -> Result<(Vec<ArtifactPath>, Vec<ArtifactPath>)> { + let mut dist_artifacts = Vec::new(); + let mut doc_artifacts = Vec::new(); + + let id_to_idx: HashMap<&str, usize> = builders + .iter() + .enumerate() + .filter_map(|(i, e)| e.id.as_deref().map(|id| (id, i))) + .collect(); + + for (i, entry) in builders.iter().enumerate() { + for dep in &entry.depends_on { + if !id_to_idx.contains_key(dep.as_str()) { + return Err(miette::miette!( + "builder #{i} ({}) lists '{}' in depends_on, \ + but no builder has that id", + entry.label(), + dep + )); + } + } + } + + { + let n = builders.len(); + let mut state = vec![0u8; n]; + + fn dfs( + idx: usize, + id_to_idx: &HashMap<&str, usize>, + builders: &[BuilderEntry], + state: &mut Vec<u8>, + ) -> Result<()> { + if state[idx] == 1 { + return Err(miette::miette!( + "dependency cycle detected involving builder #{idx} ({})", + builders[idx].id.as_deref().unwrap_or(builders[idx].label()) + )); + } + if state[idx] == 2 { + return Ok(()); + } + state[idx] = 1; + for dep in &builders[idx].depends_on { + if let Some(&dep_idx) = id_to_idx.get(dep.as_str()) { + dfs(dep_idx, id_to_idx, builders, state)?; + } + } + state[idx] = 2; + Ok(()) + } + + for i in 0..n { + dfs(i, &id_to_idx, builders, &mut state)?; + } + } + + let mut completion_txs: HashMap<String, watch::Sender<Option<bool>>> = HashMap::new(); + let mut completion_rxs: HashMap<String, watch::Receiver<Option<bool>>> = HashMap::new(); + + for entry in builders { + if let Some(id) = &entry.id { + let (tx, rx) = watch::channel(None::<bool>); + completion_txs.insert(id.clone(), tx); + completion_rxs.insert(id.clone(), rx); + } + } + + let total = builders.len(); + let multi = MultiProgress::new(); + + let summary = multi.add(ProgressBar::new(total as u64)); + summary.set_style( + ProgressStyle::with_template("{pos}/{len} builders {bar:20.green/white} {msg}") + .expect("valid template"), + ); + summary.set_message("building…"); + + let mut join_set: JoinSet<Result<Vec<ArtifactPath>>> = JoinSet::new(); + + for (i, entry) in builders.iter().enumerate() { + let color = COLOURS[i % COLOURS.len()]; + let label = entry.id.as_deref().unwrap_or(entry.label()); + let colored_prefix = format!("{color}[{label}]{RESET}"); + + let pb = multi.insert_before(&summary, ProgressBar::new_spinner()); + pb.set_style(spinner_style(false)); + pb.set_prefix(colored_prefix); + pb.set_message("starting…"); + pb.enable_steady_tick(Duration::from_millis(100)); + + let (log_tx, mut log_rx) = mpsc::unbounded_channel::<LogEvent>(); + + let pb_log = pb.clone(); + let multi_log = multi.clone(); + let parent_color_idx = i; + tokio::spawn(async move { + let mut child_pbs: HashMap<String, ProgressBar> = HashMap::new(); + let mut last_child_pb = pb_log.clone(); + let mut child_color_idx = parent_color_idx + 1; + + while let Some(event) = log_rx.recv().await { + match event { + LogEvent::Line(line) => { + pb_log.set_message(line); + } + LogEvent::ChildStart { id, label } => { + let child_color = COLOURS[child_color_idx % COLOURS.len()]; + child_color_idx += 1; + let child_pb = + multi_log.insert_after(&last_child_pb, ProgressBar::new_spinner()); + child_pb.set_style(spinner_style(true)); + child_pb.set_prefix(format!("{child_color}[{label}]{RESET}")); + child_pb.set_message("starting…"); + child_pb.enable_steady_tick(Duration::from_millis(100)); + last_child_pb = child_pb.clone(); + child_pbs.insert(id, child_pb); + } + LogEvent::ChildLine { id, line } => { + if let Some(child_pb) = child_pbs.get(&id) { + child_pb.set_message(line); + } + } + LogEvent::ChildFinish { + id, + success, + summary, + } => { + if let Some(child_pb) = child_pbs.remove(&id) { + if success { + child_pb.finish_with_message(format!( + "{GREEN}\u{2713}{RESET} {summary}" + )); + } else { + child_pb + .finish_with_message(format!("{RED}\u{2717}{RESET} {summary}")); + } + } + } + } + } + }); + + let dep_receivers: Vec<(String, watch::Receiver<Option<bool>>)> = entry + .depends_on + .iter() + .filter_map(|dep_id| { + completion_rxs + .get(dep_id) + .map(|rx| (dep_id.clone(), rx.clone())) + }) + .collect(); + + let my_tx: Option<watch::Sender<Option<bool>>> = + entry.id.as_ref().and_then(|id| completion_txs.remove(id)); + + let entry = entry.clone(); + let version = version.to_owned(); + let pb_task = pb.clone(); + let summary_task = summary.clone(); + + join_set.spawn(async move { + for (dep_id, mut rx) in dep_receivers { + pb_task.set_message(format!("waiting for '{dep_id}'…")); + + let resolved = rx.wait_for(|v| v.is_some()).await; + + let succeeded = match resolved { + Err(_) => false, + Ok(r) => r.unwrap_or(false), + }; + + if !succeeded { + summary_task.inc(1); + pb_task.finish_with_message(format!( + "{YELLOW}\u{29B8} skipped{RESET} (dependency '{dep_id}' failed)" + )); + if let Some(tx) = &my_tx { + let _ = tx.send(Some(false)); + } + return Ok(vec![]); + } + } + + pb_task.set_message("running…"); + let result = entry.build(&version, log_tx).await; + let succeeded = result.is_ok(); + + if let Some(tx) = my_tx { + let _ = tx.send(Some(succeeded)); + } + + summary_task.inc(1); + match &result { + Ok(artifacts) => pb_task.finish_with_message(format!( + "{GREEN}\u{2713} done{RESET} ({} artifact(s))", + artifacts.len() + )), + Err(e) => pb_task.finish_with_message(format!("{RED}\u{2717} failed:{RESET} {e}")), + } + result + }); + } + + let mut errors: Vec<miette::Report> = Vec::new(); + while let Some(res) = join_set.join_next().await { + match res.into_diagnostic()? { + Ok(artifacts) => { + for artifact in artifacts { + if artifact.path.is_dir() { + doc_artifacts.push(artifact); + } else { + dist_artifacts.push(artifact); + } + } + } + Err(e) => errors.push(e), + } + } + + let summary_msg = if errors.is_empty() { + format!("{GREEN}\u{2713} all done{RESET}") + } else { + format!("{RED}\u{2717} some builders failed{RESET}") + }; + summary.finish_with_message(summary_msg); + + if let Some(first_err) = errors.into_iter().next() { + return Err(first_err); + } + + Ok((dist_artifacts, doc_artifacts)) +} -
added src/git_browse.rs
diff --git a/src/git_browse.rs b/src/git_browse.rs new file mode 100644 index 0000000..191d3da --- /dev/null +++ b/src/git_browse.rs @@ -0,0 +1,395 @@ +use std::path::Path; + +use gix::bstr::ByteSlice; +use miette::{IntoDiagnostic, Result}; +use serde::Serialize; +use tera::{Context, Tera}; + +use crate::config::OutputFormat; +use crate::git_ui::{ + TEMPLATE_GIT_BLOB_GEMTEXT, TEMPLATE_GIT_BLOB_HTML, TEMPLATE_GIT_TREE_GEMTEXT, + TEMPLATE_GIT_TREE_HTML, +}; + +// ── Types ────────────────────────────────────────────────────────────────────── + +#[derive(Serialize)] +struct Crumb { + name: String, + url: Option<String>, +} + +#[derive(Serialize)] +#[serde(rename_all = "lowercase")] +enum TreeEntryKind { + Tree, + Blob, +} + +#[derive(Serialize)] +struct TreeEntry { + name: String, + kind: TreeEntryKind, + url: String, +} + +// ── Entry point ──────────────────────────────────────────────────────────────── + +/// Build the full static tree browser for every revision in `revisions`. +#[allow(clippy::too_many_arguments)] +pub(crate) fn build_browse_pages( + revisions: &[(String, gix::ObjectId)], + browse_dir: &Path, + repo_path: &Path, + theme_path: &Path, + project_name: &str, + lang: &Option<String>, + clone_url: &Option<String>, + formats: &[OutputFormat], +) -> Result<()> { + use syntect::highlighting::ThemeSet; + use syntect::parsing::SyntaxSet; + + let ss = SyntaxSet::load_defaults_newlines(); + let ts = ThemeSet::load_defaults(); + let theme = &ts.themes["InspiredGitHub"]; + + let mut tera = Tera::default(); + crate::site::register_format_templates( + &mut tera, + theme_path, + formats, + &[ + ( + "git_tree", + TEMPLATE_GIT_TREE_HTML, + TEMPLATE_GIT_TREE_GEMTEXT, + ), + ( + "git_blob", + TEMPLATE_GIT_BLOB_HTML, + TEMPLATE_GIT_BLOB_GEMTEXT, + ), + ], + )?; + + for (hash, oid) in revisions { + let rev_dir = browse_dir.join(hash); + std::fs::create_dir_all(&rev_dir).into_diagnostic()?; + + let repo = gix::open(repo_path).into_diagnostic()?; + let commit_obj = repo.find_object(*oid).into_diagnostic()?.into_commit(); + let decoded = commit_obj.decode().into_diagnostic()?; + let tree_id = decoded.tree(); + + walk_tree_dir( + repo_path, + &repo, + tree_id, + "", + hash, + &rev_dir, + &tera, + project_name, + lang, + clone_url, + &ss, + theme, + formats, + )?; + } + + Ok(()) +} + +// ── Tree walker ──────────────────────────────────────────────────────────────── + +#[allow(clippy::too_many_arguments)] +fn walk_tree_dir( + repo_path: &Path, + repo: &gix::Repository, + tree_id: gix::ObjectId, + dir_path: &str, + commit_hash: &str, + rev_dir: &Path, + tera: &Tera, + project_name: &str, + lang: &Option<String>, + clone_url: &Option<String>, + ss: &syntect::parsing::SyntaxSet, + theme: &syntect::highlighting::Theme, + formats: &[OutputFormat], +) -> Result<()> { + let tree_obj = repo.find_object(tree_id).into_diagnostic()?.into_tree(); + let decoded = tree_obj.decode().into_diagnostic()?; + + let depth: usize = dir_path.split('/').filter(|s| !s.is_empty()).count(); + + let page_dir = if dir_path.is_empty() { + rev_dir.to_path_buf() + } else { + dir_path + .split('/') + .filter(|s| !s.is_empty()) + .fold(rev_dir.to_path_buf(), |p, c| p.join(c)) + }; + std::fs::create_dir_all(&page_dir).into_diagnostic()?; + + let mut entries: Vec<TreeEntry> = Vec::new(); + let mut subdirs: Vec<(String, gix::ObjectId)> = Vec::new(); + let mut blobs: Vec<(String, gix::ObjectId)> = Vec::new(); + + for entry in decoded.entries.iter() { + let name = entry.filename.to_str_lossy().into_owned(); + let oid: gix::ObjectId = entry.oid.to_owned(); + + if entry.mode.is_tree() { + entries.push(TreeEntry { + url: format!("{name}/index.html"), + name: name.clone(), + kind: TreeEntryKind::Tree, + }); + subdirs.push((name, oid)); + } else { + entries.push(TreeEntry { + url: format!("{name}.html"), + name: name.clone(), + kind: TreeEntryKind::Blob, + }); + if !entry.mode.is_commit() { + blobs.push((name, oid)); + } + } + } + + entries.sort_by(|a, b| { + let a_tree = matches!(a.kind, TreeEntryKind::Tree); + let b_tree = matches!(b.kind, TreeEntryKind::Tree); + b_tree.cmp(&a_tree).then(a.name.cmp(&b.name)) + }); + + let root_path = "../".repeat(3 + depth); + let commit_url = format!("{}commit/{commit_hash}.html", "../".repeat(depth + 2)); + let breadcrumbs = make_crumbs(dir_path, false, None); + + for format in formats { + let suffix = format.extension(); + let tmpl_name = format!("git_tree.{suffix}"); + let ext = format.extension(); + + let mut ctx = Context::new(); + ctx.insert("project_name", project_name); + ctx.insert("lang", lang); + ctx.insert("clone_url", clone_url); + ctx.insert("commit_hash", commit_hash); + ctx.insert("commit_hash_short", &commit_hash[..7]); + ctx.insert("commit_url", &commit_url); + ctx.insert("dir_path", dir_path); + ctx.insert("entries", &entries); + ctx.insert("breadcrumbs", &breadcrumbs); + ctx.insert("root_path", &root_path); + + let content = tera + .render(&tmpl_name, &ctx) + .map_err(|e| miette::miette!("{e}"))?; + std::fs::write(page_dir.join(format!("index.{ext}")), content).into_diagnostic()?; + } + + for (name, oid) in subdirs { + let child_path = if dir_path.is_empty() { + name + } else { + format!("{dir_path}/{name}") + }; + walk_tree_dir( + repo_path, + repo, + oid, + &child_path, + commit_hash, + rev_dir, + tera, + project_name, + lang, + clone_url, + ss, + theme, + formats, + )?; + } + + for (name, oid) in blobs { + let file_path = if dir_path.is_empty() { + name.clone() + } else { + format!("{dir_path}/{name}") + }; + render_blob_page( + repo_path, + &name, + &file_path, + oid, + commit_hash, + depth, + &page_dir, + tera, + project_name, + lang, + clone_url, + ss, + theme, + formats, + )?; + } + + Ok(()) +} + +// ── Blob page ────────────────────────────────────────────────────────────────── + +#[allow(clippy::too_many_arguments)] +fn render_blob_page( + repo_path: &Path, + filename: &str, + file_path: &str, + oid: gix::ObjectId, + commit_hash: &str, + depth: usize, + page_dir: &Path, + tera: &Tera, + project_name: &str, + lang: &Option<String>, + clone_url: &Option<String>, + ss: &syntect::parsing::SyntaxSet, + theme: &syntect::highlighting::Theme, + formats: &[OutputFormat], +) -> Result<()> { + const MAX_BLOB_BYTES: usize = 1024 * 1024; + + let data: Vec<u8> = std::process::Command::new("git") + .current_dir(repo_path) + .args(["cat-file", "blob", &oid.to_string()]) + .output() + .map(|o| o.stdout) + .unwrap_or_default(); + + let is_binary = data[..data.len().min(8192)].contains(&0u8); + let too_large = data.len() > MAX_BLOB_BYTES; + + let text = String::from_utf8_lossy(&data); + let content_plain: Option<String> = if is_binary || too_large || data.is_empty() { + None + } else { + Some(text.to_string()) + }; + let content_html: Option<String> = if is_binary || too_large || data.is_empty() { + None + } else { + let ext = std::path::Path::new(filename) + .extension() + .and_then(|s| s.to_str()) + .unwrap_or(""); + let syntax = ss + .find_syntax_by_extension(ext) + .or_else(|| { + text.lines() + .next() + .and_then(|l| ss.find_syntax_by_first_line(l)) + }) + .unwrap_or_else(|| ss.find_syntax_plain_text()); + Some( + syntect::html::highlighted_html_for_string(&text, ss, syntax, theme) + .unwrap_or_else(|_| format!("<pre>{}</pre>", escape_html(&text))), + ) + }; + + let root_path = "../".repeat(3 + depth); + let commit_url = format!("{}commit/{commit_hash}.html", "../".repeat(depth + 2)); + let breadcrumbs = make_crumbs( + std::path::Path::new(file_path) + .parent() + .and_then(|p| p.to_str()) + .unwrap_or(""), + true, + Some(filename), + ); + + for format in formats { + let suffix = format.extension(); + let tmpl_name = format!("git_blob.{suffix}"); + let ext = format.extension(); + + let mut ctx = Context::new(); + ctx.insert("project_name", project_name); + ctx.insert("lang", lang); + ctx.insert("clone_url", clone_url); + ctx.insert("commit_hash", commit_hash); + ctx.insert("commit_hash_short", &commit_hash[..7]); + ctx.insert("commit_url", &commit_url); + ctx.insert("file_path", file_path); + ctx.insert("filename", filename); + ctx.insert("breadcrumbs", &breadcrumbs); + ctx.insert("content_html", &content_html); + ctx.insert("content_plain", &content_plain); + ctx.insert("is_binary", &is_binary); + ctx.insert("too_large", &too_large); + ctx.insert("size", &data.len()); + ctx.insert("root_path", &root_path); + + let content = tera + .render(&tmpl_name, &ctx) + .map_err(|e| miette::miette!("{e}"))?; + std::fs::write(page_dir.join(format!("{filename}.{ext}")), content).into_diagnostic()?; + } + + Ok(()) +} + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +fn make_crumbs(dir_path: &str, is_blob: bool, filename: Option<&str>) -> Vec<Crumb> { + let parts: Vec<&str> = dir_path.split('/').filter(|s| !s.is_empty()).collect(); + let depth = parts.len(); + let mut crumbs = Vec::new(); + + let root_url = if depth == 0 && !is_blob { + None + } else { + Some(format!("{}index.html", "../".repeat(depth))) + }; + crumbs.push(Crumb { + name: "~".to_string(), + url: root_url, + }); + + for (i, &part) in parts.iter().enumerate() { + let is_last_and_tree = i == depth - 1 && !is_blob; + let url = if is_last_and_tree { + None + } else { + let levels_up = depth - i - 1; + Some(format!("{}index.html", "../".repeat(levels_up))) + }; + crumbs.push(Crumb { + name: part.to_string(), + url, + }); + } + + if is_blob { + if let Some(name) = filename { + crumbs.push(Crumb { + name: name.to_string(), + url: None, + }); + } + } + + crumbs +} + +fn escape_html(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") +} -
modified src/git_ui.rs
diff --git a/src/git_ui.rs b/src/git_ui.rs index ea02a1e..26c0b8f 100644 --- a/src/git_ui.rs +++ b/src/git_ui.rs @@ -29,7 +29,7 @@ use serde::Serialize; use tera::{Context, Tera}; use tracing::warn; -use crate::config::{AbbayeConfig, GitUiConfig, OutputFormat}; +use crate::config::{AbbayeConfig, GitUiConfig}; // ── Template sources ────────────────────────────────────────────────────────── @@ -127,32 +127,6 @@ struct RefBadge { kind: RefBadgeKind, } -/// One level in the breadcrumb navigation on tree and blob pages. -#[derive(Serialize)] -struct Crumb { - name: String, - /// Relative link to this directory's `index.html`. `None` for the last - /// (current) segment - rendered as plain text, not a link. - url: Option<String>, -} - -/// Kind of an entry in a directory listing. Serialises as lowercase. -#[derive(Serialize)] -#[serde(rename_all = "lowercase")] -enum TreeEntryKind { - Tree, - Blob, -} - -/// One row in a directory listing. -#[derive(Serialize)] -struct TreeEntry { - name: String, - kind: TreeEntryKind, - /// Relative URL from the current tree page to this entry's page. - url: String, -} - /// One entry in the branch-switcher nav rendered on every log page. #[derive(Serialize)] struct BranchNav { @@ -454,7 +428,7 @@ pub async fn build_git_repository_ui(config: &AbbayeConfig, git_cfg: &GitUiConfi let formats = config.site.formats.clone(); tokio::task::spawn_blocking(move || { - build_browse_pages( + crate::git_browse::build_browse_pages( &browse_revisions, &browse_dir, &repo_path_browse, @@ -1056,390 +1030,6 @@ async fn prune_excluded_refs( Ok(()) } -// ── Tree browser ────────────────────────────────────────────────────────────────── - -/// Build the full static tree browser for every revision in `revisions`. -/// -/// Everything here is synchronous (gix + std::fs + syntect), intended to run -/// inside `tokio::task::spawn_blocking`. -#[allow(clippy::too_many_arguments)] -fn build_browse_pages( - revisions: &[(String, gix::ObjectId)], - browse_dir: &Path, // public/repository/browse/ - repo_path: &Path, // for `git cat-file blob` - theme_path: &Path, // .abbaye/theme (theme overrides) - project_name: &str, - lang: &Option<String>, - clone_url: &Option<String>, - formats: &[OutputFormat], -) -> Result<()> { - use syntect::highlighting::ThemeSet; - use syntect::parsing::SyntaxSet; - - let ss = SyntaxSet::load_defaults_newlines(); - let ts = ThemeSet::load_defaults(); - let theme = &ts.themes["InspiredGitHub"]; - - // Build a separate Tera instance for browse templates. - let mut tera = Tera::default(); - crate::site::register_format_templates( - &mut tera, - theme_path, - formats, - &[ - ( - "git_tree", - TEMPLATE_GIT_TREE_HTML, - TEMPLATE_GIT_TREE_GEMTEXT, - ), - ( - "git_blob", - TEMPLATE_GIT_BLOB_HTML, - TEMPLATE_GIT_BLOB_GEMTEXT, - ), - ], - )?; - - for (hash, oid) in revisions { - let rev_dir = browse_dir.join(hash); - std::fs::create_dir_all(&rev_dir).into_diagnostic()?; - - // Resolve the commit's root tree. - let repo = gix::open(repo_path).into_diagnostic()?; - let commit_obj = repo.find_object(*oid).into_diagnostic()?.into_commit(); - let decoded = commit_obj.decode().into_diagnostic()?; - let tree_id = decoded.tree(); - - walk_tree_dir( - repo_path, - &repo, - tree_id, - "", - hash, - &rev_dir, - &tera, - project_name, - lang, - clone_url, - &ss, - theme, - formats, - )?; - } - - Ok(()) -} - -/// Recursively generate one `index.html` (directory listing) per tree and one -/// `<name>.html` per blob, rooted at `rev_dir`. -/// TODO: Fix clippy warning about too many arguments -#[allow(clippy::too_many_arguments)] -fn walk_tree_dir( - repo_path: &Path, - repo: &gix::Repository, - tree_id: gix::ObjectId, - dir_path: &str, // "" = root, "src", "src/utils" - commit_hash: &str, - rev_dir: &Path, // public/repository/browse/<hash>/ - tera: &Tera, - project_name: &str, - lang: &Option<String>, - clone_url: &Option<String>, - ss: &syntect::parsing::SyntaxSet, - theme: &syntect::highlighting::Theme, - formats: &[OutputFormat], -) -> Result<()> { - let tree_obj = repo.find_object(tree_id).into_diagnostic()?.into_tree(); - let decoded = tree_obj.decode().into_diagnostic()?; - - // Depth = number of path components in dir_path. - let depth: usize = dir_path.split('/').filter(|s| !s.is_empty()).count(); - - // Public output directory for this tree's index.html. - let page_dir = if dir_path.is_empty() { - rev_dir.to_path_buf() - } else { - dir_path - .split('/') - .filter(|s| !s.is_empty()) - .fold(rev_dir.to_path_buf(), |p, c| p.join(c)) - }; - std::fs::create_dir_all(&page_dir).into_diagnostic()?; - - let mut entries: Vec<TreeEntry> = Vec::new(); - // Defer recursion until after the listing page is written. - let mut subdirs: Vec<(String, gix::ObjectId)> = Vec::new(); - let mut blobs: Vec<(String, gix::ObjectId)> = Vec::new(); - - for entry in decoded.entries.iter() { - let name = entry.filename.to_str_lossy().into_owned(); - let oid: gix::ObjectId = entry.oid.to_owned(); - - if entry.mode.is_tree() { - entries.push(TreeEntry { - url: format!("{name}/index.html"), - name: name.clone(), - kind: TreeEntryKind::Tree, - }); - subdirs.push((name, oid)); - } else { - // blob, executable blob, symlink, or submodule commit - entries.push(TreeEntry { - url: format!("{name}.html"), - name: name.clone(), - kind: TreeEntryKind::Blob, - }); - if !entry.mode.is_commit() { - // Skip submodule gitlinks (they have no blob content). - blobs.push((name, oid)); - } - } - } - - // Directories first, then files; both alphabetical. - entries.sort_by(|a, b| { - let a_tree = matches!(a.kind, TreeEntryKind::Tree); - let b_tree = matches!(b.kind, TreeEntryKind::Tree); - b_tree.cmp(&a_tree).then(a.name.cmp(&b.name)) - }); - - // root_path: how many levels up to reach the site root (public/). - // browse/<hash>/[subpath/] => 3 + depth levels up. - let root_path = "../".repeat(3 + depth); - // commit_url: link back to the commit detail page. - let commit_url = format!("{}commit/{commit_hash}.html", "../".repeat(depth + 2)); - let breadcrumbs = make_crumbs(dir_path, false, None); - - for format in formats { - let suffix = format.extension(); - let tmpl_name = format!("git_tree.{suffix}"); - let ext = format.extension(); - - let mut ctx = Context::new(); - ctx.insert("project_name", project_name); - ctx.insert("lang", lang); - ctx.insert("clone_url", clone_url); - ctx.insert("commit_hash", commit_hash); - ctx.insert("commit_hash_short", &commit_hash[..7]); - ctx.insert("commit_url", &commit_url); - ctx.insert("dir_path", dir_path); - ctx.insert("entries", &entries); - ctx.insert("breadcrumbs", &breadcrumbs); - ctx.insert("root_path", &root_path); - - let content = tera - .render(&tmpl_name, &ctx) - .map_err(|e| miette::miette!("{e}"))?; - std::fs::write(page_dir.join(format!("index.{ext}")), content).into_diagnostic()?; - } - - // Recurse into subdirectories. - for (name, oid) in subdirs { - let child_path = if dir_path.is_empty() { - name - } else { - format!("{dir_path}/{name}") - }; - walk_tree_dir( - repo_path, - repo, - oid, - &child_path, - commit_hash, - rev_dir, - tera, - project_name, - lang, - clone_url, - ss, - theme, - formats, - )?; - } - - // Render blob pages. - for (name, oid) in blobs { - let file_path = if dir_path.is_empty() { - name.clone() - } else { - format!("{dir_path}/{name}") - }; - render_blob_page( - repo_path, - &name, - &file_path, - oid, - commit_hash, - depth, - &page_dir, - tera, - project_name, - lang, - clone_url, - ss, - theme, - formats, - )?; - } - - Ok(()) -} - -/// Write one syntax-highlighted blob page to `page_dir/<name>.html`. -#[allow(clippy::too_many_arguments)] -fn render_blob_page( - repo_path: &Path, - filename: &str, - file_path: &str, // full path from repo root, e.g. "src/main.rs" - oid: gix::ObjectId, - commit_hash: &str, - depth: usize, // number of directory components containing the file - page_dir: &Path, // output directory (same as the parent tree's page_dir) - tera: &Tera, - project_name: &str, - lang: &Option<String>, - clone_url: &Option<String>, - ss: &syntect::parsing::SyntaxSet, - theme: &syntect::highlighting::Theme, - formats: &[OutputFormat], -) -> Result<()> { - const MAX_BLOB_BYTES: usize = 1024 * 1024; // 1 MiB - - // Read blob via `git cat-file blob <oid>` to avoid gix private-field access. - let data: Vec<u8> = std::process::Command::new("git") - .current_dir(repo_path) - .args(["cat-file", "blob", &oid.to_string()]) - .output() - .map(|o| o.stdout) - .unwrap_or_default(); - - let is_binary = data[..data.len().min(8192)].contains(&0u8); - let too_large = data.len() > MAX_BLOB_BYTES; - - let text = String::from_utf8_lossy(&data); - let content_plain: Option<String> = if is_binary || too_large || data.is_empty() { - None - } else { - Some(text.to_string()) - }; - let content_html: Option<String> = if is_binary || too_large || data.is_empty() { - None - } else { - let ext = std::path::Path::new(filename) - .extension() - .and_then(|s| s.to_str()) - .unwrap_or(""); - let syntax = ss - .find_syntax_by_extension(ext) - .or_else(|| { - text.lines() - .next() - .and_then(|l| ss.find_syntax_by_first_line(l)) - }) - .unwrap_or_else(|| ss.find_syntax_plain_text()); - Some( - syntect::html::highlighted_html_for_string(&text, ss, syntax, theme) - .unwrap_or_else(|_| format!("<pre>{}</pre>", escape_html(&text))), - ) - }; - - let root_path = "../".repeat(3 + depth); - let commit_url = format!("{}commit/{commit_hash}.html", "../".repeat(depth + 2)); - let breadcrumbs = make_crumbs( - std::path::Path::new(file_path) - .parent() - .and_then(|p| p.to_str()) - .unwrap_or(""), - true, - Some(filename), - ); - - for format in formats { - let suffix = format.extension(); - let tmpl_name = format!("git_blob.{suffix}"); - let ext = format.extension(); - - let mut ctx = Context::new(); - ctx.insert("project_name", project_name); - ctx.insert("lang", lang); - ctx.insert("clone_url", clone_url); - ctx.insert("commit_hash", commit_hash); - ctx.insert("commit_hash_short", &commit_hash[..7]); - ctx.insert("commit_url", &commit_url); - ctx.insert("file_path", file_path); - ctx.insert("filename", filename); - ctx.insert("breadcrumbs", &breadcrumbs); - ctx.insert("content_html", &content_html); - ctx.insert("content_plain", &content_plain); - ctx.insert("is_binary", &is_binary); - ctx.insert("too_large", &too_large); - ctx.insert("size", &data.len()); - ctx.insert("root_path", &root_path); - - let content = tera - .render(&tmpl_name, &ctx) - .map_err(|e| miette::miette!("{e}"))?; - std::fs::write(page_dir.join(format!("{filename}.{ext}")), content).into_diagnostic()?; - } - - Ok(()) -} - -/// Build breadcrumb entries for a tree or blob page. -/// -/// `dir_path` is the path to the containing directory (e.g. `"src"` for -/// `src/main.rs`). `depth` is derived from `dir_path` internally. -fn make_crumbs(dir_path: &str, is_blob: bool, filename: Option<&str>) -> Vec<Crumb> { - let parts: Vec<&str> = dir_path.split('/').filter(|s| !s.is_empty()).collect(); - let depth = parts.len(); - let mut crumbs = Vec::new(); - - // Root crumb (“~”). - let root_url = if depth == 0 && !is_blob { - None // we ARE the root dir listing - } else { - Some(format!("{}index.html", "../".repeat(depth))) - }; - crumbs.push(Crumb { - name: "~".to_string(), - url: root_url, - }); - - // Intermediate directory crumbs. - for (i, &part) in parts.iter().enumerate() { - let is_last_and_tree = i == depth - 1 && !is_blob; - let url = if is_last_and_tree { - None // current directory - } else { - // levels_up = how many "../" to navigate from current location to this dir - let levels_up = depth - i - 1; - Some(format!("{}index.html", "../".repeat(levels_up))) - }; - crumbs.push(Crumb { - name: part.to_string(), - url, - }); - } - - // Filename crumb for blobs. - if is_blob { - if let Some(name) = filename { - crumbs.push(Crumb { - name: name.to_string(), - url: None, - }); - } - } - - crumbs -} - -fn escape_html(s: &str) -> String { - s.replace('&', "&") - .replace('<', "<") - .replace('>', ">") -} - // ── Helpers ──────────────────────────────────────────────────────────────────── /// Split a raw commit message into (subject, optional body). -
modified src/main.rs
diff --git a/src/main.rs b/src/main.rs index 021bee4..d9bf332 100644 --- a/src/main.rs +++ b/src/main.rs @@ -170,8 +170,12 @@ pub mod changelog; pub mod cli; /// Handles the `abbaye.toml` configuration file. pub mod config; +/// Static tree browser for git repositories. +pub mod git_browse; /// Generates a static git web UI and clonable bare repository. pub mod git_ui; +/// Markdown rendering (HTML and Gemtext). +pub mod render; /// Generates the site from the configuration and builds it. pub mod site; /// Self-update logic: fetches the release feed and replaces the binary when a newer version exists. -
added src/render.rs
diff --git a/src/render.rs b/src/render.rs new file mode 100644 index 0000000..009de95 --- /dev/null +++ b/src/render.rs @@ -0,0 +1,251 @@ +use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd, html}; + +pub(crate) fn extract_local_refs(md: &str) -> Vec<String> { + let opts = Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH; + let mut refs = Vec::new(); + for event in Parser::new_ext(md, opts) { + let url: Option<pulldown_cmark::CowStr> = match event { + Event::Start(Tag::Image { dest_url, .. }) => Some(dest_url), + Event::Start(Tag::Link { dest_url, .. }) => Some(dest_url), + Event::End(TagEnd::Image | TagEnd::Link) => None, + _ => None, + }; + if let Some(url) = url { + let s = url.as_ref(); + if !s.contains("://") && !s.starts_with('#') && !s.is_empty() { + refs.push(s.to_owned()); + } + } + } + refs +} + +pub(crate) fn render_markdown_html(md: &str) -> String { + let opts = Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH; + let parser = Parser::new_ext(md, opts); + let mut buf = String::new(); + html::push_html(&mut buf, parser); + buf +} + +pub(crate) fn render_markdown_gemtext(md: &str) -> String { + let opts = Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH; + let parser = Parser::new_ext(md, opts); + + let mut out: Vec<String> = Vec::new(); + let mut buf = String::new(); + let mut links: Vec<(String, String)> = Vec::new(); + let mut last_blank = false; + + enum Ctx { + Paragraph, + BlockQuote, + ListItem, + Heading(u8), + } + let mut stack: Vec<Ctx> = Vec::new(); + + let mut in_link = false; + let mut link_url = String::new(); + let mut link_text = String::new(); + + let mut in_code = false; + let mut code_kind = String::new(); + let mut code_body = String::new(); + + fn flush_line( + out: &mut Vec<String>, + buf: &mut String, + links: &mut Vec<(String, String)>, + stack: &[Ctx], + _last_blank: &mut bool, + ) { + let text = buf.trim(); + if text.is_empty() && links.is_empty() { + return; + } + + if let Some(Ctx::Heading(lvl)) = stack.last() { + let prefix = match lvl { + 1 => "#", + 2 => "##", + _ => "###", + }; + if !text.is_empty() { + out.push(format!("{prefix} {text}")); + } + for (url, t) in links.drain(..) { + out.push(format!("=> {url} {t}")); + } + *_last_blank = false; + } else if let Some(Ctx::BlockQuote) = stack.last() { + if !text.is_empty() { + for line in text.lines() { + out.push(format!("> {line}")); + } + } + for (url, t) in links.drain(..) { + out.push(format!("=> {url} {t}")); + } + *_last_blank = false; + } else if let Some(Ctx::ListItem) = stack.last() { + if !text.is_empty() { + out.push(format!("* {text}")); + } + for (url, t) in links.drain(..) { + out.push(format!("=> {url} {t}")); + } + *_last_blank = false; + } else { + if !text.is_empty() { + out.push(text.to_string()); + } + for (url, t) in links.drain(..) { + out.push(format!("=> {url} {t}")); + } + *_last_blank = false; + } + buf.clear(); + } + + for event in parser { + match event { + Event::Start(tag) => match tag { + Tag::Heading { level, .. } => { + flush_line(&mut out, &mut buf, &mut links, &stack, &mut last_blank); + let lvl = level as u8; + stack.push(Ctx::Heading(if lvl > 3 { 3 } else { lvl })); + } + Tag::Paragraph => { + stack.push(Ctx::Paragraph); + } + Tag::BlockQuote(_) => { + flush_line(&mut out, &mut buf, &mut links, &stack, &mut last_blank); + stack.push(Ctx::BlockQuote); + } + Tag::CodeBlock(kind) => { + flush_line(&mut out, &mut buf, &mut links, &stack, &mut last_blank); + in_code = true; + code_kind = match kind { + CodeBlockKind::Fenced(info) => info.to_string(), + CodeBlockKind::Indented => String::new(), + }; + code_body.clear(); + } + Tag::List { .. } => {} + Tag::Item => { + flush_line(&mut out, &mut buf, &mut links, &stack, &mut last_blank); + stack.push(Ctx::ListItem); + } + Tag::Link { dest_url, .. } => { + in_link = true; + link_url = dest_url.to_string(); + link_text.clear(); + } + Tag::Image { dest_url, .. } => { + in_link = true; + link_url = dest_url.to_string(); + link_text.clear(); + } + _ => {} + }, + Event::End(tag) => match tag { + TagEnd::Heading(_) => { + flush_line(&mut out, &mut buf, &mut links, &stack, &mut last_blank); + stack.pop(); + } + TagEnd::Paragraph => { + flush_line(&mut out, &mut buf, &mut links, &stack, &mut last_blank); + if !matches!(stack.last(), Some(Ctx::BlockQuote | Ctx::ListItem)) { + out.push(String::new()); + last_blank = true; + } + stack.pop(); + } + TagEnd::BlockQuote(_) => { + flush_line(&mut out, &mut buf, &mut links, &stack, &mut last_blank); + out.push(String::new()); + last_blank = true; + stack.pop(); + } + TagEnd::CodeBlock => { + if !code_body.is_empty() { + out.push(format!("```{}", code_kind)); + for line in code_body.trim().lines() { + out.push(line.to_string()); + } + out.push("```".to_string()); + out.push(String::new()); + last_blank = true; + } + in_code = false; + code_kind.clear(); + code_body.clear(); + } + TagEnd::List(_) => { + out.push(String::new()); + last_blank = true; + } + TagEnd::Item => { + flush_line(&mut out, &mut buf, &mut links, &stack, &mut last_blank); + stack.pop(); + } + TagEnd::Link => { + let t = link_text.trim().to_string(); + if in_link && !link_url.is_empty() && !t.is_empty() { + links.push((link_url.clone(), t)); + } + in_link = false; + link_url.clear(); + link_text.clear(); + } + TagEnd::Image => { + let t = if link_text.trim().is_empty() { + "image".to_string() + } else { + link_text.trim().to_string() + }; + if in_link { + out.push(format!("=> {} {}", link_url, t)); + } + in_link = false; + link_url.clear(); + link_text.clear(); + } + _ => {} + }, + Event::Text(text) => { + if in_code { + code_body.push_str(&text); + } else if in_link { + link_text.push_str(&text); + } else { + buf.push_str(&text); + } + } + Event::Code(text) => { + if in_link { + link_text.push_str(&text); + } else { + buf.push_str(&text); + } + } + Event::SoftBreak | Event::HardBreak => { + if in_code { + code_body.push('\n'); + } else { + buf.push(' '); + } + } + _ => {} + } + } + + flush_line(&mut out, &mut buf, &mut links, &stack, &mut last_blank); + + while out.last().is_some_and(|l| l.is_empty()) { + out.pop(); + } + + out.join("\n") + "\n" +} -
modified src/site.rs
diff --git a/src/site.rs b/src/site.rs index 959b593..e9749fd 100644 --- a/src/site.rs +++ b/src/site.rs @@ -2,25 +2,19 @@ use std::{ future::Future, path::{Path, PathBuf}, pin::Pin, - time::Duration, }; use chrono::{DateTime, SecondsFormat, Utc}; -use sha2::{Digest, Sha256}; - -use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; +use indicatif::ProgressStyle; use miette::{IntoDiagnostic, Result}; -use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd, html}; -use std::collections::HashMap; +use sha2::{Digest, Sha256}; use tera::{Context, Tera}; -use tokio::sync::{mpsc, watch}; -use tokio::task::JoinSet; use tracing::warn; use crate::{ - builders::LogEvent, changelog::ChangelogExtractor, config::{AbbayeConfig, OutputFormat}, + render::{extract_local_refs, render_markdown_gemtext, render_markdown_html}, utils, version_extractors::VersionInfo, }; @@ -144,274 +138,8 @@ pub async fn build_site(config: AbbayeConfig) -> Result<()> { } // ── 3. Builders (run in parallel, respecting depends_on ordering) ───────── - let mut dist_artifacts = Vec::new(); - let mut doc_artifacts = Vec::new(); - - { - use crate::cli::{COLOURS, GREEN, RED, RESET, YELLOW}; - - // ── Dependency validation ───────────────────────────────────────────── - // - // Build a map from id → index so we can reference builders by name. - let id_to_idx: HashMap<&str, usize> = config - .builders - .iter() - .enumerate() - .filter_map(|(i, e)| e.id.as_deref().map(|id| (id, i))) - .collect(); - - // Check that every depends_on reference resolves to a known id. - for (i, entry) in config.builders.iter().enumerate() { - for dep in &entry.depends_on { - if !id_to_idx.contains_key(dep.as_str()) { - return Err(miette::miette!( - "builder #{i} ({}) lists '{}' in depends_on, \ - but no builder has that id", - entry.label(), - dep - )); - } - } - } - - // Cycle detection via iterative DFS (0=unvisited, 1=in-stack, 2=done). - { - let n = config.builders.len(); - let mut state = vec![0u8; n]; - - fn dfs( - idx: usize, - id_to_idx: &HashMap<&str, usize>, - builders: &[crate::builders::BuilderEntry], - state: &mut Vec<u8>, - ) -> Result<()> { - if state[idx] == 1 { - return Err(miette::miette!( - "dependency cycle detected involving builder #{idx} ({})", - builders[idx].id.as_deref().unwrap_or(builders[idx].label()) - )); - } - if state[idx] == 2 { - return Ok(()); - } - state[idx] = 1; - for dep in &builders[idx].depends_on { - if let Some(&dep_idx) = id_to_idx.get(dep.as_str()) { - dfs(dep_idx, id_to_idx, builders, state)?; - } - } - state[idx] = 2; - Ok(()) - } - - for i in 0..n { - dfs(i, &id_to_idx, &config.builders, &mut state)?; - } - } - - // ── Completion signals ──────────────────────────────────────────────── - // - // For every builder that carries an `id` we open a watch channel. - // Dependents receive a clone of the receiver and wait until the value - // transitions from `None` (pending) to `Some(true)` (success) or - // `Some(false)` (failure / dependency failure). - let mut completion_txs: HashMap<String, watch::Sender<Option<bool>>> = HashMap::new(); - let mut completion_rxs: HashMap<String, watch::Receiver<Option<bool>>> = HashMap::new(); - - for entry in &config.builders { - if let Some(id) = &entry.id { - let (tx, rx) = watch::channel(None::<bool>); - completion_txs.insert(id.clone(), tx); - completion_rxs.insert(id.clone(), rx); - } - } - - // ── Progress bars & task spawning ───────────────────────────────────── - let total = config.builders.len(); - let multi = MultiProgress::new(); - - // Bottom bar: overall completion counter. - let summary = multi.add(ProgressBar::new(total as u64)); - summary.set_style( - ProgressStyle::with_template("{pos}/{len} builders {bar:20.green/white} {msg}") - .expect("valid template"), - ); - summary.set_message("building…"); - - let mut join_set: JoinSet<miette::Result<Vec<crate::builders::ArtifactPath>>> = - JoinSet::new(); - - for (i, entry) in config.builders.iter().enumerate() { - let color = COLOURS[i % COLOURS.len()]; - let label = entry.id.as_deref().unwrap_or(entry.label()); - let colored_prefix = format!("{color}[{label}]{RESET}"); - - // Spinner inserted above the summary bar. - let pb = multi.insert_before(&summary, ProgressBar::new_spinner()); - pb.set_style(spinner_style(false)); - pb.set_prefix(colored_prefix); - pb.set_message("starting…"); - pb.enable_steady_tick(Duration::from_millis(100)); - - let (log_tx, mut log_rx) = mpsc::unbounded_channel::<LogEvent>(); - - // Task: receive LogEvents and update spinners. - // ChildStart creates a new sub-spinner inserted right below the - // parent (and below any previously created siblings, via - // `last_child_pb`). ChildLine / ChildFinish update or finish it. - let pb_log = pb.clone(); - let multi_log = multi.clone(); - let parent_color_idx = i; - tokio::spawn(async move { - let mut child_pbs: HashMap<String, ProgressBar> = HashMap::new(); - // Track insertion point so siblings stack in order. - let mut last_child_pb = pb_log.clone(); - let mut child_color_idx = parent_color_idx + 1; - - while let Some(event) = log_rx.recv().await { - match event { - LogEvent::Line(line) => { - pb_log.set_message(line); - } - LogEvent::ChildStart { id, label } => { - let child_color = COLOURS[child_color_idx % COLOURS.len()]; - child_color_idx += 1; - let child_pb = - multi_log.insert_after(&last_child_pb, ProgressBar::new_spinner()); - child_pb.set_style(spinner_style(true)); - child_pb.set_prefix(format!("{child_color}[{label}]{RESET}")); - child_pb.set_message("starting…"); - child_pb.enable_steady_tick(Duration::from_millis(100)); - last_child_pb = child_pb.clone(); - child_pbs.insert(id, child_pb); - } - LogEvent::ChildLine { id, line } => { - if let Some(child_pb) = child_pbs.get(&id) { - child_pb.set_message(line); - } - } - LogEvent::ChildFinish { - id, - success, - summary, - } => { - if let Some(child_pb) = child_pbs.remove(&id) { - if success { - child_pb.finish_with_message(format!( - "{GREEN}\u{2713}{RESET} {summary}" - )); - } else { - child_pb.finish_with_message(format!( - "{RED}\u{2717}{RESET} {summary}" - )); - } - } - } - } - } - }); - - // Collect the watch receivers for every declared dependency. - let dep_receivers: Vec<(String, watch::Receiver<Option<bool>>)> = entry - .depends_on - .iter() - .filter_map(|dep_id| { - completion_rxs - .get(dep_id) - .map(|rx| (dep_id.clone(), rx.clone())) - }) - .collect(); - - // Take ownership of the completion sender for this builder's own id - // (if it has one) so the task can signal its outcome. - let my_tx: Option<watch::Sender<Option<bool>>> = - entry.id.as_ref().and_then(|id| completion_txs.remove(id)); - - let entry = entry.clone(); - let version = version.clone(); - let pb_task = pb.clone(); - let summary_task = summary.clone(); - - join_set.spawn(async move { - // ── Wait for dependencies ───────────────────────────────────── - for (dep_id, mut rx) in dep_receivers { - pb_task.set_message(format!("waiting for '{dep_id}'…")); - - // Block until the dependency resolves (Some(_)) or its - // sender is dropped (which we treat as a failure). - let resolved = rx.wait_for(|v| v.is_some()).await; - - let succeeded = match resolved { - Err(_) => false, // sender dropped unexpectedly - Ok(r) => r.unwrap_or(false), - }; - - if !succeeded { - summary_task.inc(1); - pb_task.finish_with_message(format!( - "{YELLOW}\u{29B8} skipped{RESET} (dependency '{dep_id}' failed)" - )); - if let Some(tx) = &my_tx { - let _ = tx.send(Some(false)); - } - // Return an empty artifact list; the dependency error - // itself will surface from the dependency's own task. - return Ok(vec![]); - } - } - - // ── Run the builder ─────────────────────────────────────────── - pb_task.set_message("running…"); - let result = entry.build(&version, log_tx).await; - let succeeded = result.is_ok(); - - if let Some(tx) = my_tx { - let _ = tx.send(Some(succeeded)); - } - - summary_task.inc(1); - match &result { - Ok(artifacts) => pb_task.finish_with_message(format!( - "{GREEN}\u{2713} done{RESET} ({} artifact(s))", - artifacts.len() - )), - Err(e) => { - pb_task.finish_with_message(format!("{RED}\u{2717} failed:{RESET} {e}")) - } - } - result - }); - } - - // Collect all results; continue even when some builders fail so every - // spinner reaches its final state before we return an error. - let mut errors: Vec<miette::Report> = Vec::new(); - while let Some(res) = join_set.join_next().await { - match res.into_diagnostic()? { - Ok(artifacts) => { - for artifact in artifacts { - if artifact.path.is_dir() { - doc_artifacts.push(artifact); - } else { - dist_artifacts.push(artifact); - } - } - } - Err(e) => errors.push(e), - } - } - - let summary_msg = if errors.is_empty() { - format!("{GREEN}all done{RESET}") - } else { - format!("{RED}some builders failed{RESET}") - }; - summary.finish_with_message(summary_msg); - - if let Some(first_err) = errors.into_iter().next() { - return Err(first_err); - } - } + let (dist_artifacts, doc_artifacts) = + crate::builders::orchestrator::run_builders(&config.builders, &version).await?; // ── 4. Lay out dist/ ────────────────────────────────────────────────────── let version_dir = output_dir.join(&version); @@ -650,273 +378,6 @@ pub async fn build_site(config: AbbayeConfig) -> Result<()> { // ── Private helpers ─────────────────────────────────────────────────────────── -/// Extract URLs of locally-referenced files from a Markdown document. -/// -/// Returns relative paths that are referenced as images or links and that do -/// not look like remote URLs (no `://` scheme) or bare fragment anchors -/// (starting with `#`). -fn extract_local_refs(md: &str) -> Vec<String> { - let opts = Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH; - let mut refs = Vec::new(); - for event in Parser::new_ext(md, opts) { - let url: Option<pulldown_cmark::CowStr> = match event { - Event::Start(Tag::Image { dest_url, .. }) => Some(dest_url), - Event::Start(Tag::Link { dest_url, .. }) => Some(dest_url), - // pulldown-cmark emits End events with a TagEnd - ignore those. - Event::End(TagEnd::Image | TagEnd::Link) => None, - _ => None, - }; - if let Some(url) = url { - let s = url.as_ref(); - // Skip remote URLs, data URIs, and fragment-only links. - if !s.contains("://") && !s.starts_with('#') && !s.is_empty() { - refs.push(s.to_owned()); - } - } - } - refs -} - -/// Render Markdown to an HTML string. -fn render_markdown_html(md: &str) -> String { - let opts = Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH; - let parser = Parser::new_ext(md, opts); - let mut buf = String::new(); - html::push_html(&mut buf, parser); - buf -} - -/// Render Markdown to a Gemtext string. -pub(crate) fn render_markdown_gemtext(md: &str) -> String { - use pulldown_cmark::{Tag, TagEnd}; - - let opts = Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH; - let parser = Parser::new_ext(md, opts); - - let mut out: Vec<String> = Vec::new(); - let mut buf = String::new(); - let mut links: Vec<(String, String)> = Vec::new(); - let mut last_blank = false; - - // Stack of containers that affect line prefix. - enum Ctx { - Paragraph, - BlockQuote, - ListItem, - Heading(u8), - } - let mut stack: Vec<Ctx> = Vec::new(); - - // Track whether we are inside a link or image tag. - let mut in_link = false; - let mut link_url = String::new(); - let mut link_text = String::new(); - - // Track code blocks. - let mut in_code = false; - let mut code_kind = String::new(); - let mut code_body = String::new(); - - fn flush_line( - out: &mut Vec<String>, - buf: &mut String, - links: &mut Vec<(String, String)>, - stack: &[Ctx], - last_blank: &mut bool, - ) { - let text = buf.trim(); - if text.is_empty() && links.is_empty() { - return; - } - - if let Some(Ctx::Heading(lvl)) = stack.last() { - let prefix = match lvl { - 1 => "#", - 2 => "##", - _ => "###", - }; - if !text.is_empty() { - out.push(format!("{prefix} {text}")); - } - for (url, t) in links.drain(..) { - out.push(format!("=> {url} {t}")); - } - *last_blank = false; - } else if let Some(Ctx::BlockQuote) = stack.last() { - if !text.is_empty() { - for line in text.lines() { - out.push(format!("> {line}")); - } - } - for (url, t) in links.drain(..) { - out.push(format!("=> {url} {t}")); - } - *last_blank = false; - } else if let Some(Ctx::ListItem) = stack.last() { - if !text.is_empty() { - out.push(format!("* {text}")); - } - for (url, t) in links.drain(..) { - out.push(format!("=> {url} {t}")); - } - *last_blank = false; - } else { - // Top-level paragraph - if !text.is_empty() { - out.push(text.to_string()); - } - for (url, t) in links.drain(..) { - out.push(format!("=> {url} {t}")); - } - *last_blank = false; - } - buf.clear(); - } - - for event in parser { - match event { - Event::Start(tag) => match tag { - Tag::Heading { level, .. } => { - flush_line(&mut out, &mut buf, &mut links, &stack, &mut last_blank); - let lvl = level as u8; - stack.push(Ctx::Heading(if lvl > 3 { 3 } else { lvl })); - } - Tag::Paragraph => { - stack.push(Ctx::Paragraph); - } - Tag::BlockQuote(_) => { - flush_line(&mut out, &mut buf, &mut links, &stack, &mut last_blank); - stack.push(Ctx::BlockQuote); - } - Tag::CodeBlock(kind) => { - flush_line(&mut out, &mut buf, &mut links, &stack, &mut last_blank); - in_code = true; - code_kind = match kind { - pulldown_cmark::CodeBlockKind::Fenced(info) => info.to_string(), - pulldown_cmark::CodeBlockKind::Indented => String::new(), - }; - code_body.clear(); - } - Tag::List { .. } => {} - Tag::Item => { - flush_line(&mut out, &mut buf, &mut links, &stack, &mut last_blank); - stack.push(Ctx::ListItem); - } - Tag::Link { dest_url, .. } => { - in_link = true; - link_url = dest_url.to_string(); - link_text.clear(); - } - Tag::Image { dest_url, .. } => { - in_link = true; - link_url = dest_url.to_string(); - link_text.clear(); - } - _ => {} - }, - Event::End(tag) => match tag { - TagEnd::Heading(_) => { - flush_line(&mut out, &mut buf, &mut links, &stack, &mut last_blank); - stack.pop(); - } - TagEnd::Paragraph => { - flush_line(&mut out, &mut buf, &mut links, &stack, &mut last_blank); - if !matches!(stack.last(), Some(Ctx::BlockQuote | Ctx::ListItem)) { - out.push(String::new()); - last_blank = true; - } - stack.pop(); - } - TagEnd::BlockQuote(_) => { - flush_line(&mut out, &mut buf, &mut links, &stack, &mut last_blank); - out.push(String::new()); - last_blank = true; - stack.pop(); - } - TagEnd::CodeBlock => { - if !code_body.is_empty() { - out.push(format!("```{}", code_kind)); - for line in code_body.trim().lines() { - out.push(line.to_string()); - } - out.push("```".to_string()); - out.push(String::new()); - last_blank = true; - } - in_code = false; - code_kind.clear(); - code_body.clear(); - } - TagEnd::List(_) => { - out.push(String::new()); - last_blank = true; - } - TagEnd::Item => { - flush_line(&mut out, &mut buf, &mut links, &stack, &mut last_blank); - stack.pop(); - } - TagEnd::Link => { - let t = link_text.trim().to_string(); - if in_link && !link_url.is_empty() && !t.is_empty() { - links.push((link_url.clone(), t)); - } - in_link = false; - link_url.clear(); - link_text.clear(); - } - TagEnd::Image => { - let t = if link_text.trim().is_empty() { - "image".to_string() - } else { - link_text.trim().to_string() - }; - if in_link { - // Emit link immediately since images are standalone in gemtext - out.push(format!("=> {} {}", link_url, t)); - } - in_link = false; - link_url.clear(); - link_text.clear(); - } - _ => {} - }, - Event::Text(text) => { - if in_code { - code_body.push_str(&text); - } else if in_link { - link_text.push_str(&text); - } else { - buf.push_str(&text); - } - } - Event::Code(text) => { - if in_link { - link_text.push_str(&text); - } else { - buf.push_str(&text); - } - } - Event::SoftBreak | Event::HardBreak => { - if in_code { - code_body.push('\n'); - } else { - buf.push(' '); - } - } - _ => {} - } - } - - flush_line(&mut out, &mut buf, &mut links, &stack, &mut last_blank); - - // Trim trailing blank lines. - while out.last().is_some_and(|l| l.is_empty()) { - out.pop(); - } - - out.join("\n") + "\n" -} - /// Scan `docs_dir` for subdirectories that contain an `index.html` and return /// their names. Used to build a fallback listing when rustdoc itself did not /// generate a root `index.html` (typical for multi-crate workspaces).