Commit
Message
Changed Files (17)
-
modified CHANGELOG.md
diff --git a/CHANGELOG.md b/CHANGELOG.md index f2ad0aa..a01e549 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## [unreleased] + +### π Features + +- Add multi-format output support (HTML + Gemtext) ## [0.7.1] - 2026-06-16 ### π Features -
modified abbaye.schema.json
diff --git a/abbaye.schema.json b/abbaye.schema.json index 6416cc2..b3e6f9a 100644 --- a/abbaye.schema.json +++ b/abbaye.schema.json @@ -392,6 +392,16 @@ "description": "Configuration for [`MarkdownBuilder`].", "type": "object", "properties": { + "formats": { + "description": "Output formats to generate. Defaults to `[\"html\"]`.\nSet to `[\"html\", \"gemtext\"]` to also produce Gemini text files.", + "type": "array", + "default": [ + "html" + ], + "items": { + "$ref": "#/definitions/OutputFormat" + } + }, "input": { "description": "Directory containing `.md` files to render.\n\nEvery `.md` file in the directory is rendered to a corresponding\n`.html` file in the output directory, preserving the subdirectory\nstructure. Non-Markdown files referenced (linked or embedded) inside\nany Markdown source - images, PDFs, downloadable assets, etc. - are\ncopied into the output directory next to the rendered HTML so that all\nrelative URLs remain valid.\n\nDefaults to `\".\"` (the current working directory).", "type": [ @@ -454,6 +464,14 @@ "image" ] }, + "OutputFormat": { + "description": "The output format for generated pages.", + "type": "string", + "enum": [ + "html", + "gemtext" + ] + }, "ScriptBuilderConfig": { "description": "Configuration for [`ScriptBuilder`].", "type": "object", @@ -519,6 +537,16 @@ "null" ] }, + "formats": { + "description": "Output formats to generate. Defaults to `[\"html\"]`.\nSet to `[\"html\", \"gemtext\"]` to also generate Gemini text pages.", + "type": "array", + "default": [ + "html" + ], + "items": { + "$ref": "#/definitions/OutputFormat" + } + }, "lang": { "description": "An optional language code for the website (e.g. `\"en\"` or `\"fr\"`).\nWhen set, the generated HTML will include a `lang` attribute on the `<html>` tag. Defaults to `\"en\"`.", "type": [ -
modified abbaye.toml
diff --git a/abbaye.toml b/abbaye.toml index 2c9976a..e197760 100644 --- a/abbaye.toml +++ b/abbaye.toml @@ -5,6 +5,7 @@ name = "Abbaye" base_url = "https://vit.am/~ololduck/abbaye" repo_url = "https://git.sr.ht/~ololduck/abbaye" fediverse_creator = "@ololduck@vit.am" +formats = ["html", "gemtext"] [site.opengraph] image = "latest/logo-wordmark.svg" @@ -21,11 +22,11 @@ tag_prefix = "v" [changelog] -[[builders]] # builds the project using cargo build --release +[[builders]] # builds the project using cargo build --release type = "cargo" targets = ["x86_64-unknown-linux-gnu", "x86_64-unknown-linux-musl"] -[[builders]] # generates documentation using cargo doc +[[builders]] # generates documentation using cargo doc type = "cargo_doc" no_deps = true @@ -40,7 +41,7 @@ outputs = ["target/abbaye.schema.json"] [[builders]] id = "archive source" -type = "archive" # creates a compressed tarball of the source code (can be of anything, really) +type = "archive" # creates a compressed tarball of the source code (can be of anything, really) output = "target/abbaye-source.tar.gz" [[builders]] -
modified src/builders/markdown.rs
diff --git a/src/builders/markdown.rs b/src/builders/markdown.rs index 9579b08..bc4075e 100644 --- a/src/builders/markdown.rs +++ b/src/builders/markdown.rs @@ -9,6 +9,7 @@ use serde::{Deserialize, Serialize}; use tera::{Context, Tera}; use crate::builders::{ArtifactPath, Builder, LogEvent, LogSender}; +use crate::config::OutputFormat; /// The default Tera template used to wrap rendered Markdown content in a /// complete HTML5 document. Exposed as a `pub const` so that `abbaye @@ -17,12 +18,8 @@ use crate::builders::{ArtifactPath, Builder, LogEvent, LogSender}; /// Template variables: /// - `{{ title }}` - plain-text page title (auto-escaped by Tera). /// - `{{ content | safe }}` - the rendered HTML body fragment. -pub const TEMPLATE_MARKDOWN: &str = include_str!("../templates/markdown.html.j2"); - -/// Filename looked up inside `.abbaye/theme/` at runtime. -const THEME_FILENAME: &str = "markdown.html.j2"; -/// Name under which the template is registered inside the Tera instance. -const TERA_NAME: &str = "markdown.html"; +pub const TEMPLATE_MARKDOWN_HTML: &str = include_str!("../templates/markdown.html.j2"); +pub const TEMPLATE_MARKDOWN_GEMTEXT: &str = include_str!("../templates/markdown.gmi.j2"); fn default_recursive() -> bool { true @@ -62,6 +59,14 @@ pub struct MarkdownBuilderConfig { /// builder. #[serde(default = "default_recursive")] pub recursive: bool, + /// Output formats to generate. Defaults to `["html"]`. + /// Set to `["html", "gemtext"]` to also produce Gemini text files. + #[serde(default = "default_recursive_formats")] + pub formats: Vec<OutputFormat>, +} + +fn default_recursive_formats() -> Vec<OutputFormat> { + vec![OutputFormat::Html] } impl Default for MarkdownBuilderConfig { @@ -70,6 +75,7 @@ impl Default for MarkdownBuilderConfig { input: None, output: None, recursive: default_recursive(), + formats: default_recursive_formats(), } } } @@ -114,30 +120,34 @@ impl Builder for MarkdownBuilder { // Load the Tera template once per builder invocation and share it // across all files so template parsing only happens once. - let tera = load_tera()?; - - build_directory(&input, config.output, config.recursive, &log, &tera).await + let tera = load_tera(&config.formats)?; + + build_directory( + &input, + config.output, + config.recursive, + &log, + &tera, + &config.formats, + ) + .await } } /// Load the Tera instance for this builder invocation. -/// -/// Checks whether `.abbaye/theme/markdown.html.j2` exists and loads that -/// file when present; otherwise falls back to the compiled-in -/// [`TEMPLATE_MARKDOWN`] constant - exactly the same override mechanism -/// used by the site templates (`root_index.html.j2` / `version_index.html.j2`). -fn load_tera() -> Result<Tera> { +fn load_tera(formats: &[OutputFormat]) -> Result<Tera> { let theme_path = PathBuf::from(".abbaye").join("theme"); - let theme_file = theme_path.join(THEME_FILENAME); let mut tera = Tera::default(); - if theme_file.is_file() { - tera.add_template_file(&theme_file, Some(TERA_NAME)) - .into_diagnostic()?; - } else { - tera.add_raw_template(TERA_NAME, TEMPLATE_MARKDOWN) - .into_diagnostic()?; - } - crate::site::load_extra_theme_templates(&mut tera, &theme_path, &[TERA_NAME])?; + crate::site::register_format_templates( + &mut tera, + &theme_path, + formats, + &[( + "markdown", + TEMPLATE_MARKDOWN_HTML, + TEMPLATE_MARKDOWN_GEMTEXT, + )], + )?; Ok(tera) } @@ -149,6 +159,7 @@ async fn build_directory( recursive: bool, log: &LogSender, tera: &Tera, + formats: &[OutputFormat], ) -> Result<Vec<ArtifactPath>> { let output_dir = output.unwrap_or_else(|| { let stem = input_dir @@ -213,11 +224,22 @@ async fn build_directory( } let title = extract_title(&md).unwrap_or_else(|| file_stem_string(md_path)); - let document = render_template(tera, &title, &render_markdown(&md))?; - tokio::fs::write(&out_path, document.as_bytes()) - .await - .into_diagnostic()?; + for format in formats { + let suffix = format.extension(); + let ext = format.extension(); + let tmpl_name = format!("markdown.{suffix}"); + let content = match format { + OutputFormat::Html => render_markdown(&md), + OutputFormat::Gemtext => crate::site::render_markdown_gemtext(&md), + }; + let document = render_template(tera, &tmpl_name, &title, &content)?; + + let fmt_out_path = out_path.with_extension(ext); + tokio::fs::write(&fmt_out_path, document.as_bytes()) + .await + .into_diagnostic()?; + } } // Copy every referenced asset, creating parent directories as needed. @@ -389,16 +411,12 @@ fn extract_title(md: &str) -> Option<String> { if title.is_empty() { None } else { Some(title) } } -/// Render the Tera template with the given `title` and HTML `content`. -/// -/// `title` is passed as a plain string; Tera auto-escapes it when inserted -/// into `{{ title }}`. `content` is the already-rendered HTML fragment and -/// must be inserted with `{{ content | safe }}` in the template. -fn render_template(tera: &Tera, title: &str, content: &str) -> Result<String> { +/// Render the Tera template with the given `title` and format-specific `content`. +fn render_template(tera: &Tera, template_name: &str, title: &str, content: &str) -> Result<String> { let mut ctx = Context::new(); ctx.insert("title", title); ctx.insert("content", content); - tera.render(TERA_NAME, &ctx).into_diagnostic() + tera.render(template_name, &ctx).into_diagnostic() } /// Extract the file stem as an owned `String`, falling back to `"Document"`. -
modified src/config.rs
diff --git a/src/config.rs b/src/config.rs index d0974eb..dba37e3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -13,6 +13,36 @@ use crate::{ builders::BuilderEntry, changelog::ChangelogConfig, version_extractors::AnyVersionExtractor, }; +/// The output format for generated pages. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum OutputFormat { + Html, + Gemtext, +} + +impl OutputFormat { + /// File extension for this format (e.g. `"html"` or `"gmi"`). + pub fn extension(&self) -> &'static str { + match self { + Self::Html => "html", + Self::Gemtext => "gmi", + } + } + + /// Whether this format supports inline formatting (HTML does, gemtext doesn't). + pub fn supports_inline_formatting(&self) -> bool { + match self { + Self::Html => true, + Self::Gemtext => false, + } + } +} + +fn default_formats() -> Vec<OutputFormat> { + vec![OutputFormat::Html] +} + /// General website metadata. #[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)] pub struct SiteConfig { @@ -38,6 +68,10 @@ pub struct SiteConfig { pub fediverse_creator: Option<String>, /// OpenGraph configuration for the website. pub opengraph: Option<OpenGraphConfig>, + /// Output formats to generate. Defaults to `["html"]`. + /// Set to `["html", "gemtext"]` to also generate Gemini text pages. + #[serde(default = "default_formats")] + pub formats: Vec<OutputFormat>, } /// OpenGraph configuration for the website. -
modified src/git_ui.rs
diff --git a/src/git_ui.rs b/src/git_ui.rs index 344c925..ea02a1e 100644 --- a/src/git_ui.rs +++ b/src/git_ui.rs @@ -21,7 +21,7 @@ use std::time::Duration; use chrono::{DateTime, Utc}; use gix::bstr::ByteSlice; use globset::{Glob, GlobSet, GlobSetBuilder}; -use indicatif::{ProgressBar, ProgressStyle}; +use indicatif::ProgressBar; use miette::{IntoDiagnostic, Result}; use crate::cli::{CYAN, GREEN, RED, RESET}; @@ -29,15 +29,21 @@ use serde::Serialize; use tera::{Context, Tera}; use tracing::warn; -use crate::config::{AbbayeConfig, GitUiConfig}; +use crate::config::{AbbayeConfig, GitUiConfig, OutputFormat}; // ββ Template sources ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ -pub const TEMPLATE_GIT_LOG: &str = include_str!("templates/git_log.html.j2"); -pub const TEMPLATE_GIT_COMMIT: &str = include_str!("templates/git_commit.html.j2"); -pub const TEMPLATE_GIT_REFS: &str = include_str!("templates/git_refs.html.j2"); -pub const TEMPLATE_GIT_TREE: &str = include_str!("templates/git_tree.html.j2"); -pub const TEMPLATE_GIT_BLOB: &str = include_str!("templates/git_blob.html.j2"); +pub const TEMPLATE_GIT_LOG_HTML: &str = include_str!("templates/git_log.html.j2"); +pub const TEMPLATE_GIT_COMMIT_HTML: &str = include_str!("templates/git_commit.html.j2"); +pub const TEMPLATE_GIT_REFS_HTML: &str = include_str!("templates/git_refs.html.j2"); +pub const TEMPLATE_GIT_TREE_HTML: &str = include_str!("templates/git_tree.html.j2"); +pub const TEMPLATE_GIT_BLOB_HTML: &str = include_str!("templates/git_blob.html.j2"); + +pub const TEMPLATE_GIT_LOG_GEMTEXT: &str = include_str!("templates/git_log.gmi.j2"); +pub const TEMPLATE_GIT_COMMIT_GEMTEXT: &str = include_str!("templates/git_commit.gmi.j2"); +pub const TEMPLATE_GIT_REFS_GEMTEXT: &str = include_str!("templates/git_refs.gmi.j2"); +pub const TEMPLATE_GIT_TREE_GEMTEXT: &str = include_str!("templates/git_tree.gmi.j2"); +pub const TEMPLATE_GIT_BLOB_GEMTEXT: &str = include_str!("templates/git_blob.gmi.j2"); // ββ Template-facing data structures ββββββββββββββββββββββββββββββββββββββββββ @@ -172,11 +178,7 @@ struct BranchEntry { /// Shared spinner style - matches the builder spinners in `site.rs`. fn make_spinner(label: &str) -> ProgressBar { let pb = ProgressBar::new_spinner(); - pb.set_style( - ProgressStyle::with_template("{spinner:.bold} {prefix} {msg}") - .expect("valid template") - .tick_chars("β β β Ήβ Έβ Όβ ΄β ¦β §β β "), - ); + pb.set_style(crate::site::spinner_style(false)); pb.set_prefix(format!("{CYAN}[{label}]{RESET}")); pb.enable_steady_tick(Duration::from_millis(100)); pb @@ -313,23 +315,24 @@ pub async fn build_git_repository_ui(config: &AbbayeConfig, git_cfg: &GitUiConfi // ββ Tera setup ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ let mut tera = Tera::default(); let theme_path = PathBuf::from(".abbaye").join("theme"); - for (name, builtin) in [ - ("git_log.html", TEMPLATE_GIT_LOG), - ("git_commit.html", TEMPLATE_GIT_COMMIT), - ("git_refs.html", TEMPLATE_GIT_REFS), - ] { - let override_path = theme_path.join(format!("{name}.j2")); - if override_path.is_file() { - tera.add_template_file(&override_path, Some(name)) - .into_diagnostic()?; - } else { - tera.add_raw_template(name, builtin).into_diagnostic()?; - } - } - crate::site::load_extra_theme_templates( + + crate::site::register_format_templates( &mut tera, &theme_path, - &["git_log.html", "git_commit.html", "git_refs.html"], + &config.site.formats, + &[ + ("git_log", TEMPLATE_GIT_LOG_HTML, TEMPLATE_GIT_LOG_GEMTEXT), + ( + "git_commit", + TEMPLATE_GIT_COMMIT_HTML, + TEMPLATE_GIT_COMMIT_GEMTEXT, + ), + ( + "git_refs", + TEMPLATE_GIT_REFS_HTML, + TEMPLATE_GIT_REFS_GEMTEXT, + ), + ], )?; // ββ Branch switcher nav (shared across all log pages) βββββββββββββββββββββ @@ -345,7 +348,7 @@ pub async fn build_git_repository_ui(config: &AbbayeConfig, git_cfg: &GitUiConfi b_default.cmp(&a_default).then(na.cmp(nb)) }); - // ββ Render one log page per branch ββββββββββββββββββββββββββββββββββββββββ + // ββ Render one log page per branch (per format) βββββββββββββββββββββββββββ pb.set_message("rendering log pagesβ¦"); for (short_name, filename, commits) in &branch_pages { let truncated = commits.len() >= max_commits; @@ -359,24 +362,36 @@ pub async fn build_git_repository_ui(config: &AbbayeConfig, git_cfg: &GitUiConfi }) .collect(); - let mut ctx = Context::new(); - ctx.insert("project_name", &config.site.name); - ctx.insert("lang", &config.site.lang); - ctx.insert("clone_url", &clone_url); - ctx.insert("current_branch", short_name); - ctx.insert("branch_nav", &branch_nav); - ctx.insert("commits", commits); - ctx.insert("truncated", &truncated); - ctx.insert("root_path", "../"); - - let html = tera.render("git_log.html", &ctx).into_diagnostic()?; - tokio::fs::write(ui_dir.join(filename), html) - .await - .into_diagnostic()?; + for format in &config.site.formats { + let suffix = format.extension(); + let tmpl_name = format!("git_log.{suffix}"); + let ext = format.extension(); + let out_filename = filename.replace(".html", &format!(".{ext}")); + + let mut ctx = Context::new(); + ctx.insert("project_name", &config.site.name); + ctx.insert("lang", &config.site.lang); + ctx.insert("clone_url", &clone_url); + ctx.insert("current_branch", short_name); + ctx.insert("branch_nav", &branch_nav); + ctx.insert("commits", commits); + ctx.insert("truncated", &truncated); + ctx.insert("root_path", "../"); + + let content = tera.render(&tmpl_name, &ctx).into_diagnostic()?; + tokio::fs::write(ui_dir.join(&out_filename), content) + .await + .into_diagnostic()?; + } } - // ββ Render refs page ββββββββββββββββββββββββββββββββββββββββββββββββββββββ - { + // ββ Render refs page (per format) βββββββββββββββββββββββββββββββββββββββββ + for format in &config.site.formats { + let suffix = format.extension(); + let tmpl_name = format!("git_refs.{suffix}"); + let ext = format.extension(); + let out_filename = format!("refs.{ext}"); + let mut ctx = Context::new(); ctx.insert("project_name", &config.site.name); ctx.insert("lang", &config.site.lang); @@ -385,36 +400,39 @@ pub async fn build_git_repository_ui(config: &AbbayeConfig, git_cfg: &GitUiConfi ctx.insert("branches", &ref_branches); ctx.insert("root_path", "../"); - let html = tera.render("git_refs.html", &ctx).into_diagnostic()?; - tokio::fs::write(ui_dir.join("refs.html"), html) + let content = tera.render(&tmpl_name, &ctx).into_diagnostic()?; + tokio::fs::write(ui_dir.join(&out_filename), content) .await .into_diagnostic()?; } - // ββ Render per-commit detail pages ββββββββββββββββββββββββββββββββββββββββ + // ββ Render per-commit detail pages (per format) βββββββββββββββββββββββββββ pb.set_message(format!("rendering {} commit pagesβ¦", unique_commits.len())); for commit_info in &unique_commits { let changed_files = get_changed_files(&commit_info.hash).await?; let has_browse = !commit_info.ref_badges.is_empty(); - - let mut ctx = Context::new(); - ctx.insert("project_name", &config.site.name); - ctx.insert("lang", &config.site.lang); - ctx.insert("clone_url", &clone_url); - ctx.insert("commit", commit_info); - ctx.insert("changed_files", &changed_files); - ctx.insert("has_browse", &has_browse); - ctx.insert("root_path", "../../"); - - let html = tera.render("git_commit.html", &ctx).into_diagnostic()?; - tokio::fs::write( - ui_dir - .join("commit") - .join(format!("{}.html", commit_info.hash)), - html, - ) - .await - .into_diagnostic()?; + let commit_dir = ui_dir.join("commit"); + + for format in &config.site.formats { + let suffix = format.extension(); + let tmpl_name = format!("git_commit.{suffix}"); + let ext = format.extension(); + let out_filename = format!("{}.{ext}", commit_info.hash); + + let mut ctx = Context::new(); + ctx.insert("project_name", &config.site.name); + ctx.insert("lang", &config.site.lang); + ctx.insert("clone_url", &clone_url); + ctx.insert("commit", commit_info); + ctx.insert("changed_files", &changed_files); + ctx.insert("has_browse", &has_browse); + ctx.insert("root_path", "../../"); + + let content = tera.render(&tmpl_name, &ctx).into_diagnostic()?; + tokio::fs::write(commit_dir.join(&out_filename), content) + .await + .into_diagnostic()?; + } } // ββ Tree browser (browse/<hash>/) βββββββββββββββββββββββββββββββββββββββββ @@ -433,6 +451,7 @@ pub async fn build_git_repository_ui(config: &AbbayeConfig, git_cfg: &GitUiConfi let clone_url_browse = clone_url.clone(); let theme_path = PathBuf::from(".abbaye").join("theme"); let repo_path_browse = repo_path.clone(); + let formats = config.site.formats.clone(); tokio::task::spawn_blocking(move || { build_browse_pages( @@ -443,6 +462,7 @@ pub async fn build_git_repository_ui(config: &AbbayeConfig, git_cfg: &GitUiConfi &project_name, &lang, &clone_url_browse, + &formats, ) }) .await @@ -1042,6 +1062,7 @@ async fn prune_excluded_refs( /// /// 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/ @@ -1050,6 +1071,7 @@ fn build_browse_pages( project_name: &str, lang: &Option<String>, clone_url: &Option<String>, + formats: &[OutputFormat], ) -> Result<()> { use syntect::highlighting::ThemeSet; use syntect::parsing::SyntaxSet; @@ -1060,23 +1082,22 @@ fn build_browse_pages( // Build a separate Tera instance for browse templates. let mut tera = Tera::default(); - for (name, builtin) in [ - ("git_tree.html", TEMPLATE_GIT_TREE), - ("git_blob.html", TEMPLATE_GIT_BLOB), - ] { - let override_path = theme_path.join(format!("{name}.j2")); - if override_path.is_file() { - tera.add_template_file(&override_path, Some(name)) - .map_err(|e| miette::miette!("{e}"))?; - } else { - tera.add_raw_template(name, builtin) - .map_err(|e| miette::miette!("{e}"))?; - } - } - crate::site::load_extra_theme_templates( + crate::site::register_format_templates( &mut tera, theme_path, - &["git_tree.html", "git_blob.html"], + 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 { @@ -1102,6 +1123,7 @@ fn build_browse_pages( clone_url, &ss, theme, + formats, )?; } @@ -1125,6 +1147,7 @@ fn walk_tree_dir( 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()?; @@ -1187,22 +1210,28 @@ fn walk_tree_dir( let commit_url = format!("{}commit/{commit_hash}.html", "../".repeat(depth + 2)); let breadcrumbs = make_crumbs(dir_path, false, None); - 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 html = tera - .render("git_tree.html", &ctx) - .map_err(|e| miette::miette!("{e}"))?; - std::fs::write(page_dir.join("index.html"), html).into_diagnostic()?; + 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 { @@ -1224,6 +1253,7 @@ fn walk_tree_dir( clone_url, ss, theme, + formats, )?; } @@ -1248,6 +1278,7 @@ fn walk_tree_dir( clone_url, ss, theme, + formats, )?; } @@ -1270,6 +1301,7 @@ fn render_blob_page( clone_url: &Option<String>, ss: &syntect::parsing::SyntaxSet, theme: &syntect::highlighting::Theme, + formats: &[OutputFormat], ) -> Result<()> { const MAX_BLOB_BYTES: usize = 1024 * 1024; // 1 MiB @@ -1284,10 +1316,15 @@ fn render_blob_page( 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 text = String::from_utf8_lossy(&data); let ext = std::path::Path::new(filename) .extension() .and_then(|s| s.to_str()) @@ -1317,26 +1354,33 @@ fn render_blob_page( Some(filename), ); - 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("is_binary", &is_binary); - ctx.insert("too_large", &too_large); - ctx.insert("size", &data.len()); - ctx.insert("root_path", &root_path); - - let html = tera - .render("git_blob.html", &ctx) - .map_err(|e| miette::miette!("{e}"))?; - std::fs::write(page_dir.join(format!("{filename}.html")), html).into_diagnostic()?; + 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(()) } -
modified src/main.rs
diff --git a/src/main.rs b/src/main.rs index 817630b..021bee4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -401,34 +401,73 @@ async fn main() -> Result<()> { create_dir_all(&theme_path).await.into_diagnostic()?; tokio::fs::write( theme_path.join("root_index.html.j2"), - site::TEMPLATE_ROOT_INDEX, + site::TEMPLATE_ROOT_INDEX_HTML, + ) + .await + .into_diagnostic()?; + tokio::fs::write( + theme_path.join("root_index.gmi.j2"), + site::TEMPLATE_ROOT_INDEX_GEMTEXT, ) .await .into_diagnostic()?; tokio::fs::write( theme_path.join("version_index.html.j2"), - site::TEMPLATE_VERSION_INDEX, + site::TEMPLATE_VERSION_INDEX_HTML, + ) + .await + .into_diagnostic()?; + tokio::fs::write( + theme_path.join("version_index.gmi.j2"), + site::TEMPLATE_VERSION_INDEX_GEMTEXT, ) .await .into_diagnostic()?; tokio::fs::write( theme_path.join("markdown.html.j2"), - builders::markdown::TEMPLATE_MARKDOWN, + builders::markdown::TEMPLATE_MARKDOWN_HTML, + ) + .await + .into_diagnostic()?; + tokio::fs::write( + theme_path.join("markdown.gmi.j2"), + builders::markdown::TEMPLATE_MARKDOWN_GEMTEXT, + ) + .await + .into_diagnostic()?; + tokio::fs::write( + theme_path.join("git_log.html.j2"), + git_ui::TEMPLATE_GIT_LOG_HTML, + ) + .await + .into_diagnostic()?; + tokio::fs::write( + theme_path.join("git_log.gmi.j2"), + git_ui::TEMPLATE_GIT_LOG_GEMTEXT, ) .await .into_diagnostic()?; - tokio::fs::write(theme_path.join("git_log.html.j2"), git_ui::TEMPLATE_GIT_LOG) - .await - .into_diagnostic()?; tokio::fs::write( theme_path.join("git_commit.html.j2"), - git_ui::TEMPLATE_GIT_COMMIT, + git_ui::TEMPLATE_GIT_COMMIT_HTML, + ) + .await + .into_diagnostic()?; + tokio::fs::write( + theme_path.join("git_commit.gmi.j2"), + git_ui::TEMPLATE_GIT_COMMIT_GEMTEXT, ) .await .into_diagnostic()?; tokio::fs::write( theme_path.join("git_refs.html.j2"), - git_ui::TEMPLATE_GIT_REFS, + git_ui::TEMPLATE_GIT_REFS_HTML, + ) + .await + .into_diagnostic()?; + tokio::fs::write( + theme_path.join("git_refs.gmi.j2"), + git_ui::TEMPLATE_GIT_REFS_GEMTEXT, ) .await .into_diagnostic()?; @@ -441,13 +480,25 @@ async fn main() -> Result<()> { } tokio::fs::write( theme_path.join("git_tree.html.j2"), - git_ui::TEMPLATE_GIT_TREE, + git_ui::TEMPLATE_GIT_TREE_HTML, + ) + .await + .into_diagnostic()?; + tokio::fs::write( + theme_path.join("git_tree.gmi.j2"), + git_ui::TEMPLATE_GIT_TREE_GEMTEXT, ) .await .into_diagnostic()?; tokio::fs::write( theme_path.join("git_blob.html.j2"), - git_ui::TEMPLATE_GIT_BLOB, + git_ui::TEMPLATE_GIT_BLOB_HTML, + ) + .await + .into_diagnostic()?; + tokio::fs::write( + theme_path.join("git_blob.gmi.j2"), + git_ui::TEMPLATE_GIT_BLOB_GEMTEXT, ) .await .into_diagnostic()?; -
modified src/site.rs
diff --git a/src/site.rs b/src/site.rs index 6d3002e..959b593 100644 --- a/src/site.rs +++ b/src/site.rs @@ -18,16 +18,34 @@ use tokio::task::JoinSet; use tracing::warn; use crate::{ - builders::LogEvent, changelog::ChangelogExtractor, config::AbbayeConfig, utils, + builders::LogEvent, + changelog::ChangelogExtractor, + config::{AbbayeConfig, OutputFormat}, + utils, version_extractors::VersionInfo, }; -pub const TEMPLATE_ROOT_INDEX: &str = include_str!("templates/root_index.html.j2"); -pub const TEMPLATE_VERSION_INDEX: &str = include_str!("templates/version_index.html.j2"); +pub const TEMPLATE_ROOT_INDEX_HTML: &str = include_str!("templates/root_index.html.j2"); +pub const TEMPLATE_VERSION_INDEX_HTML: &str = include_str!("templates/version_index.html.j2"); +pub const TEMPLATE_ROOT_INDEX_GEMTEXT: &str = include_str!("templates/root_index.gmi.j2"); +pub const TEMPLATE_VERSION_INDEX_GEMTEXT: &str = include_str!("templates/version_index.gmi.j2"); pub const SITE_CSS: &str = include_str!("templates/site.css"); const ATOM_FEED_FILENAME: &str = "releases.atom"; -const SPINNER_CHARS: &str = "β β β Ήβ Έβ Όβ ΄β ¦β §β β "; +pub(crate) const SPINNER_CHARS: &str = "β β β Ήβ Έβ Όβ ΄β ¦β §β β "; + +/// Create a spinner style matching the rest of the abbaye UI. +/// When `indent` is true the template is prefixed with two spaces (for child spinners). +pub(crate) fn spinner_style(indent: bool) -> ProgressStyle { + let tmpl = if indent { + " {spinner:.bold} {prefix} {msg}" + } else { + "{spinner:.bold} {prefix} {msg}" + }; + ProgressStyle::with_template(tmpl) + .expect("valid template") + .tick_chars(SPINNER_CHARS) +} // ββ Types βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ @@ -91,30 +109,24 @@ pub async fn build_site(config: AbbayeConfig) -> Result<()> { // ββ 2. Tera setup βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ let mut tera = Tera::default(); let theme_path = PathBuf::from(".abbaye").join("theme"); - if theme_path.join("root_index.html.j2").is_file() { - tera.add_template_file( - theme_path.join("root_index.html.j2"), - Some("root_index.html"), - ) - .into_diagnostic()?; - } else { - tera.add_raw_template("root_index.html", TEMPLATE_ROOT_INDEX) - .into_diagnostic()?; - } - if theme_path.join("version_index.html.j2").is_file() { - tera.add_template_file( - theme_path.join("version_index.html.j2"), - Some("version_index.html"), - ) - .into_diagnostic()?; - } else { - tera.add_raw_template("version_index.html", TEMPLATE_VERSION_INDEX) - .into_diagnostic()?; - } - load_extra_theme_templates( + + // Load templates for each configured format (with theme overrides). + register_format_templates( &mut tera, &theme_path, - &["root_index.html", "version_index.html"], + &config.site.formats, + &[ + ( + "root_index", + TEMPLATE_ROOT_INDEX_HTML, + TEMPLATE_ROOT_INDEX_GEMTEXT, + ), + ( + "version_index", + TEMPLATE_VERSION_INDEX_HTML, + TEMPLATE_VERSION_INDEX_GEMTEXT, + ), + ], )?; // Write shared CSS to static/ so the git UI templates can reference it. { @@ -236,11 +248,7 @@ pub async fn build_site(config: AbbayeConfig) -> Result<()> { // Spinner inserted above the summary bar. let pb = multi.insert_before(&summary, ProgressBar::new_spinner()); - pb.set_style( - ProgressStyle::with_template("{spinner:.bold} {prefix} {msg}") - .expect("valid template") - .tick_chars(SPINNER_CHARS), - ); + pb.set_style(spinner_style(false)); pb.set_prefix(colored_prefix); pb.set_message("startingβ¦"); pb.enable_steady_tick(Duration::from_millis(100)); @@ -270,11 +278,7 @@ pub async fn build_site(config: AbbayeConfig) -> Result<()> { child_color_idx += 1; let child_pb = multi_log.insert_after(&last_child_pb, ProgressBar::new_spinner()); - child_pb.set_style( - ProgressStyle::with_template(" {spinner:.bold} {prefix} {msg}") - .expect("valid template") - .tick_chars(SPINNER_CHARS), - ); + 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)); @@ -471,30 +475,28 @@ pub async fn build_site(config: AbbayeConfig) -> Result<()> { has_docs_tarball = false; } - // ββ 6. README βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ + // ββ 6. Raw README content βββββββββββββββββββββββββββββββββββββββββββββββββ let readme_path = config .site .readme .as_deref() .unwrap_or(Path::new("README.md")); - let readme_html = match tokio::fs::read_to_string(readme_path).await { - Ok(content) => render_markdown(&content), - Err(_) => { + let readme_md = tokio::fs::read_to_string(readme_path) + .await + .unwrap_or_else(|_| { warn!("README not found at {}", readme_path.display()); String::new() - } - }; + }); // Copy any locally-referenced files (e.g. images) from the README's // directory into the version directory so they resolve correctly from - // the generated index.html. - let readme_dir = readme_path.parent().unwrap_or_else(|| Path::new(".")); - if let Ok(content) = tokio::fs::read_to_string(readme_path).await { - for rel in extract_local_refs(&content) { + // the generated pages. + if !readme_md.is_empty() { + let readme_dir = readme_path.parent().unwrap_or_else(|| Path::new(".")); + for rel in extract_local_refs(&readme_md) { let src = readme_dir.join(&rel); if src.is_file() { - // Preserve any sub-directory structure relative to the README. let dest = version_dir.join(&rel); if let Some(parent) = dest.parent() { tokio::fs::create_dir_all(parent).await.into_diagnostic()?; @@ -504,19 +506,19 @@ pub async fn build_site(config: AbbayeConfig) -> Result<()> { } } - // ββ 7. Changelog section ββββββββββββββββββββββββββββββββββββββββββββββββββ - let changelog_html = match ChangelogExtractor + // ββ 7. Raw changelog section ββββββββββββββββββββββββββββββββββββββββββββββ + let changelog_md = match ChangelogExtractor .section(config.changelog.clone(), &version) .await { - Ok(section) => render_markdown(§ion), + Ok(section) => section, Err(_) => { warn!("No changelog entry found for version {version}"); String::new() } }; - // ββ 8. Version index.html βββββββββββββββββββββββββββββββββββββββββββββββββ + // ββ 8. Version pages (per format) βββββββββββββββββββββββββββββββββββββββββ // Git UI integration: derive the clone URL once and compute the tag name // for the current version so the templates can link into the repository UI. @@ -531,28 +533,49 @@ pub async fn build_site(config: AbbayeConfig) -> Result<()> { }); let version_tag = config.version_extractor.tag_name(&version); - let mut version_ctx = Context::new(); - version_ctx.insert("config", &config); - version_ctx.insert("project_name", &config.site.name); - version_ctx.insert("lang", &config.site.lang); - version_ctx.insert("repo_url", &config.site.repo_url); - version_ctx.insert("version", &version); - version_ctx.insert("readme_html", &readme_html); - version_ctx.insert("changelog_html", &changelog_html); - version_ctx.insert("has_docs", &has_docs); - version_ctx.insert("has_docs_tarball", &has_docs_tarball); - version_ctx.insert("has_dist", &has_dist); - version_ctx.insert("dist_files", &dist_file_infos); - version_ctx.insert("git_ui_enabled", &config.git_ui.is_some()); - version_ctx.insert("git_ui_clone_url", &git_ui_clone_url); - version_ctx.insert("version_tag", &version_tag); - - let version_html = tera - .render("version_index.html", &version_ctx) - .into_diagnostic()?; - tokio::fs::write(version_dir.join("index.html"), version_html) - .await - .into_diagnostic()?; + for format in &config.site.formats { + let suffix = format.extension(); + let tmpl_name = format!("version_index.{suffix}"); + let ext = format.extension(); + + let (readme_content, changelog_content): (String, String) = match format { + OutputFormat::Html => ( + render_markdown_html(&readme_md), + render_markdown_html(&changelog_md), + ), + OutputFormat::Gemtext => ( + render_markdown_gemtext(&readme_md), + render_markdown_gemtext(&changelog_md), + ), + }; + + // Pre-render HTML versions for backward-compatible templates + let readme_html = render_markdown_html(&readme_md); + let changelog_html = render_markdown_html(&changelog_md); + + let mut ctx = Context::new(); + ctx.insert("config", &config); + ctx.insert("project_name", &config.site.name); + ctx.insert("lang", &config.site.lang); + ctx.insert("repo_url", &config.site.repo_url); + ctx.insert("version", &version); + ctx.insert("readme_html", &readme_html); + ctx.insert("changelog_html", &changelog_html); + ctx.insert("readme_content", &readme_content); + ctx.insert("changelog_content", &changelog_content); + ctx.insert("has_docs", &has_docs); + ctx.insert("has_docs_tarball", &has_docs_tarball); + ctx.insert("has_dist", &has_dist); + ctx.insert("dist_files", &dist_file_infos); + ctx.insert("git_ui_enabled", &config.git_ui.is_some()); + ctx.insert("git_ui_clone_url", &git_ui_clone_url); + ctx.insert("version_tag", &version_tag); + + let content = tera.render(&tmpl_name, &ctx).into_diagnostic()?; + tokio::fs::write(version_dir.join(format!("index.{ext}")), content) + .await + .into_diagnostic()?; + } // ββ 9. Root index.html + Atom feed ββββββββββββββββββββββββββββββββββββββ // Collect every known version from the version extractor, ensure the @@ -573,22 +596,26 @@ pub async fn build_site(config: AbbayeConfig) -> Result<()> { .as_deref() .map(|u| u.trim_end_matches('/')); - let mut root_ctx = Context::new(); - root_ctx.insert("config", &config); - root_ctx.insert("project_name", &config.site.name); - root_ctx.insert("lang", &config.site.lang); - root_ctx.insert("repo_url", &config.site.repo_url); - root_ctx.insert("versions", &version_entries); - root_ctx.insert("atom_feed", ATOM_FEED_FILENAME); - root_ctx.insert("git_ui_enabled", &config.git_ui.is_some()); - root_ctx.insert("git_ui_clone_url", &git_ui_clone_url); - - let root_html = tera - .render("root_index.html", &root_ctx) - .into_diagnostic()?; - tokio::fs::write(output_dir.join("index.html"), root_html) - .await - .into_diagnostic()?; + for format in &config.site.formats { + let suffix = format.extension(); + let tmpl_name = format!("root_index.{suffix}"); + let ext = format.extension(); + + let mut ctx = Context::new(); + ctx.insert("config", &config); + ctx.insert("project_name", &config.site.name); + ctx.insert("lang", &config.site.lang); + ctx.insert("repo_url", &config.site.repo_url); + ctx.insert("versions", &version_entries); + ctx.insert("atom_feed", ATOM_FEED_FILENAME); + ctx.insert("git_ui_enabled", &config.git_ui.is_some()); + ctx.insert("git_ui_clone_url", &git_ui_clone_url); + + let content = tera.render(&tmpl_name, &ctx).into_diagnostic()?; + tokio::fs::write(output_dir.join(format!("index.{ext}")), content) + .await + .into_diagnostic()?; + } // ββ 10. Atom feed βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ // Load all changelog sections once so we can embed release notes in feed entries. @@ -651,7 +678,7 @@ fn extract_local_refs(md: &str) -> Vec<String> { } /// Render Markdown to an HTML string. -fn render_markdown(md: &str) -> 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(); @@ -659,6 +686,237 @@ fn render_markdown(md: &str) -> String { 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). @@ -842,7 +1100,7 @@ fn generate_atom_feed( // it for embedding inside <content type="html">. let content_element = match changelog_sections.get(&vi.version) { Some(md) if !md.is_empty() => { - let html = render_markdown(md); + let html = render_markdown_html(md); format!( " <content type=\"html\">{}</content>\n", xml_escape(&html) @@ -911,6 +1169,54 @@ fn update_latest_symlink(output_dir: &Path, version_dir_name: &str) -> Result<() Ok(()) } +/// Register templates for each configured output format, supporting theme overrides. +/// +/// For each `format` in `formats` and each `(base_name, html_builtin, gmi_builtin)` +/// tuple in `templates`, registers a Tera template named `"{base_name}.{ext}"` β +/// first checking for a user override at `theme_path / "{base_name}.{ext}.j2"`, +/// otherwise falling back to the corresponding builtin constant. +/// +/// After registering the format-specific templates, also loads any extra `.j2` +/// files from the theme directory that weren't explicitly registered (see +/// [`load_extra_theme_templates`]). +pub(crate) fn register_format_templates( + tera: &mut Tera, + theme_path: &Path, + formats: &[OutputFormat], + templates: &[(&str, &str, &str)], +) -> Result<()> { + for format in formats { + let ext = format.extension(); + for (template_base, builtin_html, builtin_gmi) in templates { + let name = format!("{template_base}.{ext}"); + let builtin = match format { + OutputFormat::Html => builtin_html, + OutputFormat::Gemtext => builtin_gmi, + }; + let theme_file = theme_path.join(format!("{template_base}.{ext}.j2")); + if theme_file.is_file() { + tera.add_template_file(&theme_file, Some(&name)) + .into_diagnostic()?; + } else { + tera.add_raw_template(&name, builtin).into_diagnostic()?; + } + } + } + + let skip_names: Vec<String> = formats + .iter() + .flat_map(|fmt| { + let ext = fmt.extension(); + templates + .iter() + .map(move |(base, _, _)| format!("{base}.{ext}")) + }) + .collect(); + let skip_refs: Vec<&str> = skip_names.iter().map(|s| s.as_str()).collect(); + load_extra_theme_templates(tera, theme_path, &skip_refs)?; + Ok(()) +} + /// Scans `theme_path` for any `*.j2` files whose stem (the name without the /// `.j2` suffix, e.g. `"base.html"`) is **not** already listed in `skip`, and /// loads each one into `tera` under that stem name. -
added src/templates/git_blob.gmi.j2
diff --git a/src/templates/git_blob.gmi.j2 b/src/templates/git_blob.gmi.j2 new file mode 100644 index 0000000..12bc9db --- /dev/null +++ b/src/templates/git_blob.gmi.j2 @@ -0,0 +1,17 @@ +# {{ commit_hash_short }}/{{ file_path }} + +=> {{ commit_url | replace(from=".html", to=".gmi") }} Commit {{ commit_hash_short }} +=> ../index.gmi Log +{% for crumb in breadcrumbs %} +=> {{ crumb.url | default(value="") | replace(from=".html", to=".gmi") }} {{ crumb.name }} +{% endfor %} + +## {{ filename }} +{% if is_binary %} +=> {{ file_path }} Download (binary) +{% elif too_large %} +File too large ({{ size }} bytes) to display inline. +{% elif content_plain %} +```{{ filename }} +{{ content_plain }}``` +{% endif %} -
added src/templates/git_commit.gmi.j2
diff --git a/src/templates/git_commit.gmi.j2 b/src/templates/git_commit.gmi.j2 new file mode 100644 index 0000000..4990a85 --- /dev/null +++ b/src/templates/git_commit.gmi.j2 @@ -0,0 +1,21 @@ +# {{ commit.subject }} + +=> ../index.gmi Log + +Author: {{ commit.author_name }} +Date: {{ commit.datetime_display }} +Hash: {{ commit.hash }} +{% for parent in commit.parents %} +=> ../commit/{{ parent.hash }}.gmi Parent {{ parent.hash_short }} +{% endfor %} +{% if commit.body %} + +{{ commit.body }} +{% endif %} +{% for file in changed_files %} +## {{ file.path }} ({{ file.status }}) + +```diff +{% for line in file.diff_lines %}{{ line.content }} +{% endfor %}``` +{% endfor %} -
added src/templates/git_log.gmi.j2
diff --git a/src/templates/git_log.gmi.j2 b/src/templates/git_log.gmi.j2 new file mode 100644 index 0000000..a199b50 --- /dev/null +++ b/src/templates/git_log.gmi.j2 @@ -0,0 +1,12 @@ +# {{ project_name }} β {{ current_branch }} + +=> refs.gmi Refs +{% for branch in branch_nav %} +=> {{ branch.filename | replace(from=".html", to=".gmi") }} {{ branch.short_name }}{% if branch.is_current %} (current){% endif %} +{% endfor %} + +## Commits +{% for commit in commits %} +=> commit/{{ commit.hash }}.gmi {{ commit.date }} {{ commit.subject }} +{% endfor %} +{% if truncated %}... (truncated){% endif %} -
added src/templates/git_refs.gmi.j2
diff --git a/src/templates/git_refs.gmi.j2 b/src/templates/git_refs.gmi.j2 new file mode 100644 index 0000000..d1b6446 --- /dev/null +++ b/src/templates/git_refs.gmi.j2 @@ -0,0 +1,15 @@ +# {{ project_name }} β Refs + +=> index.gmi Log +{% if branches %} +## Branches +{% for branch in branches %} +=> {{ branch.short_name | replace(from="/", to="-") }}.gmi {{ branch.short_name }} +{% endfor %} +{% endif %} +{% if tags %} +## Tags +{% for tag in tags %} +=> ../{{ tag.short_name }}/index.gmi {{ tag.short_name }} +{% endfor %} +{% endif %} -
added src/templates/git_tree.gmi.j2
diff --git a/src/templates/git_tree.gmi.j2 b/src/templates/git_tree.gmi.j2 new file mode 100644 index 0000000..821505a --- /dev/null +++ b/src/templates/git_tree.gmi.j2 @@ -0,0 +1,12 @@ +# {{ commit_hash_short }}/{{ dir_path }} + +=> {{ commit_url | replace(from=".html", to=".gmi") }} Commit {{ commit_hash_short }} +=> ../index.gmi Log +{% for crumb in breadcrumbs %} +=> {{ crumb.url | default(value="") | replace(from=".html", to=".gmi") }} {{ crumb.name }} +{% endfor %} + +## {{ dir_path | default(value="/") }} +{% for entry in entries %} +=> {{ entry.url | replace(from=".html", to=".gmi") }} {{ entry.name }} +{% endfor %} -
added src/templates/markdown.gmi.j2
diff --git a/src/templates/markdown.gmi.j2 b/src/templates/markdown.gmi.j2 new file mode 100644 index 0000000..453c732 --- /dev/null +++ b/src/templates/markdown.gmi.j2 @@ -0,0 +1,3 @@ +# {{ title }} + +{{ content | safe }} -
added src/templates/root_index.gmi.j2
diff --git a/src/templates/root_index.gmi.j2 b/src/templates/root_index.gmi.j2 new file mode 100644 index 0000000..f03def2 --- /dev/null +++ b/src/templates/root_index.gmi.j2 @@ -0,0 +1,12 @@ +# {{ project_name }} + +## Versions +{% for v in versions %} +=> {{ v.version }}/ {{ v.version }}{% if loop.first %} (latest){% endif %}{% if v.date %} {{ v.date }}{% endif %} +{% endfor %} +{% if git_ui_enabled %} +## Repository + +=> repository/ Browse source +{% endif %} +=> {{ atom_feed }} Atom feed -
added src/templates/version_index.gmi.j2
diff --git a/src/templates/version_index.gmi.j2 b/src/templates/version_index.gmi.j2 new file mode 100644 index 0000000..7104f80 --- /dev/null +++ b/src/templates/version_index.gmi.j2 @@ -0,0 +1,26 @@ +# {{ project_name }} β {{ version }} + +=> ../ All versions +{% if repo_url %} +## Repository +=> {{ repo_url }} {{ repo_url }} +{% endif %}{% if git_ui_enabled and version_tag %} +=> ../repository/refs.gmi#tag-{{ version_tag }} Browse {{ version_tag }} +{% endif %}{% if has_docs %} +## Documentation +=> docs/ API docs +{% if has_docs_tarball %} +=> docs.tar.gz Download docs.tar.gz +{% endif %} +{% endif %}{% if has_dist %} +## Downloads +{% for file in dist_files %} +=> dist/{{ file.name }} {{ file.name }} ({{ file.size_human }}) +{% endfor %} +{% endif %} +{{ readme_content | safe }} +{% if changelog_content %} +## Changes in {{ version }} + +{{ changelog_content | safe }} +{% endif %} -
modified src/utils.rs
diff --git a/src/utils.rs b/src/utils.rs index 414062a..021aba2 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,4 +1,3 @@ -use std::collections::HashMap; use std::path::{Path, PathBuf}; use flate2::Compression; @@ -54,16 +53,11 @@ where { let mut path_str = path.as_ref().to_string_lossy().to_string(); - // Convert iterator into a HashMap for efficient lookup - let var_map: HashMap<String, String> = vars - .into_iter() - .map(|(k, v)| (k.as_ref().to_string(), v.as_ref().to_string())) - .collect(); - - // Replace each variable - for (key, value) in var_map { - path_str = path_str.replace(&format!("${{{}}}", key), &value); - path_str = path_str.replace(&format!("${}", key), &value); + for (key, value) in vars { + let key = key.as_ref(); + let value = value.as_ref(); + path_str = path_str.replace(&format!("${{{key}}}"), value); + path_str = path_str.replace(&format!("${key}"), value); } PathBuf::from(path_str)