markdown-merge 7.0.0 → 7.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/LICENSE.md +13 -0
- data/README.md +673 -0
- data/lib/markdown/merge/backend_support.rb +200 -0
- data/lib/markdown/merge/cleanse/block_spacing.rb +248 -0
- data/lib/markdown/merge/cleanse/code_fence_spacing.rb +294 -0
- data/lib/markdown/merge/cleanse/condensed_link_refs.rb +411 -0
- data/lib/markdown/merge/cleanse/list_marker_duplication.rb +66 -0
- data/lib/markdown/merge/cleanse/templating_corruption.rb +86 -0
- data/lib/markdown/merge/cleanse.rb +44 -0
- data/lib/markdown/merge/code_block_match_refiner.rb +111 -0
- data/lib/markdown/merge/code_block_merger.rb +742 -0
- data/lib/markdown/merge/comment_tracker.rb +42 -0
- data/lib/markdown/merge/conflict_resolver.rb +199 -0
- data/lib/markdown/merge/debug_logger.rb +26 -0
- data/lib/markdown/merge/document_problems.rb +190 -0
- data/lib/markdown/merge/file_aligner.rb +496 -0
- data/lib/markdown/merge/file_analysis.rb +689 -0
- data/lib/markdown/merge/file_analysis_base.rb +766 -0
- data/lib/markdown/merge/freeze_node.rb +93 -0
- data/lib/markdown/merge/gap_line_node.rb +142 -0
- data/lib/markdown/merge/link_definition_formatter.rb +49 -0
- data/lib/markdown/merge/link_definition_node.rb +157 -0
- data/lib/markdown/merge/link_parser.rb +421 -0
- data/lib/markdown/merge/link_reference_rehydrator.rb +320 -0
- data/lib/markdown/merge/list_match_refiner.rb +98 -0
- data/lib/markdown/merge/list_merger.rb +322 -0
- data/lib/markdown/merge/markdown_structure.rb +123 -0
- data/lib/markdown/merge/merge_result.rb +483 -0
- data/lib/markdown/merge/node_type_normalizer.rb +126 -0
- data/lib/markdown/merge/output_builder.rb +248 -0
- data/lib/markdown/merge/partial_template_merger.rb +555 -0
- data/lib/markdown/merge/preservation_support.rb +291 -0
- data/lib/markdown/merge/rspec/shared_examples/source_preserving_provider.rb +338 -0
- data/lib/markdown/merge/smart_merger.rb +269 -0
- data/lib/markdown/merge/smart_merger_base.rb +1490 -0
- data/lib/markdown/merge/source_preserving_provider.rb +814 -0
- data/lib/markdown/merge/table_match_algorithm.rb +499 -0
- data/lib/markdown/merge/table_match_refiner.rb +132 -0
- data/lib/markdown/merge/version.rb +5 -3
- data/lib/markdown/merge/whitespace_normalizer.rb +243 -0
- data/lib/markdown/merge/wrapper_support.rb +194 -0
- data/lib/markdown/merge.rb +271 -87
- data/lib/markdown-merge.rb +7 -1
- data/sig/markdown/merge.rbs +62 -0
- data.tar.gz.sig +0 -0
- metadata +289 -15
- metadata.gz.sig +0 -0
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Markdown
|
|
4
|
+
module Merge
|
|
5
|
+
# Rehydrates inline links and images to use link reference definitions.
|
|
6
|
+
#
|
|
7
|
+
# When markdown is processed through `to_commonmark`, reference-style links
|
|
8
|
+
# `[text][label]` are converted to inline links `[text](url)`.
|
|
9
|
+
# This class reverses that transformation by:
|
|
10
|
+
# 1. Parsing link reference definitions from content using {LinkParser}
|
|
11
|
+
# 2. Finding inline links/images using {LinkParser}'s PEG-based parsing
|
|
12
|
+
# 3. Replacing inline URLs with reference labels where a definition exists
|
|
13
|
+
#
|
|
14
|
+
# Uses Parslet-based parsing for robust handling of:
|
|
15
|
+
# - Emoji in labels (e.g., `[🖼️galtzo-discord]`)
|
|
16
|
+
# - Nested brackets (for linked images like `[![alt][ref]](url)`)
|
|
17
|
+
# - Multi-byte UTF-8 characters
|
|
18
|
+
#
|
|
19
|
+
# @example Standalone usage
|
|
20
|
+
# content = <<~MD
|
|
21
|
+
# Check out [Example](https://example.com) for more info.
|
|
22
|
+
#
|
|
23
|
+
# [example]: https://example.com
|
|
24
|
+
# MD
|
|
25
|
+
# result = LinkReferenceRehydrator.rehydrate(content)
|
|
26
|
+
# # => "Check out [Example][example] for more info.\n\n[example]: https://example.com\n"
|
|
27
|
+
#
|
|
28
|
+
class LinkReferenceRehydrator
|
|
29
|
+
# @return [String] The original content
|
|
30
|
+
attr_reader :content
|
|
31
|
+
|
|
32
|
+
# @return [DocumentProblems] Problems found during rehydration
|
|
33
|
+
attr_reader :problems
|
|
34
|
+
|
|
35
|
+
class << self
|
|
36
|
+
# Rehydrate inline links/images to reference style (class method).
|
|
37
|
+
#
|
|
38
|
+
# @param content [String] Content to rehydrate
|
|
39
|
+
# @return [String] Rehydrated content
|
|
40
|
+
def rehydrate(content)
|
|
41
|
+
new(content).rehydrate
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Initialize a new rehydrator.
|
|
46
|
+
#
|
|
47
|
+
# @param content [String] Content to process
|
|
48
|
+
def initialize(content)
|
|
49
|
+
@content = content
|
|
50
|
+
@problems = DocumentProblems.new
|
|
51
|
+
@link_definitions = nil
|
|
52
|
+
@duplicate_definitions = nil
|
|
53
|
+
@url_to_label = nil
|
|
54
|
+
@parser = LinkParser.new
|
|
55
|
+
@rehydration_count = 0
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Get the map of URLs to their preferred label.
|
|
59
|
+
#
|
|
60
|
+
# @return [Hash<String, String>] URL => label mapping
|
|
61
|
+
def link_definitions
|
|
62
|
+
build_definition_maps unless @link_definitions
|
|
63
|
+
@link_definitions
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Get duplicate definitions (multiple labels for same URL).
|
|
67
|
+
#
|
|
68
|
+
# @return [Hash<String, Array<String>>] URL => [labels] for duplicates only
|
|
69
|
+
def duplicate_definitions
|
|
70
|
+
build_definition_maps unless @duplicate_definitions
|
|
71
|
+
@duplicate_definitions
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Rehydrate inline links and images to use reference definitions.
|
|
75
|
+
#
|
|
76
|
+
# Uses a tree-based approach to handle nested structures like linked images
|
|
77
|
+
# `[](link-url)`. The parser builds a tree of link constructs,
|
|
78
|
+
# and we process them in leaf-first (post-order) traversal to ensure
|
|
79
|
+
# inner replacements are applied before outer ones.
|
|
80
|
+
#
|
|
81
|
+
# For linked images, this means:
|
|
82
|
+
# 1. First, the inner image `` is replaced with `![alt][img-label]`
|
|
83
|
+
# 2. Then, the outer link's text is updated to include the replaced image
|
|
84
|
+
# 3. Finally, the outer link `[![alt][img-label]](link-url)` is replaced with `[![alt][img-label]][link-label]`
|
|
85
|
+
#
|
|
86
|
+
# This is done in a single pass by tracking replacement offsets.
|
|
87
|
+
#
|
|
88
|
+
# @return [String] Rehydrated content
|
|
89
|
+
def rehydrate
|
|
90
|
+
build_definition_maps unless @link_definitions
|
|
91
|
+
record_duplicate_problems
|
|
92
|
+
|
|
93
|
+
return content if @url_to_label.empty?
|
|
94
|
+
|
|
95
|
+
# Use the new tree-based approach
|
|
96
|
+
# 1. Find all link constructs with proper nesting detection
|
|
97
|
+
tree = @parser.find_all_link_constructs(content)
|
|
98
|
+
|
|
99
|
+
# 2. Collect all replacements using recursive tree processing
|
|
100
|
+
# This properly handles nested structures by processing children first
|
|
101
|
+
# and adjusting parent text to include child replacements
|
|
102
|
+
replacements = collect_nested_replacements(tree, content)
|
|
103
|
+
|
|
104
|
+
# 3. Apply replacements in reverse position order
|
|
105
|
+
result = content.dup
|
|
106
|
+
replacements.sort_by { |r| -r[:start_pos] }.each do |replacement|
|
|
107
|
+
result = result[0...replacement[:start_pos]] +
|
|
108
|
+
replacement[:replacement] +
|
|
109
|
+
result[replacement[:end_pos]..]
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
result
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Check if rehydration made any changes.
|
|
116
|
+
#
|
|
117
|
+
# @return [Boolean] true if any links were rehydrated
|
|
118
|
+
def changed?
|
|
119
|
+
@rehydration_count.positive?
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Get count of links/images rehydrated.
|
|
123
|
+
#
|
|
124
|
+
# @return [Integer] Number of rehydrations performed
|
|
125
|
+
attr_reader :rehydration_count
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
# Collect replacements from tree structure, processing children first.
|
|
130
|
+
#
|
|
131
|
+
# This method recursively processes the tree in post-order (children before parents).
|
|
132
|
+
# When a child is replaced, the parent's text is updated to include the child's
|
|
133
|
+
# replacement before the parent is processed.
|
|
134
|
+
#
|
|
135
|
+
# @param items [Array<Hash>] Tree items from find_all_link_constructs
|
|
136
|
+
# @param text [String] The current text (used for extracting updated content)
|
|
137
|
+
# @return [Array<Hash>] Replacements with :start_pos, :end_pos, :replacement
|
|
138
|
+
def collect_nested_replacements(items, text)
|
|
139
|
+
replacements = []
|
|
140
|
+
|
|
141
|
+
items.each do |item|
|
|
142
|
+
if item[:children]&.any?
|
|
143
|
+
# Process children first and collect their replacements
|
|
144
|
+
child_replacements = collect_nested_replacements(item[:children], text)
|
|
145
|
+
|
|
146
|
+
# Try to process the parent with updated text content
|
|
147
|
+
parent_replacement = process_parent_with_children(item, child_replacements)
|
|
148
|
+
|
|
149
|
+
if parent_replacement
|
|
150
|
+
# Parent was successfully processed - use ONLY the parent replacement
|
|
151
|
+
# (it already includes the transformed child content)
|
|
152
|
+
replacements << parent_replacement
|
|
153
|
+
else
|
|
154
|
+
# Parent couldn't be processed (no matching label, has title, etc.)
|
|
155
|
+
# Include the child replacements instead
|
|
156
|
+
replacements.concat(child_replacements)
|
|
157
|
+
end
|
|
158
|
+
else
|
|
159
|
+
# Leaf node - process directly
|
|
160
|
+
replacement = if item[:type] == :image
|
|
161
|
+
process_image(item)
|
|
162
|
+
else
|
|
163
|
+
process_link(item)
|
|
164
|
+
end
|
|
165
|
+
replacements << replacement if replacement
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
replacements
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Process a parent item that has children, accounting for child replacements.
|
|
173
|
+
#
|
|
174
|
+
# For a linked image like `[](link-url)`:
|
|
175
|
+
# 1. The child image was already processed: `` → `![alt][img-label]`
|
|
176
|
+
# 2. We need to build the new parent text: `[![alt][img-label]][link-label]`
|
|
177
|
+
#
|
|
178
|
+
# @param item [Hash] Parent item with :children
|
|
179
|
+
# @param child_replacements [Array<Hash>] Replacements made by children
|
|
180
|
+
# @return [Hash, nil] Replacement for the parent, or nil if not applicable
|
|
181
|
+
def process_parent_with_children(item, child_replacements)
|
|
182
|
+
# Get the label for the parent's URL
|
|
183
|
+
label = @url_to_label[item[:url]]
|
|
184
|
+
return unless label
|
|
185
|
+
|
|
186
|
+
# Check if parent has a title (can't rehydrate if it does)
|
|
187
|
+
if item[:title] && !item[:title].empty?
|
|
188
|
+
@problems.add(
|
|
189
|
+
:link_has_title,
|
|
190
|
+
severity: :info,
|
|
191
|
+
text: item[:text],
|
|
192
|
+
url: item[:url],
|
|
193
|
+
title: item[:title]
|
|
194
|
+
)
|
|
195
|
+
return
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Build the new link text by applying child replacements to the original text
|
|
199
|
+
# Extract the original "text" part of the link (between [ and ])
|
|
200
|
+
original_text = item[:text] || ''
|
|
201
|
+
|
|
202
|
+
# Apply child replacements to build the new text content
|
|
203
|
+
# Children positions are relative to the document, so we need to adjust
|
|
204
|
+
new_text = original_text.dup
|
|
205
|
+
|
|
206
|
+
# Sort child replacements by position (reverse order for safe replacement)
|
|
207
|
+
sorted_children = child_replacements.sort_by { |r| -r[:start_pos] }
|
|
208
|
+
|
|
209
|
+
sorted_children.each do |child_rep|
|
|
210
|
+
# Calculate position relative to the link text start
|
|
211
|
+
# The link text starts at item[:start_pos] + 1 (after the '[')
|
|
212
|
+
text_start = item[:start_pos] + 1
|
|
213
|
+
relative_start = child_rep[:start_pos] - text_start
|
|
214
|
+
relative_end = child_rep[:end_pos] - text_start
|
|
215
|
+
|
|
216
|
+
# Only apply if the child is within the text portion
|
|
217
|
+
if relative_start >= 0 && relative_end <= new_text.length
|
|
218
|
+
new_text = new_text[0...relative_start] + child_rep[:replacement] + new_text[relative_end..]
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
@rehydration_count += 1
|
|
223
|
+
{
|
|
224
|
+
start_pos: item[:start_pos],
|
|
225
|
+
end_pos: item[:end_pos],
|
|
226
|
+
replacement: "[#{new_text}][#{label}]"
|
|
227
|
+
}
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def build_definition_maps
|
|
231
|
+
@link_definitions = {}
|
|
232
|
+
@duplicate_definitions = {}
|
|
233
|
+
@url_to_label = {}
|
|
234
|
+
url_to_all_labels = Hash.new { |h, k| h[k] = [] }
|
|
235
|
+
|
|
236
|
+
definitions = @parser.parse_definitions(content)
|
|
237
|
+
|
|
238
|
+
definitions.each do |defn|
|
|
239
|
+
url_to_all_labels[defn[:url]] << defn[:label]
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
url_to_all_labels.each do |url, labels|
|
|
243
|
+
sorted = labels.sort_by.with_index { |l, i| [l.length, i] }
|
|
244
|
+
best_label = sorted.first
|
|
245
|
+
|
|
246
|
+
@link_definitions[url] = best_label
|
|
247
|
+
@url_to_label[url] = best_label
|
|
248
|
+
|
|
249
|
+
@duplicate_definitions[url] = labels if labels.size > 1
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def record_duplicate_problems
|
|
254
|
+
@duplicate_definitions.each do |url, labels|
|
|
255
|
+
@problems.add(
|
|
256
|
+
:duplicate_link_definition,
|
|
257
|
+
severity: :warning,
|
|
258
|
+
url: url,
|
|
259
|
+
labels: labels,
|
|
260
|
+
selected_label: @url_to_label[url]
|
|
261
|
+
)
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def process_link(link)
|
|
266
|
+
url = link[:url]
|
|
267
|
+
title = link[:title]
|
|
268
|
+
link_text = link[:text]
|
|
269
|
+
|
|
270
|
+
if title && !title.empty?
|
|
271
|
+
@problems.add(
|
|
272
|
+
:link_has_title,
|
|
273
|
+
severity: :info,
|
|
274
|
+
text: link_text,
|
|
275
|
+
url: url,
|
|
276
|
+
title: title
|
|
277
|
+
)
|
|
278
|
+
return
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
label = @url_to_label[url]
|
|
282
|
+
return unless label
|
|
283
|
+
|
|
284
|
+
@rehydration_count += 1
|
|
285
|
+
{
|
|
286
|
+
start_pos: link[:start_pos],
|
|
287
|
+
end_pos: link[:end_pos],
|
|
288
|
+
replacement: "[#{link_text}][#{label}]"
|
|
289
|
+
}
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def process_image(image)
|
|
293
|
+
url = image[:url]
|
|
294
|
+
title = image[:title]
|
|
295
|
+
alt_text = image[:alt]
|
|
296
|
+
|
|
297
|
+
if title && !title.empty?
|
|
298
|
+
@problems.add(
|
|
299
|
+
:image_has_title,
|
|
300
|
+
severity: :info,
|
|
301
|
+
alt: alt_text,
|
|
302
|
+
url: url,
|
|
303
|
+
title: title
|
|
304
|
+
)
|
|
305
|
+
return
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
label = @url_to_label[url]
|
|
309
|
+
return unless label
|
|
310
|
+
|
|
311
|
+
@rehydration_count += 1
|
|
312
|
+
{
|
|
313
|
+
start_pos: image[:start_pos],
|
|
314
|
+
end_pos: image[:end_pos],
|
|
315
|
+
replacement: "![#{alt_text}][#{label}]"
|
|
316
|
+
}
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Markdown
|
|
4
|
+
module Merge
|
|
5
|
+
# Fuzzy matches markdown list nodes by item-token overlap so inner list merging
|
|
6
|
+
# can repair previously-corrupted lists that no longer share an exact signature.
|
|
7
|
+
class ListMatchRefiner < Ast::Merge::MatchRefinerBase
|
|
8
|
+
include Ast::Merge::JaccardSimilarity
|
|
9
|
+
|
|
10
|
+
DEFAULT_THRESHOLD = 0.45
|
|
11
|
+
|
|
12
|
+
def initialize(threshold: DEFAULT_THRESHOLD, **options)
|
|
13
|
+
super(threshold: threshold, node_types: [:list], **options)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call(template_nodes, dest_nodes, context = {})
|
|
17
|
+
template_lists = template_nodes.select { |node| node_type(node).to_s == 'list' }
|
|
18
|
+
dest_lists = dest_nodes.select { |node| node_type(node).to_s == 'list' }
|
|
19
|
+
return [] if template_lists.empty? || dest_lists.empty?
|
|
20
|
+
|
|
21
|
+
greedy_match(template_lists, dest_lists) do |template_node, dest_node|
|
|
22
|
+
compute_similarity(template_node, dest_node, context)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def compute_similarity(template_node, dest_node, context)
|
|
29
|
+
template_items = list_item_anchors(template_node)
|
|
30
|
+
dest_items = list_item_anchors(dest_node)
|
|
31
|
+
return 0.0 if template_items.empty? || dest_items.empty?
|
|
32
|
+
|
|
33
|
+
containment = template_item_containment(template_items, dest_items)
|
|
34
|
+
token_overlap = jaccard(list_tokens(template_node), list_tokens(dest_node))
|
|
35
|
+
first_item_score = template_items.first == dest_items.first ? 1.0 : 0.0
|
|
36
|
+
context_score = context_similarity(
|
|
37
|
+
template_node,
|
|
38
|
+
context[:template_analysis],
|
|
39
|
+
dest_node,
|
|
40
|
+
context[:dest_analysis]
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
(containment * 0.35) + (token_overlap * 0.35) + (context_score * 0.2) + (first_item_score * 0.1)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def list_item_anchors(list_node)
|
|
47
|
+
raw = Ast::Merge::NodeTyping.unwrap(list_node)
|
|
48
|
+
|
|
49
|
+
raw.each_with_object([]) do |child, anchors|
|
|
50
|
+
next unless child.respond_to?(:type) && %w[list_item item].include?(child.type.to_s)
|
|
51
|
+
|
|
52
|
+
anchors << normalize_anchor(child.text.to_s)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def list_tokens(list_node)
|
|
57
|
+
extract_tokens(list_node.text.to_s)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def template_item_containment(template_items, dest_items)
|
|
61
|
+
template_set = template_items.to_set
|
|
62
|
+
dest_set = dest_items.to_set
|
|
63
|
+
return 0.0 if template_set.empty?
|
|
64
|
+
|
|
65
|
+
(template_set & dest_set).size.to_f / template_set.size
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def context_similarity(template_node, template_analysis, dest_node, dest_analysis)
|
|
69
|
+
template_context = preceding_context_text(template_node, template_analysis)
|
|
70
|
+
dest_context = preceding_context_text(dest_node, dest_analysis)
|
|
71
|
+
return 0.0 if template_context.empty? || dest_context.empty?
|
|
72
|
+
|
|
73
|
+
jaccard(extract_tokens(template_context), extract_tokens(dest_context))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def preceding_context_text(node, analysis)
|
|
77
|
+
return '' unless analysis
|
|
78
|
+
|
|
79
|
+
index = analysis.statements.index(node)
|
|
80
|
+
return '' unless index
|
|
81
|
+
|
|
82
|
+
(index - 1).downto(0) do |current_index|
|
|
83
|
+
candidate = analysis.statements[current_index]
|
|
84
|
+
signature = analysis.signature_at(current_index)
|
|
85
|
+
next unless signature.is_a?(Array) && %i[heading paragraph code_block].include?(signature.first)
|
|
86
|
+
|
|
87
|
+
return candidate.text.to_s
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
''
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def normalize_anchor(text)
|
|
94
|
+
text.to_s.strip.gsub(/\s+/, ' ').downcase
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|