diff --git a/CHANGELOG.md b/CHANGELOG.md index 5dd40fe..4e8883c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - [#50](https://github.com/dblock/ruby-gem-release/pull/50): Add optional release spec helpers, local publication adapters, and a complete example for testing project-specific release customizations in isolated repositories - [@dblock](https://github.com/dblock). - [#51](https://github.com/dblock/ruby-gem-release/pull/51): Carry changelog subsections into the next release and remove multiple contribution placeholders - [@dblock](https://github.com/dblock). - [#52](https://github.com/dblock/ruby-gem-release/pull/52): Support configured contribution placeholders and complete category templates in Keep a Changelog releases - [@dblock](https://github.com/dblock). +- [#53](https://github.com/dblock/ruby-gem-release/pull/53): Add Markdown section replacement and text substitution for project-specific release documentation - [@dblock](https://github.com/dblock). - Your contribution here. ## 0.5.1 (2026/11/10) diff --git a/docs/markdown.md b/docs/markdown.md new file mode 100644 index 0000000..f7dd4b7 --- /dev/null +++ b/docs/markdown.md @@ -0,0 +1,30 @@ +# Updating Markdown Sections + +Use `RubyGemRelease::Markdown::Section` to replace a section's body while keeping project-specific wording and version links in your hooks: + +```ruby +require 'ruby-gem-release/markdown/section' + +stable = RubyGemRelease::Markdown::Section.new( + path: 'README.md', heading: '## Stable Release' +) + +workflow.before(:validate) { |_ctx| stable.validate! } +workflow.after(:prepare_release) do |ctx| + stable.replace!("You're reading the documentation for version #{ctx.release.version}.") +end +``` + +`heading:` is the complete, exact heading line, with one to six leading `#` characters. There must be exactly one matching top-level Markdown heading; headings inside code, quotes, and HTML examples do not count. Missing or duplicate headings raise before writing. + +`replace!` replaces everything after that heading through the next equal-or-shallower heading, including nested subsections. It preserves surrounding source, normalizes the replacement's line endings to the file, and accepts repeated replacement. Mixed or unsupported line endings are rejected. + +Use `gsub!(pattern, replacement)` to replace matching text without replacing the whole body: + +```ruby +stable.gsub!(/version \d+\.\d+\.\d+/, 'version 0.2.0') +``` + +Like `String#gsub!`, the pattern can be a string or regular expression, and replacement strings support capture references. All matches within the body are replaced, including matches in nested subsections and code examples; the heading and surrounding sections remain unchanged. Existing formatting and the final newline are preserved, and inserted line endings follow the file's style. With no matches, it returns `nil` without writing. Missing or duplicate section headings still raise. + +The [lifecycle-hooks example](../examples/lifecycle_hooks/release_notes.rb) uses this helper for its stable and development README text. Keep assertions about that wording and the project's release behavior in the project's [custom workflow specs](testing.md). diff --git a/docs/workflow/hooks.md b/docs/workflow/hooks.md index ecc8dc0..ea383cf 100644 --- a/docs/workflow/hooks.md +++ b/docs/workflow/hooks.md @@ -14,6 +14,8 @@ For each stage, before hooks run in registration order, then the built-in operat Use the built-in [changelog contribution template options](../changelog/keep_a_changelog.md#contribution-templates) for placeholder removal and category restoration rather than implementing those operations as hooks. +For README updates, use the [Markdown section helper](../markdown.md) to validate and replace one heading's body while keeping project-specific wording in the hook. + ## Context Hooks receive an immutable execution context with the configured `ctx.workflow` and current `ctx.release`. `ctx.stage`, `ctx.hook`, and `ctx.hook_index` identify the stage, timing, and one-based position. Before capture, `ctx.release` is `nil`; semantic hooks see `ctx.analysis` and the selected version after validation. Next-version hooks see `ctx.release.next_version` after development preparation. diff --git a/examples/lifecycle_hooks/release_notes.rb b/examples/lifecycle_hooks/release_notes.rb index d61d622..d96e0cb 100644 --- a/examples/lifecycle_hooks/release_notes.rb +++ b/examples/lifecycle_hooks/release_notes.rb @@ -1,14 +1,14 @@ # frozen_string_literal: true -class ProjectReleaseNotes - STABLE_PARAGRAPH = /^## Stable Release\n\n[^\n]+$/ +require 'ruby-gem-release/markdown/section' +class ProjectReleaseNotes def initialize(readme: 'README.md') - @readme = readme + @readme_section = RubyGemRelease::Markdown::Section.new(path: readme, heading: '## Stable Release') end def validate!(_ctx) - raise ArgumentError, 'expected exactly one stable release paragraph in README.md' unless File.read(@readme).scan(STABLE_PARAGRAPH).one? + @readme_section.validate! end def update_stable_readme!(ctx) @@ -24,6 +24,6 @@ def update_development_readme!(ctx) private def write_readme(paragraph) - File.write(@readme, File.read(@readme).sub(STABLE_PARAGRAPH, "## Stable Release\n\n#{paragraph}")) + @readme_section.replace!(paragraph) end end diff --git a/lib/ruby-gem-release/changelog/base.rb b/lib/ruby-gem-release/changelog/base.rb index c5196f4..dff92a4 100644 --- a/lib/ruby-gem-release/changelog/base.rb +++ b/lib/ruby-gem-release/changelog/base.rb @@ -1,11 +1,13 @@ # frozen_string_literal: true require 'date' -require 'commonmarker' +require_relative '../markdown/source' module RubyGemRelease module Changelog class Base + include Markdown::Source + def initialize(path: 'CHANGELOG.md') @path = path end @@ -44,38 +46,6 @@ def unreleased_notes(content, separator) content[line_end(content, position, separator)...section_end] end - def heading(content, pattern) - matches = heading_positions(content, pattern) - raise ArgumentError, "expected exactly one matching heading in #{@path}" unless matches.one? - - matches.first - end - - def heading_positions(content, pattern, max_level: 6) - lines = content.lines - offsets = line_offsets(lines) - matches = [] - document(content).each do |node| - next unless node.type == :heading && node.header_level <= max_level - - index = node.source_position[:start_line] - 1 - matches << offsets[index] if pattern.match?(lines[index].chomp) - end - matches - end - - def heading_prefix(content, position) - content[position, 6][/\A#+/] - end - - def section_end_position(content, position, level) - heading_positions(content, /\A\#{1,6} /, max_level: level).find { |offset| offset > position } || content.length - end - - def line_end(content, position, separator) - content.index(separator, position) || raise(ArgumentError, "heading must end with a newline in #{@path}") - end - def check_release_notes(content, position, separator, version) section_end = section_end_position(content, position, heading_prefix(content, position).length) check_notes(content[line_end(content, position, separator)...section_end], version) @@ -103,19 +73,6 @@ def editable_lines(content) end end - def document(content) - Commonmarker.parse(content, options: { extension: { shortcodes: false } }) - end - - def line_offsets(lines) - offset = 0 - lines.map do |line| - position = offset - offset += line.length - position - end - end - def excluded_lines(content) excluded = Set.new document(content).walk do |node| @@ -127,12 +84,6 @@ def excluded_lines(content) excluded end - def newline(content) - raise ArgumentError, "mixed or unsupported line endings in #{@path}" if (content.match?(/(? position } || content.length + end + + def line_end(content, position, separator) + content.index(separator, position) || raise(ArgumentError, "heading must end with a newline in #{@path}") + end + + def document(content) + Commonmarker.parse(content, options: { extension: { shortcodes: false } }) + end + + def line_offsets(lines) + offset = 0 + lines.map do |line| + position = offset + offset += line.length + position + end + end + + def newline(content) + raise ArgumentError, "mixed or unsupported line endings in #{@path}" if (content.match?(/(? ## Stable Release\n\n