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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/LICENSE.md +13 -0
  4. data/README.md +673 -0
  5. data/lib/markdown/merge/backend_support.rb +200 -0
  6. data/lib/markdown/merge/cleanse/block_spacing.rb +248 -0
  7. data/lib/markdown/merge/cleanse/code_fence_spacing.rb +294 -0
  8. data/lib/markdown/merge/cleanse/condensed_link_refs.rb +411 -0
  9. data/lib/markdown/merge/cleanse/list_marker_duplication.rb +66 -0
  10. data/lib/markdown/merge/cleanse/templating_corruption.rb +86 -0
  11. data/lib/markdown/merge/cleanse.rb +44 -0
  12. data/lib/markdown/merge/code_block_match_refiner.rb +111 -0
  13. data/lib/markdown/merge/code_block_merger.rb +742 -0
  14. data/lib/markdown/merge/comment_tracker.rb +42 -0
  15. data/lib/markdown/merge/conflict_resolver.rb +199 -0
  16. data/lib/markdown/merge/debug_logger.rb +26 -0
  17. data/lib/markdown/merge/document_problems.rb +190 -0
  18. data/lib/markdown/merge/file_aligner.rb +496 -0
  19. data/lib/markdown/merge/file_analysis.rb +689 -0
  20. data/lib/markdown/merge/file_analysis_base.rb +766 -0
  21. data/lib/markdown/merge/freeze_node.rb +93 -0
  22. data/lib/markdown/merge/gap_line_node.rb +142 -0
  23. data/lib/markdown/merge/link_definition_formatter.rb +49 -0
  24. data/lib/markdown/merge/link_definition_node.rb +157 -0
  25. data/lib/markdown/merge/link_parser.rb +421 -0
  26. data/lib/markdown/merge/link_reference_rehydrator.rb +320 -0
  27. data/lib/markdown/merge/list_match_refiner.rb +98 -0
  28. data/lib/markdown/merge/list_merger.rb +322 -0
  29. data/lib/markdown/merge/markdown_structure.rb +123 -0
  30. data/lib/markdown/merge/merge_result.rb +483 -0
  31. data/lib/markdown/merge/node_type_normalizer.rb +126 -0
  32. data/lib/markdown/merge/output_builder.rb +248 -0
  33. data/lib/markdown/merge/partial_template_merger.rb +555 -0
  34. data/lib/markdown/merge/preservation_support.rb +291 -0
  35. data/lib/markdown/merge/rspec/shared_examples/source_preserving_provider.rb +338 -0
  36. data/lib/markdown/merge/smart_merger.rb +269 -0
  37. data/lib/markdown/merge/smart_merger_base.rb +1490 -0
  38. data/lib/markdown/merge/source_preserving_provider.rb +814 -0
  39. data/lib/markdown/merge/table_match_algorithm.rb +499 -0
  40. data/lib/markdown/merge/table_match_refiner.rb +132 -0
  41. data/lib/markdown/merge/version.rb +5 -3
  42. data/lib/markdown/merge/whitespace_normalizer.rb +243 -0
  43. data/lib/markdown/merge/wrapper_support.rb +194 -0
  44. data/lib/markdown/merge.rb +271 -87
  45. data/lib/markdown-merge.rb +7 -1
  46. data/sig/markdown/merge.rbs +62 -0
  47. data.tar.gz.sig +0 -0
  48. metadata +289 -15
  49. 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
+ # `[![alt](img-url)](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 `![alt](img-url)` 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 `[![alt](img-url)](link-url)`:
175
+ # 1. The child image was already processed: `![alt](img-url)` → `![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