From 521aa41e2f397320dbaae7350dba177a4a1a8894 Mon Sep 17 00:00:00 2001 From: "Daniel (dB.) Doubrovkine" Date: Wed, 11 Nov 2026 09:41:16 -0500 Subject: [PATCH] Add reusable Markdown section updates Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 1 + docs/markdown.md | 30 ++++ docs/workflow/hooks.md | 2 + examples/lifecycle_hooks/release_notes.rb | 10 +- lib/ruby-gem-release/changelog/base.rb | 55 +------ lib/ruby-gem-release/markdown/section.rb | 73 +++++++++ lib/ruby-gem-release/markdown/source.rb | 62 ++++++++ .../ruby_gem_release/markdown/section_spec.rb | 145 ++++++++++++++++++ 8 files changed, 321 insertions(+), 57 deletions(-) create mode 100644 docs/markdown.md create mode 100644 lib/ruby-gem-release/markdown/section.rb create mode 100644 lib/ruby-gem-release/markdown/source.rb create mode 100644 spec/ruby_gem_release/markdown/section_spec.rb 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
\n## Stable Release\n
\n\n" + File.write('README.md', "#{examples}## Stable Release\n\nOld.\n") + + section.replace!('New.') + + expect(File.read('README.md')).to eq("#{examples}## Stable Release\n\nNew.\n") + end + + it 'rejects missing and duplicate real headings without writing' do + ["# Other\n\nOld.\n", "## Stable Release\n\nOld.\n\n## Stable Release\n\nOther.\n"].each do |content| + File.write('README.md', content) + expect { section.validate! }.to raise_error(ArgumentError, /exactly one matching heading/) + expect { section.replace!('New.') }.to raise_error(ArgumentError, /exactly one matching heading/) + expect { section.gsub!('Old.', 'New.') }.to raise_error(ArgumentError, /exactly one matching heading/) + expect(File.read('README.md')).to eq(content) + end + end + + it 'matches the heading literally rather than as a regular expression' do + literal = described_class.new(path: 'README.md', heading: '## Stable [Release]') + File.write('README.md', "## Stable Release\n\nKeep.\n\n## Stable [Release]\n\nOld.\n") + + literal.replace!('New.') + + expect(File.read('README.md')).to eq("## Stable Release\n\nKeep.\n\n## Stable [Release]\n\nNew.\n") + end + + it 'stops at a shallower heading and leaves it unchanged' do + ['# History', "#\tHistory", "History\n======="].each do |following| + File.write('README.md', "## Stable Release\n\nOld.\n\n#{following}\n\nKeep.\n") + section.replace!('New.') + expect(File.read('README.md')).to eq("## Stable Release\n\nNew.\n\n#{following}\n\nKeep.\n") + end + end + + it 'handles the last section with and without a final newline, including an empty body' do + ['## Stable Release', "## Stable Release\n\nOld.", "## Stable Release\n\nOld.\n"].each do |content| + File.write('README.md', content) + section.replace!('New.') + ending = content.end_with?("\n") ? "\n" : '' + expect(File.read('README.md')).to eq("## Stable Release\n\nNew.#{ending}") + section.replace!('') + expect(File.read('README.md')).to eq("## Stable Release\n\n") + end + end + + it 'rejects mixed and unsupported line endings before editing' do + ["## Stable Release\r\n\nOld.\n", "## Stable Release\rOld."].each do |content| + File.binwrite('README.md', content) + expect { section.replace!('New.') }.to raise_error(ArgumentError, /line endings/) + expect { section.gsub!('Old.', 'New.') }.to raise_error(ArgumentError, /line endings/) + expect(File.binread('README.md')).to eq(content) + end + File.write('README.md', "## Stable Release\n\nOld.\n") + expect { section.replace!("New.\nMixed.\r\n") }.to raise_error(ArgumentError, /line endings/) + expect(File.read('README.md')).to eq("## Stable Release\n\nOld.\n") + end +end