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,689 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+
5
+ module Markdown
6
+ module Merge
7
+ # File analysis for Markdown files using tree_haver backends.
8
+ #
9
+ # Extends FileAnalysisBase with backend-agnostic parsing via tree_haver.
10
+ # Supports both Commonmarker and Markly backends through tree_haver's
11
+ # unified API.
12
+ #
13
+ # Parses Markdown source code and extracts:
14
+ # - Top-level block elements (headings, paragraphs, lists, code blocks, etc.)
15
+ # - Freeze blocks marked with HTML comments
16
+ # - Structural signatures for matching elements between files
17
+ #
18
+ # All nodes are wrapped with canonical types via NodeTypeNormalizer,
19
+ # enabling portable merge rules across backends.
20
+ #
21
+ # Freeze blocks are marked with HTML comments:
22
+ # <!-- markdown-merge:freeze -->
23
+ # ... content to preserve ...
24
+ # <!-- markdown-merge:unfreeze -->
25
+ #
26
+ # @example Basic usage with auto backend
27
+ # analysis = FileAnalysis.new(markdown_source)
28
+ # analysis.statements.each do |node|
29
+ # puts "#{node.merge_type}: #{node.type}"
30
+ # end
31
+ #
32
+ # @example With specific backend
33
+ # analysis = FileAnalysis.new(markdown_source, backend: :markly)
34
+ #
35
+ # @example With custom freeze token
36
+ # analysis = FileAnalysis.new(source, freeze_token: "my-merge")
37
+ # # Looks for: <!-- my-merge:freeze --> / <!-- my-merge:unfreeze -->
38
+ #
39
+ # @see FileAnalysisBase Base class
40
+ # @see NodeTypeNormalizer Type normalization
41
+ class FileAnalysis < FileAnalysisBase
42
+ # Default freeze token for identifying freeze blocks
43
+ # @return [String]
44
+ DEFAULT_FREEZE_TOKEN = 'markdown-merge'
45
+
46
+ class << self
47
+ def default_backend
48
+ :auto
49
+ end
50
+
51
+ def default_freeze_token
52
+ self::DEFAULT_FREEZE_TOKEN
53
+ end
54
+
55
+ def default_parser_options
56
+ {}
57
+ end
58
+
59
+ def default_freeze_node_class
60
+ Markdown::Merge::FreezeNode
61
+ end
62
+ end
63
+
64
+ # @return [Symbol] The backend being used (:commonmarker, :markly, :kramdown)
65
+ attr_reader :backend
66
+
67
+ # @return [Hash] Parser-specific options
68
+ attr_reader :parser_options
69
+
70
+ Location = Struct.new(:start_line, :end_line, keyword_init: true)
71
+ HeadingSectionOwner = Struct.new(:location, :heading_text, :heading_source, :level, :base, keyword_init: true)
72
+ LinkDefinitionOwner = Struct.new(:location, :label, :url, :title, :source, keyword_init: true)
73
+ HtmlCommentOwner = Struct.new(:location, :text, :source, keyword_init: true)
74
+ ListItemOwner = Struct.new(:location, :source, :text, :depth, :marker, keyword_init: true)
75
+ InlineReferenceOwner = Struct.new(
76
+ :location,
77
+ :line,
78
+ :start_column,
79
+ :end_column,
80
+ :source,
81
+ :reference_kind,
82
+ :label,
83
+ :labels,
84
+ keyword_init: true
85
+ )
86
+ TableRowOwner = Struct.new(:location, :source, :text, keyword_init: true)
87
+
88
+ # Initialize file analysis with tree_haver backend.
89
+ #
90
+ # @param source [String] Markdown source code to analyze
91
+ # @param backend [Symbol] Backend to use (:commonmarker, :markly, :kramdown, :auto)
92
+ # @param freeze_token [String] Token for freeze block markers
93
+ # @param signature_generator [Proc, nil] Custom signature generator
94
+ # @param parser_options [Hash] Backend-specific parser options
95
+ # For commonmarker: { options: {} }
96
+ # For markly: { flags: Markly::DEFAULT, extensions: [:table] }
97
+ def initialize(
98
+ source,
99
+ backend: self.class.default_backend,
100
+ freeze_token: self.class.default_freeze_token,
101
+ signature_generator: nil,
102
+ **parser_options
103
+ )
104
+ @requested_backend = backend
105
+ @parser_options = self.class.default_parser_options.merge(parser_options)
106
+
107
+ # Resolve and initialize the backend
108
+ @backend = resolve_backend(backend)
109
+ @parser = create_parser
110
+
111
+ super(source, freeze_token: freeze_token, signature_generator: signature_generator)
112
+ end
113
+
114
+ # Parse the source document using tree_haver backend.
115
+ #
116
+ # Error handling follows the same pattern as other *-merge gems:
117
+ # - TreeHaver::Error (which inherits from Exception, not StandardError) is caught
118
+ # - TreeHaver::NotAvailable is a subclass of TreeHaver::Error, so it's also caught
119
+ # - When an error occurs, the error is stored in @errors and nil is returned
120
+ # - SmartMergerBase#parse_and_analyze checks valid? and raises the appropriate parse error
121
+ #
122
+ # @param source [String] Markdown source to parse
123
+ # @return [Object, nil] Root document node from tree_haver, or nil on error
124
+ def parse_document(source)
125
+ tree = @parser.parse(source)
126
+ tree.root_node
127
+ rescue TreeHaver::Error => e
128
+ # TreeHaver::Error inherits from Exception, not StandardError.
129
+ # This also catches TreeHaver::NotAvailable (subclass of Error).
130
+ @errors << e.message
131
+ nil
132
+ end
133
+
134
+ # Get the next sibling of a node.
135
+ #
136
+ # Handles differences between backends:
137
+ # - Commonmarker: node.next_sibling
138
+ # - Markly: node.next
139
+ #
140
+ # @param node [Object] Current node
141
+ # @return [Object, nil] Next sibling or nil
142
+ def next_sibling(node)
143
+ # tree_haver normalizes this, but handle both patterns for safety
144
+ if node.respond_to?(:next_sibling)
145
+ node.next_sibling
146
+ elsif node.respond_to?(:next)
147
+ node.next
148
+ end
149
+ end
150
+
151
+ # Returns the FreezeNode class to use.
152
+ #
153
+ # @return [Class] Markdown::Merge::FreezeNode
154
+ def freeze_node_class
155
+ self.class.default_freeze_node_class
156
+ end
157
+
158
+ # Check if value is a tree_haver node.
159
+ #
160
+ # @param value [Object] Value to check
161
+ # @return [Boolean] true if this is a parser node
162
+ def parser_node?(value)
163
+ # Check for tree_haver node or wrapped node
164
+ return true if value.respond_to?(:type) && value.respond_to?(:source_position)
165
+ return true if Ast::Merge::NodeTyping.typed_node?(value)
166
+
167
+ false
168
+ end
169
+
170
+ # Override to detect tree_haver nodes for signature generator fallthrough
171
+ # @param value [Object] The value to check
172
+ # @return [Boolean] true if this is a fallthrough node
173
+ def fallthrough_node?(value)
174
+ Ast::Merge::NodeTyping.typed_node?(value) ||
175
+ value.is_a?(Ast::Merge::FreezeNodeBase) ||
176
+ parser_node?(value) ||
177
+ super
178
+ end
179
+
180
+ # Compute signature for a tree_haver node.
181
+ #
182
+ # Uses canonical types from NodeTypeNormalizer for portable signatures.
183
+ #
184
+ # @param node [Object] The node (may be wrapped)
185
+ # @return [Array, nil] Signature array
186
+ def compute_parser_signature(node)
187
+ # Get canonical type from wrapper or normalize raw type
188
+ canonical_type = if Ast::Merge::NodeTyping.typed_node?(node)
189
+ Ast::Merge::NodeTyping.merge_type_for(node)
190
+ else
191
+ NodeTypeNormalizer.canonical_type(node.type, @backend)
192
+ end
193
+
194
+ # Unwrap to access underlying node methods
195
+ raw_node = Ast::Merge::NodeTyping.unwrap(node)
196
+
197
+ case canonical_type
198
+ when :heading
199
+ level = raw_node.header_level
200
+ # H1 is the document title — treat as a singleton (see FileAnalysisBase for rationale)
201
+ return [:heading, 1] if level == 1
202
+
203
+ [:heading, level, extract_text_content(raw_node)]
204
+ when :paragraph
205
+ # Content-based: Match paragraphs by content hash (first 32 chars of digest)
206
+ text = extract_text_content(raw_node)
207
+ [:paragraph, Digest::SHA256.hexdigest(text)[0, 32]]
208
+ when :code_block
209
+ # Content-based: Match code blocks by fence info and content hash
210
+ content = safe_string_content(raw_node)
211
+ fence_info = raw_node.respond_to?(:fence_info) ? raw_node.fence_info : nil
212
+ [:code_block, fence_info, Digest::SHA256.hexdigest(content)[0, 16]]
213
+ when :list
214
+ # Structure-based: Match lists by type and item count (content may differ)
215
+ list_type = raw_node.respond_to?(:list_type) ? raw_node.list_type : nil
216
+ [:list, list_type, count_children(raw_node)]
217
+ when :block_quote
218
+ # Content-based: Match block quotes by content hash
219
+ text = extract_text_content(raw_node)
220
+ [:block_quote, Digest::SHA256.hexdigest(text)[0, 16]]
221
+ when :thematic_break
222
+ # Structure-based: All thematic breaks are equivalent
223
+ [:thematic_break]
224
+ when :html_block
225
+ # Content-based: Match HTML blocks by content hash
226
+ content = safe_string_content(raw_node)
227
+ [:html_block, Digest::SHA256.hexdigest(content)[0, 16]]
228
+ when :table
229
+ # Content-based: Match tables by structure and header content
230
+ header_content = extract_table_header_content(raw_node)
231
+ [:table, count_children(raw_node), Digest::SHA256.hexdigest(header_content)[0, 16]]
232
+ when :footnote_definition
233
+ # Name/label-based: Match footnotes by name or label
234
+ label = raw_node.respond_to?(:name) ? raw_node.name : safe_string_content(raw_node)
235
+ [:footnote_definition, label]
236
+ when :custom_block
237
+ # Content-based: Match custom blocks by content hash
238
+ text = extract_text_content(raw_node)
239
+ [:custom_block, Digest::SHA256.hexdigest(text)[0, 16]]
240
+ else
241
+ # Unknown type - use canonical type and position
242
+ pos = raw_node.source_position
243
+ [:unknown, canonical_type, pos&.dig(:start_line)]
244
+ end
245
+ end
246
+
247
+ # Extract all text content from a node and its children.
248
+ #
249
+ # Override for tree_haver nodes which don't have a `walk` method.
250
+ # Uses recursive traversal via `children` instead.
251
+ #
252
+ # @param node [Object] The node
253
+ # @return [String] Concatenated text content
254
+ def extract_text_content(node)
255
+ text_parts = []
256
+ collect_text_recursive(node, text_parts)
257
+ text_parts.join
258
+ end
259
+
260
+ # Safely get string content from a node.
261
+ #
262
+ # Override for tree_haver nodes which use `text` instead of `string_content`.
263
+ #
264
+ # @param node [Object] The node
265
+ # @return [String] String content or empty string
266
+ def safe_string_content(node)
267
+ if node.respond_to?(:string_content)
268
+ node.string_content.to_s
269
+ elsif node.respond_to?(:text)
270
+ node.text.to_s
271
+ else
272
+ extract_text_content(node)
273
+ end
274
+ rescue TypeError, NoMethodError
275
+ extract_text_content(node)
276
+ end
277
+
278
+ # Collect top-level nodes from document, wrapping with canonical types.
279
+ #
280
+ # @return [Array<Object>] Wrapped nodes
281
+ def collect_top_level_nodes
282
+ nodes = []
283
+ child = @document.first_child
284
+ while child
285
+ # Wrap each node with its canonical type
286
+ wrapped = NodeTypeNormalizer.wrap(child, @backend)
287
+ nodes << wrapped
288
+ child = next_sibling(child)
289
+ end
290
+ nodes
291
+ end
292
+
293
+ def heading_section_owners
294
+ headings = Array(statements).filter_map do |statement|
295
+ next unless heading_statement?(statement)
296
+
297
+ build_heading_owner(statement)
298
+ end
299
+
300
+ headings.each_with_index.map do |owner, index|
301
+ branch_end_line = branch_end_line(headings, index)
302
+ HeadingSectionOwner.new(
303
+ location: Location.new(start_line: owner.location.start_line, end_line: branch_end_line),
304
+ heading_text: owner.heading_text,
305
+ heading_source: owner.heading_source,
306
+ level: owner.level,
307
+ base: owner.base
308
+ )
309
+ end
310
+ end
311
+
312
+ def link_definition_owners
313
+ Array(statements).filter_map do |statement|
314
+ next unless statement.respond_to?(:merge_type) && statement.merge_type == :link_definition
315
+
316
+ position = statement.source_position
317
+ next unless position
318
+
319
+ LinkDefinitionOwner.new(
320
+ location: Location.new(start_line: position[:start_line], end_line: position[:end_line]),
321
+ label: statement.label,
322
+ url: statement.url,
323
+ title: statement.title,
324
+ source: if statement.respond_to?(:content)
325
+ statement.content
326
+ else
327
+ source_range(position[:start_line], position[:end_line]).chomp
328
+ end
329
+ )
330
+ end
331
+ end
332
+
333
+ def html_comment_owners
334
+ comment_tracker.comment_nodes.map do |comment|
335
+ HtmlCommentOwner.new(
336
+ location: Location.new(start_line: comment.location.start_line, end_line: comment.location.end_line),
337
+ text: comment.content,
338
+ source: comment.text
339
+ )
340
+ end
341
+ end
342
+
343
+ def list_item_owners
344
+ Array(statements).flat_map do |statement|
345
+ collect_list_item_owners(unwrap_markdown_statement(statement), depth: 0)
346
+ end.sort_by { |owner| [owner.location.start_line, owner.location.end_line] }
347
+ end
348
+
349
+ def inline_reference_owners
350
+ source.to_s.lines.each_with_index.flat_map do |line, index|
351
+ inline_references_for_line(line.chomp, index + 1)
352
+ end
353
+ end
354
+
355
+ def table_row_owners
356
+ ast_table_lines = {}
357
+ ast_rows = Array(statements).flat_map do |statement|
358
+ node = unwrap_markdown_statement(statement)
359
+ next [] unless node.respond_to?(:type) && node.type.to_s == 'table'
360
+
361
+ table_position = node.source_position
362
+ if table_position
363
+ (table_position[:start_line]..table_position[:end_line]).each do |line|
364
+ ast_table_lines[line] = true
365
+ end
366
+ end
367
+ Array(node.children).filter_map do |child|
368
+ next unless child.respond_to?(:type) && child.type.to_s == 'table_row'
369
+
370
+ position = child.source_position
371
+ next unless position
372
+
373
+ TableRowOwner.new(
374
+ location: Location.new(start_line: position[:start_line], end_line: position[:end_line]),
375
+ source: source_range(position[:start_line], position[:end_line]),
376
+ text: extract_text_content(child)
377
+ )
378
+ end
379
+ end
380
+ ast_lines = ast_rows.map { |owner| owner.location.start_line }.to_h { |line| [line, true] }
381
+ loose_rows = source.to_s.lines.each_with_index.filter_map do |line, index|
382
+ line_number = index + 1
383
+ next if ast_lines[line_number]
384
+ next if ast_table_lines[line_number]
385
+ next unless loose_table_row_line?(line)
386
+
387
+ TableRowOwner.new(
388
+ location: Location.new(start_line: line_number, end_line: line_number),
389
+ source: line,
390
+ text: line
391
+ )
392
+ end
393
+ ast_rows + loose_rows
394
+ end
395
+
396
+ private
397
+
398
+ def loose_table_row_line?(line)
399
+ stripped = line.to_s.lstrip
400
+ return false unless stripped.start_with?('|')
401
+
402
+ stripped.include?(' |') || stripped.include?('| ')
403
+ end
404
+
405
+ def collect_list_item_owners(node, depth:)
406
+ return [] unless node.respond_to?(:type)
407
+
408
+ canonical_type = NodeTypeNormalizer.canonical_type(node.type, @backend)
409
+ next_depth = %i[list].include?(canonical_type) ? depth + 1 : depth
410
+ owners = []
411
+ if canonical_type == :list_item
412
+ position = node.source_position if node.respond_to?(:source_position)
413
+ if position
414
+ source_text = source_range(position[:start_line], position[:end_line])
415
+ owners << ListItemOwner.new(
416
+ location: Location.new(start_line: position[:start_line], end_line: position[:end_line]),
417
+ source: source_text,
418
+ text: source_text,
419
+ depth: depth,
420
+ marker: list_item_marker(source_text)
421
+ )
422
+ end
423
+ end
424
+
425
+ Array(node.children).each do |child|
426
+ owners.concat(collect_list_item_owners(child, depth: next_depth))
427
+ end
428
+ owners
429
+ end
430
+
431
+ def list_item_marker(source_text)
432
+ stripped = source_text.to_s.lines.first.to_s.lstrip
433
+ return stripped[0, 2].strip if stripped.start_with?('- ', '* ')
434
+
435
+ marker = stripped.split(' ', 2).first.to_s
436
+ marker.end_with?('.') ? marker : nil
437
+ end
438
+
439
+ def heading_statement?(statement)
440
+ merge_type = if statement.respond_to?(:merge_type)
441
+ statement.merge_type
442
+ else
443
+ unwrap_markdown_statement(statement)&.type
444
+ end
445
+
446
+ %w[heading header].include?(merge_type.to_s)
447
+ end
448
+
449
+ def build_heading_owner(statement)
450
+ node = unwrap_markdown_statement(statement)
451
+ position = node&.source_position
452
+ return unless node && position
453
+
454
+ heading_source = source_range(position[:start_line], position[:end_line]).sub(/\n\z/, '')
455
+ heading_text = node.to_plaintext.to_s.sub(/\n+\z/, '')
456
+ HeadingSectionOwner.new(
457
+ location: Location.new(start_line: position[:start_line], end_line: position[:end_line]),
458
+ heading_text: heading_text,
459
+ heading_source: heading_source,
460
+ level: node.header_level,
461
+ base: normalize_heading_base(heading_text)
462
+ )
463
+ rescue StandardError
464
+ nil
465
+ end
466
+
467
+ def branch_end_line(headings, index)
468
+ current = headings[index]
469
+ cursor = index + 1
470
+ while cursor < headings.length
471
+ return headings[cursor].location.start_line - 1 if headings[cursor].level <= current.level
472
+
473
+ cursor += 1
474
+ end
475
+
476
+ source.to_s.lines.length
477
+ end
478
+
479
+ def unwrap_markdown_statement(statement)
480
+ Ast::Merge::NodeTyping.unwrap(statement)
481
+ rescue StandardError
482
+ statement
483
+ end
484
+
485
+ def normalize_heading_base(text)
486
+ text.to_s.sub(/\A(?:\d\uFE0F?\u20E3|[^[:alnum:][:space:]])+[ \t]*/u, '').strip.downcase
487
+ end
488
+
489
+ def inline_references_for_line(line, line_number)
490
+ owners = []
491
+ index = 0
492
+ while index < line.length
493
+ image = inline_image_reference_at(line, index, line_number)
494
+ if image
495
+ owners << image
496
+ index = image.end_column
497
+ next
498
+ end
499
+
500
+ link = inline_link_reference_at(line, index, line_number)
501
+ if link
502
+ owners << link
503
+ index = link.end_column
504
+ next
505
+ end
506
+
507
+ index += 1
508
+ end
509
+ owners
510
+ end
511
+
512
+ def inline_image_reference_at(line, index, line_number)
513
+ return unless line[index] == '!' && line[index + 1] == '['
514
+
515
+ alt_end = closing_bracket_index(line, index + 1)
516
+ return unless alt_end && line[alt_end + 1] == '['
517
+
518
+ label_end = closing_bracket_index(line, alt_end + 1)
519
+ return unless label_end
520
+
521
+ label = line[(alt_end + 2)...label_end]
522
+ inline_reference_owner(
523
+ line: line,
524
+ line_number: line_number,
525
+ start_column: index,
526
+ end_column: label_end + 1,
527
+ reference_kind: :image_reference,
528
+ label: label,
529
+ labels: [label]
530
+ )
531
+ end
532
+
533
+ def inline_link_reference_at(line, index, line_number)
534
+ return unless line[index] == '['
535
+
536
+ text_end = closing_bracket_index(line, index)
537
+ return unless text_end && line[text_end + 1] == '['
538
+
539
+ label_end = closing_bracket_index(line, text_end + 1)
540
+ return unless label_end
541
+
542
+ text = line[(index + 1)...text_end]
543
+ label = line[(text_end + 2)...label_end]
544
+ image_owner = inline_image_reference_at(text, 0, line_number)
545
+ labels = [label]
546
+ labels.unshift(image_owner.label) if image_owner && image_owner.source == text
547
+ inline_reference_owner(
548
+ line: line,
549
+ line_number: line_number,
550
+ start_column: index,
551
+ end_column: label_end + 1,
552
+ reference_kind: labels.length > 1 ? :linked_image_reference : :link_reference,
553
+ label: label,
554
+ labels: labels
555
+ )
556
+ end
557
+
558
+ def inline_reference_owner(line:, line_number:, start_column:, end_column:, reference_kind:, label:, labels:)
559
+ InlineReferenceOwner.new(
560
+ location: Location.new(start_line: line_number, end_line: line_number),
561
+ line: line_number,
562
+ start_column: start_column,
563
+ end_column: end_column,
564
+ source: line[start_column...end_column],
565
+ reference_kind: reference_kind,
566
+ label: label,
567
+ labels: labels.compact.uniq
568
+ )
569
+ end
570
+
571
+ def closing_bracket_index(text, opening_index)
572
+ depth = 0
573
+ index = opening_index
574
+ while index < text.length
575
+ case text[index]
576
+ when '['
577
+ depth += 1
578
+ when ']'
579
+ depth -= 1
580
+ return index if depth.zero?
581
+ end
582
+ index += 1
583
+ end
584
+ nil
585
+ end
586
+
587
+ # Recursively collect text content from a node and its descendants.
588
+ #
589
+ # Uses NodeTypeNormalizer to map backend-specific types to canonical types,
590
+ # enabling portable type checking across different markdown parsers.
591
+ #
592
+ # NOTE: We use `type` here instead of `merge_type` because this method operates
593
+ # on child nodes (text, code), not top-level statements.
594
+ # Only top-level statements are wrapped by NodeTypeNormalizer with `merge_type`.
595
+ # However, we use NodeTypeNormalizer.canonical_type to normalize the raw type.
596
+ #
597
+ # @param node [Object] The node to traverse
598
+ # @param text_parts [Array<String>] Array to accumulate text into
599
+ # @return [void]
600
+ def collect_text_recursive(node, text_parts)
601
+ # Normalize the type using NodeTypeNormalizer for backend portability
602
+ canonical_type = NodeTypeNormalizer.canonical_type(node.type, @backend)
603
+
604
+ # Collect text from text and code nodes
605
+ if %i[text code].include?(canonical_type)
606
+ content = if node.respond_to?(:string_content)
607
+ node.string_content.to_s
608
+ elsif node.respond_to?(:text)
609
+ node.text.to_s
610
+ else
611
+ ''
612
+ end
613
+ text_parts << content unless content.empty?
614
+ end
615
+
616
+ # Recurse into children
617
+ node.children.each do |child|
618
+ collect_text_recursive(child, text_parts)
619
+ end
620
+ end
621
+
622
+ # Resolve the backend to use.
623
+ #
624
+ # For :auto, use the same backend selection as the Markdown substrate facade.
625
+ # tree_haver handles the final availability checking.
626
+ #
627
+ # @param backend [Symbol] Requested backend
628
+ # @return [Symbol] Resolved backend
629
+ def resolve_backend(backend)
630
+ return Markdown::Merge.resolve_backend(nil).to_sym if backend.to_s.empty? || backend == :auto
631
+
632
+ backend.to_sym
633
+ end
634
+
635
+ # Create a parser for the resolved backend.
636
+ #
637
+ # @return [Object] tree_haver parser instance
638
+ def create_parser
639
+ unless Markdown::Merge::BACKEND_REFERENCES.key?(@backend.to_s)
640
+ raise ArgumentError, "Unknown backend: #{@backend}"
641
+ end
642
+
643
+ parser = TreeHaver.with_backend(@backend) { TreeHaver.parser_for(:markdown) }
644
+
645
+ case @backend
646
+ when :commonmarker
647
+ parser.language = commonmarker_language
648
+ when :markly
649
+ parser.language = markly_language
650
+ when :kramdown
651
+ parser.language = kramdown_language
652
+ else
653
+ return parser
654
+ end
655
+
656
+ parser
657
+ end
658
+
659
+ # Create a Commonmarker language config for the TreeHaver parser.
660
+ #
661
+ # @return [Commonmarker::Merge::Backend::Language]
662
+ def commonmarker_language
663
+ # Default options enable table extension for GFM compatibility
664
+ default_options = { extension: { table: true } }
665
+ options = default_options.merge(@parser_options[:options] || {})
666
+ Commonmarker::Merge::Backend::Language.markdown(options: options)
667
+ end
668
+
669
+ # Create a Markly language config for the TreeHaver parser.
670
+ #
671
+ # @return [Markly::Merge::Backend::Language]
672
+ def markly_language
673
+ flags = @parser_options[:flags]
674
+ extensions = @parser_options[:extensions] || [:table]
675
+ Markly::Merge::Backend::Language.markdown(
676
+ flags: flags,
677
+ extensions: extensions
678
+ )
679
+ end
680
+
681
+ # Create a Kramdown language config for the TreeHaver parser.
682
+ #
683
+ # @return [Kramdown::Merge::Backend::Language]
684
+ def kramdown_language
685
+ Kramdown::Merge::Backend::Language.markdown(options: @parser_options[:options] || {})
686
+ end
687
+ end
688
+ end
689
+ end