diff --git a/Cargo.lock b/Cargo.lock index 057bcf1642..7a0e5751a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1042,6 +1042,8 @@ dependencies = [ "indexmap", "next_version", "pretty_assertions", + "pulldown-cmark", + "pulldown-cmark-to-cmark", "regex", "reqwest", "reqwest-middleware", @@ -2178,6 +2180,26 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags 2.10.0", + "memchr", + "unicase", +] + +[[package]] +name = "pulldown-cmark-to-cmark" +version = "22.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab1ad36992cead65f02aa399a373a42730922f1525d988172634fdefdecb8a60" +dependencies = [ + "pulldown-cmark", +] + [[package]] name = "quick-xml" version = "0.26.0" diff --git a/cliff.toml b/cliff.toml index b4a4be3c74..66b8a18e10 100644 --- a/cliff.toml +++ b/cliff.toml @@ -70,6 +70,8 @@ footer = """ """ # Remove leading and trailing whitespaces from the changelog's body. trim = true +# Format the rendered changelog as Markdown (only applies to Markdown output). +# format = true # An array of regex based postprocessors to modify the changelog. postprocessors = [ # Replace the placeholder `` with a URL. diff --git a/git-cliff-core/Cargo.toml b/git-cliff-core/Cargo.toml index 1f02e8da23..bcbebf2852 100644 --- a/git-cliff-core/Cargo.toml +++ b/git-cliff-core/Cargo.toml @@ -70,6 +70,8 @@ indexmap = { version = "2.13.0", features = ["serde"] } toml = "0.9.8" next_version = "0.3.2" semver = "1.0.27" +pulldown-cmark = { version = "0.13.4", default-features = false } +pulldown-cmark-to-cmark = "22.0.1" humantime-serde = "1.1.1" document-features = { version = "0.2.12", optional = true } reqwest = { workspace = true, optional = true } diff --git a/git-cliff-core/src/changelog.rs b/git-cliff-core/src/changelog.rs index fe2e9bd68f..1bd23f96a7 100644 --- a/git-cliff-core/src/changelog.rs +++ b/git-cliff-core/src/changelog.rs @@ -587,64 +587,61 @@ impl<'a> Changelog<'a> { pub fn generate(&self, out: &mut W) -> Result<()> { crate::set_progress_message!("Generating and writing the changelog"); tracing::debug!("Generating changelog"); + + let mut output = self.render()?; + if self.config.changelog.format { + output = crate::markdown::format_markdown(&output)?; + } + + let write_result = write!(out, "{output}"); + if let Err(e) = write_result { + if e.kind() != std::io::ErrorKind::BrokenPipe { + return Err(e.into()); + } + } + + Ok(()) + } + + /// Renders the changelog (header + releases + footer) into a string. + /// + /// With `format` disabled this produces byte-for-byte the same output that + /// used to be written directly to the writer. + fn render(&self) -> Result { let postprocessors = self.config.changelog.postprocessors.clone(); + let mut output = String::new(); if let Some(header_template) = &self.header_template { - let write_result = writeln!( - out, - "{}", - header_template.render( - &Releases { - releases: &self.releases, - }, - Some(&self.additional_context), - &postprocessors, - )? - ); - if let Err(e) = write_result { - if e.kind() != std::io::ErrorKind::BrokenPipe { - return Err(e.into()); - } - } + output.push_str(&header_template.render( + &Releases { + releases: &self.releases, + }, + Some(&self.additional_context), + &postprocessors, + )?); + output.push('\n'); } for release in &self.releases { - let write_result = write!( - out, - "{}", - self.body_template.render( - &release, - Some(&self.additional_context), - &postprocessors - )? - ); - if let Err(e) = write_result { - if e.kind() != std::io::ErrorKind::BrokenPipe { - return Err(e.into()); - } - } + output.push_str(&self.body_template.render( + &release, + Some(&self.additional_context), + &postprocessors, + )?); } if let Some(footer_template) = &self.footer_template { - let write_result = writeln!( - out, - "{}", - footer_template.render( - &Releases { - releases: &self.releases, - }, - Some(&self.additional_context), - &postprocessors, - )? - ); - if let Err(e) = write_result { - if e.kind() != std::io::ErrorKind::BrokenPipe { - return Err(e.into()); - } - } + output.push_str(&footer_template.render( + &Releases { + releases: &self.releases, + }, + Some(&self.additional_context), + &postprocessors, + )?); + output.push('\n'); } - Ok(()) + Ok(output) } /// Generates a changelog and prepends it to the given changelog. @@ -814,6 +811,7 @@ mod test { replace_command: None, }], render_always: false, + format: false, output: None, }, git: GitConfig { @@ -1477,6 +1475,36 @@ mod test { Ok(()) } + #[test] + fn changelog_generator_format() -> Result<()> { + let (config, releases) = get_test_data(); + + // Formatting disabled: the output is exactly what the templates render. + let plain = { + let changelog = Changelog::new(releases.clone(), config.clone(), None)?; + let mut out = Vec::new(); + changelog.generate(&mut out)?; + String::from_utf8(out).expect("output should be valid utf-8") + }; + + // Formatting enabled: the output is the rendered changelog run through + // the Markdown formatter. + let mut formatted_config = config; + formatted_config.changelog.format = true; + let formatted = { + let changelog = Changelog::new(releases, formatted_config, None)?; + let mut out = Vec::new(); + changelog.generate(&mut out)?; + String::from_utf8(out).expect("output should be valid utf-8") + }; + + assert_eq!(formatted, crate::markdown::format_markdown(&plain)?); + // The fixture output isn't already normalized, so formatting changes it. + assert_ne!(plain, formatted); + + Ok(()) + } + #[test] fn changelog_generator_split_commits() -> Result<()> { let (mut config, mut releases) = get_test_data(); @@ -1661,6 +1689,7 @@ chore(deps): fix broken deps trim: true, postprocessors: Vec::new(), render_always: false, + format: false, output: None, }, git: GitConfig { diff --git a/git-cliff-core/src/config.rs b/git-cliff-core/src/config.rs index 44cf86d7a9..0e6563deb1 100644 --- a/git-cliff-core/src/config.rs +++ b/git-cliff-core/src/config.rs @@ -76,6 +76,13 @@ pub struct ChangelogConfig { pub trim: bool, /// Always render the body template. pub render_always: bool, + /// Format the rendered changelog as Markdown. + /// + /// Only takes effect when the output is Markdown (stdout or a `.md` + /// file). Defaults to `false`, in which case the output is left exactly + /// as the templates rendered it. + #[serde(default)] + pub format: bool, /// Changelog postprocessors. pub postprocessors: Vec, /// Output file path. diff --git a/git-cliff-core/src/error.rs b/git-cliff-core/src/error.rs index 1c2b89ad4d..ef54077a74 100644 --- a/git-cliff-core/src/error.rs +++ b/git-cliff-core/src/error.rs @@ -10,6 +10,9 @@ pub enum Error { /// string. #[error("UTF-8 error: `{0}`")] Utf8Error(#[from] std::str::Utf8Error), + /// Error that may occur while formatting the changelog as Markdown. + #[error("Markdown format error: `{0}`")] + MarkdownFormatError(#[from] pulldown_cmark_to_cmark::Error), /// Error variant that represents errors coming out of libgit2. #[cfg(feature = "repo")] #[error("Git error: `{0}`")] diff --git a/git-cliff-core/src/lib.rs b/git-cliff-core/src/lib.rs index 2f0ec12a73..aacbbc474c 100644 --- a/git-cliff-core/src/lib.rs +++ b/git-cliff-core/src/lib.rs @@ -26,6 +26,8 @@ pub mod contributor; pub mod embed; /// Error handling. pub mod error; +/// Markdown post-processing. +pub mod markdown; /// Commit processing pipeline. pub mod process; /// Common release type. diff --git a/git-cliff-core/src/markdown.rs b/git-cliff-core/src/markdown.rs new file mode 100644 index 0000000000..2b754016f1 --- /dev/null +++ b/git-cliff-core/src/markdown.rs @@ -0,0 +1,105 @@ +//! Markdown post-processing for the generated changelog. + +use pulldown_cmark::{Options, Parser}; +use pulldown_cmark_to_cmark::cmark_with_options; + +use crate::error::Result; + +/// Normalizes a Markdown string by round-tripping it through a parser and +/// re-emitter. +/// +/// This tidies up formatting that is tedious to get right in a Tera template +/// (heading styles, list markers, blank lines between blocks) without the user +/// having to fiddle with `{%-` / `trim` everywhere. The GitHub-flavored +/// extensions git-cliff templates commonly use (tables, strikethrough, task +/// lists, footnotes) are enabled so they survive the round-trip. +/// +/// The formatter options are intentionally conservative: it doesn't reflow +/// text or rewrite links, so template output that is already valid Markdown +/// keeps its structure. +pub fn format_markdown(input: &str) -> Result { + let mut options = Options::empty(); + options.insert(Options::ENABLE_TABLES); + options.insert(Options::ENABLE_STRIKETHROUGH); + options.insert(Options::ENABLE_TASKLISTS); + options.insert(Options::ENABLE_FOOTNOTES); + + let parser = Parser::new_ext(input, options); + let mut formatted = String::with_capacity(input.len()); + // Keep `-` as the bullet marker to match git-cliff's default templates and + // avoid churning existing changelogs from `-` to `*`. + let format_options = pulldown_cmark_to_cmark::Options { + list_token: '-', + ..Default::default() + }; + cmark_with_options(parser, &mut formatted, format_options)?; + + // `cmark` doesn't emit a trailing newline, but changelogs conventionally + // end with one. Preserve whatever the input had at the boundary. + if input.ends_with('\n') && !formatted.ends_with('\n') { + formatted.push('\n'); + } + Ok(formatted) +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn normalizes_messy_markdown() -> Result<()> { + // Valid but sloppy: a setext heading, missing blank line before a + // heading, and runs of blank lines between blocks. + let input = "\ +Changelog +========= + + +## 1.0.0 +### Features + +- first change +- second change + + + +some text +"; + let formatted = format_markdown(input)?; + let expected = "\ +# Changelog + +## 1.0.0 + +### Features + +- first change +- second change + +some text +"; + assert_eq!(expected, formatted); + // Formatting is idempotent: a second pass changes nothing. + assert_eq!(formatted, format_markdown(&formatted)?); + Ok(()) + } + + #[test] + fn keeps_clean_markdown_stable() -> Result<()> { + let input = "\ +# Changelog + +## 1.0.0 + +### Features + +- add a thing + +### Bug Fixes + +- fix a thing +"; + assert_eq!(input, format_markdown(input)?); + Ok(()) + } +} diff --git a/git-cliff-core/tests/integration_test.rs b/git-cliff-core/tests/integration_test.rs index 8885221354..3b9c65ce4d 100644 --- a/git-cliff-core/tests/integration_test.rs +++ b/git-cliff-core/tests/integration_test.rs @@ -35,6 +35,7 @@ fn generate_changelog() -> Result<()> { footer: Some(String::from("eoc - end of changelog")), trim: true, render_always: false, + format: false, postprocessors: [].to_vec(), output: None, }; diff --git a/git-cliff/src/lib.rs b/git-cliff/src/lib.rs index f7a8245885..900ed9893d 100644 --- a/git-cliff/src/lib.rs +++ b/git-cliff/src/lib.rs @@ -842,6 +842,21 @@ pub fn write_changelog( .output .clone() .or(changelog.config.changelog.output.clone()); + // Markdown formatting only makes sense for Markdown output. Detect it from + // the file extension (stdout and extension-less paths are treated as + // Markdown, matching git-cliff's default output). + if changelog.config.changelog.format { + let is_markdown = output.as_ref().is_none_or(|path| { + path.extension() + .is_none_or(|ext| ext.eq_ignore_ascii_case("md")) + }); + if !is_markdown { + tracing::warn!( + "`changelog.format` is enabled but the output is not Markdown; skipping formatting" + ); + changelog.config.changelog.format = false; + } + } if args.bump.is_some() || args.bumped_version { let current_version = changelog.releases.first().and_then(|release| { release.version.clone().or_else(|| { diff --git a/website/docs/configuration/changelog.md b/website/docs/configuration/changelog.md index 89f547547c..411b8ed304 100644 --- a/website/docs/configuration/changelog.md +++ b/website/docs/configuration/changelog.md @@ -56,6 +56,19 @@ It is useful for adding indentation to the template for readability, as shown [i If set to `true`, the changelog [body](#body) will be rendered even if there are no releases to process. +### format + +If set to `true`, the rendered changelog is passed through a Markdown formatter before it is written. This normalizes heading styles, list markers, and blank lines so you don't have to fight the template with `{%-` and `trim` to get tidy output. + +Formatting only runs when the output is Markdown, i.e. writing to stdout or to a file with a `.md` extension. It is off by default, and with it off the output is exactly what the templates render. + +```toml +[changelog] +format = true +``` + +This is an out-of-the-box alternative to configuring [`postprocessors`](#postprocessors) with an external tool like `mdformat`. + ### postprocessors An array of commit postprocessors for manipulating the changelog before outputting.