diff --git a/lib/rdoc/markup/to_html.rb b/lib/rdoc/markup/to_html.rb
index 2924b89b94..6d9899000e 100644
--- a/lib/rdoc/markup/to_html.rb
+++ b/lib/rdoc/markup/to_html.rb
@@ -282,6 +282,7 @@ def start_accepting
@res = []
@in_list_entry = []
@list = []
+ @heading_ids = {}
end
##
@@ -412,8 +413,8 @@ def accept_blank_line(blank_line)
def accept_heading(heading)
level = [6, heading.level].min
- label = heading.label @code_object
- legacy_label = heading.legacy_label @code_object
+ label = deduplicate_heading_id(heading.label(@code_object))
+ legacy_label = deduplicate_heading_id(heading.legacy_label(@code_object))
# Add legacy anchor before the heading for backward compatibility.
# This allows old links with label- prefix to still work.
@@ -468,6 +469,20 @@ def accept_table(header, body, aligns)
# :section: Utilities
+ ##
+ # Returns a unique heading ID, appending -1, -2, etc. for duplicates.
+ # Matches GitHub's behavior for duplicate heading anchors.
+
+ def deduplicate_heading_id(id)
+ if @heading_ids.key?(id)
+ @heading_ids[id] += 1
+ "#{id}-#{@heading_ids[id]}"
+ else
+ @heading_ids[id] = 0
+ id
+ end
+ end
+
##
# CGI-escapes +text+
diff --git a/lib/rdoc/markup/to_html_crossref.rb b/lib/rdoc/markup/to_html_crossref.rb
index fad87a5802..089f7fea8f 100644
--- a/lib/rdoc/markup/to_html_crossref.rb
+++ b/lib/rdoc/markup/to_html_crossref.rb
@@ -61,8 +61,11 @@ def cross_reference(name, text = nil, code = true, rdoc_ref: false)
name = name[1..-1] unless @show_hash if name[0, 1] == '#'
- if !(name.end_with?('+@', '-@')) and name =~ /(.*[^#:])?@/
- text ||= [CGI.unescape($'), (" at #{$1}" if $~.begin(1))].join("")
+ if !name.end_with?('+@', '-@') && match = name.match(/(.*[^#:])?@(.*)/)
+ context_name = match[1]
+ label = RDoc::Text.decode_legacy_label(match[2])
+ text ||= "#{label} at #{context_name}" if context_name
+ text ||= label
code = false
else
text ||= name
@@ -168,9 +171,10 @@ def link(name, text, code = true, rdoc_ref: false)
end
if label
- # Convert label to GitHub-style anchor format
- # First convert + to space (URL encoding), then apply GitHub-style rules
- formatted_label = RDoc::Text.to_anchor(label.tr('+', ' '))
+ # Decode legacy labels (e.g., "What-27s+Here" -> "What's Here")
+ # then convert to GitHub-style anchor format
+ decoded_label = RDoc::Text.decode_legacy_label(label)
+ formatted_label = RDoc::Text.to_anchor(decoded_label)
# Case 1: Path already has an anchor (e.g., method link)
# Input: C1#method@label -> path="C1.html#method-i-m"
@@ -181,7 +185,7 @@ def link(name, text, code = true, rdoc_ref: false)
# Case 2: Label matches a section title
# Input: C1@Section -> path="C1.html", section "Section" exists
# Output: C1.html#section (uses section.aref for GitHub-style)
- elsif (section = ref&.sections&.find { |s| label.tr('+', ' ') == s.title })
+ elsif (section = ref&.sections&.find { |s| decoded_label == s.title })
path << "##{section.aref}"
# Case 3: Ref has an aref (class/module context)
diff --git a/lib/rdoc/text.rb b/lib/rdoc/text.rb
index 94c84037c8..a7cca6e38d 100644
--- a/lib/rdoc/text.rb
+++ b/lib/rdoc/text.rb
@@ -335,4 +335,27 @@ def wrap(txt, line_len = 76)
text.downcase.gsub(/[^a-z0-9 \-]/, '').gsub(' ', '-')
end
+ ##
+ # Decodes a label that may be in legacy RDoc format where CGI.escape was
+ # applied and then '%' was replaced with '-'. Converts '+' to space,
+ # then reverses -XX hex encoding for non-alphanumeric characters.
+ #
+ # Labels in new format pass through unchanged because -XX patterns that
+ # decode to alphanumeric characters are left as-is (CGI.escape never
+ # encodes alphanumerics).
+ #
+ # Examples:
+ # "What-27s+Here" -> "What's Here" (legacy: -27 is apostrophe)
+ # "Foo-3A-3ABar" -> "Foo::Bar" (legacy: -3A is colon)
+ # "Whats-Here" -> "Whats-Here" (new format, unchanged)
+
+ module_function def decode_legacy_label(label)
+ label = label.tr('+', ' ')
+ label.gsub!(/-([0-7][0-9A-F])/) do
+ char = [$1.hex].pack('C')
+ char.match?(/[a-zA-Z0-9]/) ? $& : char
+ end
+ label
+ end
+
end
diff --git a/test/rdoc/markup/to_html_crossref_test.rb b/test/rdoc/markup/to_html_crossref_test.rb
index ce69aa50db..15a1d26913 100644
--- a/test/rdoc/markup/to_html_crossref_test.rb
+++ b/test/rdoc/markup/to_html_crossref_test.rb
@@ -111,6 +111,23 @@ def test_convert_CROSSREF_section_with_spaces
assert_equal para("Public Methods at C1"), result
end
+ def test_convert_CROSSREF_legacy_label
+ result = @to.convert 'C1@What-27s+Here'
+ assert_equal para("What\u2019s Here at C1"), result
+ end
+
+ def test_convert_CROSSREF_legacy_label_colon
+ result = @to.convert 'C1@Foo-3A-3ABar'
+ assert_equal para("Foo::Bar at C1"), result
+ end
+
+ def test_convert_CROSSREF_legacy_section
+ @c1.add_section "What's Here"
+
+ result = @to.convert "C1@What-27s+Here"
+ assert_equal para("What\u2019s Here at C1"), result
+ end
+
def test_convert_CROSSREF_constant
result = @to.convert 'C1::CONST'
diff --git a/test/rdoc/markup/to_html_test.rb b/test/rdoc/markup/to_html_test.rb
index bb57e78e86..4e9ac38176 100644
--- a/test/rdoc/markup/to_html_test.rb
+++ b/test/rdoc/markup/to_html_test.rb
@@ -360,6 +360,56 @@ def test_accept_heading_pipe
assert_equal "\n