Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
30 changes: 30 additions & 0 deletions docs/markdown.md
Original file line number Diff line number Diff line change
@@ -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).
2 changes: 2 additions & 0 deletions docs/workflow/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 5 additions & 5 deletions examples/lifecycle_hooks/release_notes.rb
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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
55 changes: 3 additions & 52 deletions lib/ruby-gem-release/changelog/base.rb
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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|
Expand All @@ -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?(/(?<!\r)\n/) && content.include?("\r\n")) || content.match?(/\r(?!\n)/)

content.include?("\r\n") ? "\r\n" : "\n"
end

def validate_version(version)
raise ArgumentError, "invalid version: #{version.inspect}" unless version.is_a?(String) && /\A(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)\z/.match?(version)
end
Expand Down
73 changes: 73 additions & 0 deletions lib/ruby-gem-release/markdown/section.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# frozen_string_literal: true

require_relative 'source'

module RubyGemRelease
module Markdown
class Section
include Source

def initialize(path:, heading:)
raise ArgumentError, 'heading must be a complete Markdown heading line' unless heading.is_a?(String) && /\A\#{1,6}[ \t]+\S[^\r\n]*\z/.match?(heading)

@path = path
@pattern = /\A#{Regexp.escape(heading)}\z/
end

def validate!
read_section
self
end

def replace!(body)
raise ArgumentError, 'section body must be a String' unless body.is_a?(String)

content, start, finish, separator = read_section
replacement = replacement_body(body, content, finish, separator)
File.write(@path, content[0...start] + replacement + content[finish..])
end

def gsub!(pattern, replacement)
content, start, finish, separator = read_section
body = content[start...finish]
return unless body.gsub!(pattern, replacement)

body = substitution_body(body, separator, following: finish < content.length)
File.write(@path, content[0...start] + body + content[finish..])
end

private

def read_section
content = File.read(@path)
separator = newline(content)
position = heading(content, @pattern)
finish = section_end_position(content, position, heading_prefix(content, position).length)
start = content.index(separator, position) || content.length
[content, start, finish, separator]
end

def replacement_body(body, content, finish, separator)
newline(body)
text = body.gsub(/\r?\n/, separator).sub(/\A(?:\r?\n)+/, '').sub(/(?:\r?\n)+\z/, '')
return separator * 2 if text.empty?

"#{separator}#{separator}#{text}#{trailing_newlines(content, finish, separator)}"
end

def substitution_body(body, separator, following:)
text = body.gsub(/\r?\n/, separator)
newline(text)
text.prepend(separator) unless text.empty? || text.start_with?(separator)
text.concat(separator) if following && !text.end_with?(separator)
text
end

def trailing_newlines(content, finish, separator)
return separator * 2 if finish < content.length

content.end_with?(separator) ? separator : ''
end
end
end
end
62 changes: 62 additions & 0 deletions lib/ruby-gem-release/markdown/source.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# frozen_string_literal: true

require 'commonmarker'

module RubyGemRelease
module Markdown
module Source
private

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/, 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 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?(/(?<!\r)\n/) && content.include?("\r\n")) || content.match?(/\r(?!\n)/)

content.include?("\r\n") ? "\r\n" : "\n"
end
end
end
end
Loading
Loading