markdown-merge 7.0.0 → 7.1.1

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 (47) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/LICENSE.md +13 -0
  4. data/README.md +681 -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/smart_merger.rb +269 -0
  36. data/lib/markdown/merge/smart_merger_base.rb +1490 -0
  37. data/lib/markdown/merge/table_match_algorithm.rb +499 -0
  38. data/lib/markdown/merge/table_match_refiner.rb +132 -0
  39. data/lib/markdown/merge/version.rb +5 -3
  40. data/lib/markdown/merge/whitespace_normalizer.rb +243 -0
  41. data/lib/markdown/merge/wrapper_support.rb +194 -0
  42. data/lib/markdown/merge.rb +233 -87
  43. data/lib/markdown-merge.rb +7 -1
  44. data/sig/markdown/merge.rbs +8 -0
  45. data.tar.gz.sig +0 -0
  46. metadata +287 -15
  47. metadata.gz.sig +0 -0
@@ -0,0 +1,291 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markdown
4
+ module Merge
5
+ # Shared Markdown-local helper methods for statement classification,
6
+ # standalone comment fragment preservation, and remove-plan-backed range filtering.
7
+ #
8
+ # Provides the canonical gap-line / blank-gap-line / non-blank-gap-line /
9
+ # structural-for-preservation predicate set used by both SmartMergerBase
10
+ # (removal mode) and PartialTemplateMerger (replace-mode insertion indexing).
11
+ # Both classes should call these predicates rather than performing direct
12
+ # is_a?(GapLineNode) checks.
13
+ module PreservationSupport
14
+ private
15
+
16
+ def normalized_preserved_fragment_text(text)
17
+ text.to_s.sub(/\n+\z/, '')
18
+ end
19
+
20
+ def standalone_comment_text?(text)
21
+ normalized_preserved_fragment_text(text).strip.match?(CommentTracker::STANDALONE_HTML_COMMENT_REGEX)
22
+ end
23
+
24
+ def standalone_comment_node?(node, analysis)
25
+ return false unless node.respond_to?(:source_position)
26
+ return false unless analysis.respond_to?(:comment_tracker)
27
+ return false unless analysis.respond_to?(:comment_node_at)
28
+ return false unless analysis.comment_tracker
29
+
30
+ pos = node.source_position
31
+ start_line = pos&.dig(:start_line)
32
+ end_line = pos&.dig(:end_line)
33
+ return false unless start_line && end_line
34
+ return false unless start_line == end_line
35
+
36
+ !!analysis.comment_node_at(start_line)
37
+ end
38
+
39
+ def link_definition_node?(node)
40
+ node.is_a?(LinkDefinitionNode) || (node.respond_to?(:merge_type) && node.merge_type == :link_definition)
41
+ end
42
+
43
+ def gap_line_node?(node)
44
+ node.is_a?(GapLineNode) ||
45
+ (node.respond_to?(:merge_type) && node.merge_type == :gap_line) ||
46
+ (node.respond_to?(:type) && node.type == :gap_line)
47
+ end
48
+
49
+ def blank_gap_line_node?(node)
50
+ return false unless gap_line_node?(node)
51
+ return node.blank? if node.respond_to?(:blank?)
52
+
53
+ text = if node.respond_to?(:text)
54
+ node.text
55
+ elsif node.respond_to?(:content)
56
+ node.content
57
+ else
58
+ ''
59
+ end
60
+
61
+ text.to_s.strip.empty?
62
+ end
63
+
64
+ # Returns true when the node is a gap line whose content is non-blank
65
+ # (e.g. a consumed link-reference definition or similar inline content
66
+ # that was mapped to a GapLineNode).
67
+ #
68
+ # This is the canonical replacement for `node.is_a?(GapLineNode) && !node.blank?`
69
+ # and handles wrapped gap-line statement nodes uniformly alongside real
70
+ # GapLineNode instances.
71
+ #
72
+ # @param node [Object] The node to classify
73
+ # @return [Boolean] true when the node is a gap line with non-blank content
74
+ def non_blank_gap_line_node?(node)
75
+ gap_line_node?(node) && !blank_gap_line_node?(node)
76
+ end
77
+
78
+ def structural_preservation_statement?(statement, analysis)
79
+ !gap_line_node?(statement) &&
80
+ !standalone_comment_node?(statement, analysis) &&
81
+ !link_definition_node?(statement)
82
+ end
83
+
84
+ def standalone_comment_region?(region)
85
+ standalone_comment_text?(region.respond_to?(:text) ? region.text : nil)
86
+ end
87
+
88
+ def preserved_comment_region_key(region)
89
+ [
90
+ region.respond_to?(:start_line) ? region.start_line : nil,
91
+ region.respond_to?(:end_line) ? region.end_line : nil,
92
+ normalized_preserved_fragment_text(region.respond_to?(:text) ? region.text : nil)
93
+ ]
94
+ end
95
+
96
+ def preserved_comment_node_key(node, analysis, text: nil)
97
+ pos = node.respond_to?(:source_position) ? node.source_position : nil
98
+
99
+ [
100
+ pos&.dig(:start_line),
101
+ pos&.dig(:end_line),
102
+ normalized_preserved_fragment_text(text || source_text_for_preserved_node(node, analysis))
103
+ ]
104
+ end
105
+
106
+ def region_within_removed_range?(region, remove_plan)
107
+ return false unless region
108
+
109
+ start_line = region.respond_to?(:start_line) ? region.start_line : nil
110
+ end_line = region.respond_to?(:end_line) ? region.end_line : nil
111
+ return true unless start_line && end_line
112
+
113
+ start_line >= remove_plan.remove_start_line && end_line <= remove_plan.remove_end_line
114
+ end
115
+
116
+ def remove_plan_preserved_comment_regions(remove_plan)
117
+ return [] unless remove_plan
118
+
119
+ regions = Array(remove_plan.promoted_comment_regions)
120
+ regions << remove_plan.trailing_boundary&.comment_attachment&.leading_region
121
+
122
+ seen = Set.new
123
+ regions.each_with_object([]) do |region, preserved_regions|
124
+ next unless standalone_comment_region?(region)
125
+ next unless region_within_removed_range?(region, remove_plan)
126
+
127
+ region_key = preserved_comment_region_key(region)
128
+ next if seen.include?(region_key)
129
+
130
+ seen << region_key
131
+ preserved_regions << region
132
+ end
133
+ end
134
+
135
+ def remove_plan_preserved_comment_keys(remove_plan)
136
+ remove_plan_preserved_comment_regions(remove_plan).each_with_object(Set.new) do |region, keys|
137
+ keys << preserved_comment_region_key(region)
138
+ end
139
+ end
140
+
141
+ def rebase_preserved_comment_keys(keys, line_offset:)
142
+ Array(keys).each_with_object(Set.new) do |key, rebased_keys|
143
+ start_line, end_line, text = Array(key)
144
+ rebased_keys << [
145
+ start_line ? start_line - line_offset : nil,
146
+ end_line ? end_line - line_offset : nil,
147
+ text
148
+ ]
149
+ end
150
+ end
151
+
152
+ def remove_plan_owns_comment_node?(node, analysis, remove_plan, preserved_comment_keys: nil)
153
+ return false unless remove_plan
154
+ return false unless standalone_comment_node?(node, analysis)
155
+
156
+ keys = preserved_comment_keys || remove_plan_preserved_comment_keys(remove_plan)
157
+ region = comment_region_for_node(node, analysis, kind: :orphan)
158
+ return false unless region_within_removed_range?(region, remove_plan)
159
+
160
+ keys.include?(preserved_comment_region_key(region))
161
+ end
162
+
163
+ def remove_plan_preserved_comment_keys_for_nodes(remove_plan, nodes:, analysis:)
164
+ keys = remove_plan_preserved_comment_keys(remove_plan)
165
+
166
+ Array(nodes).each do |node|
167
+ next unless standalone_comment_node?(node, analysis)
168
+
169
+ region = comment_region_for_node(node, analysis, kind: :orphan)
170
+ next unless region_within_removed_range?(region, remove_plan)
171
+
172
+ keys << preserved_comment_node_key(node, analysis)
173
+ end
174
+
175
+ keys
176
+ end
177
+
178
+ def remove_plan_comment_insertion_specs(remove_plan, insertion_index_by_owner:, final_insertion_index:)
179
+ return [] unless remove_plan
180
+
181
+ allowed_region_keys = remove_plan_preserved_comment_keys(remove_plan)
182
+ seen_region_keys = Set.new
183
+
184
+ Array(remove_plan.removed_attachments).each_with_object([]) do |attachment, specs|
185
+ append_remove_plan_comment_insertion_spec(
186
+ specs,
187
+ region: attachment.respond_to?(:leading_region) ? attachment.leading_region : nil,
188
+ insertion_index: insertion_index_by_owner[attachment_owner_key(attachment)],
189
+ gap_count: blank_gap_count(attachment.respond_to?(:leading_gap) ? attachment.leading_gap : nil),
190
+ allowed_region_keys: allowed_region_keys,
191
+ seen_region_keys: seen_region_keys
192
+ )
193
+ end.tap do |specs|
194
+ append_remove_plan_comment_insertion_spec(
195
+ specs,
196
+ region: remove_plan.trailing_boundary&.comment_attachment&.leading_region,
197
+ insertion_index: final_insertion_index,
198
+ gap_count: blank_gap_count(remove_plan.trailing_boundary&.comment_attachment&.leading_gap),
199
+ allowed_region_keys: allowed_region_keys,
200
+ seen_region_keys: seen_region_keys
201
+ )
202
+ end
203
+ end
204
+
205
+ def comment_region_for_node(node, analysis, kind: :orphan, full_line_only: true)
206
+ return unless node.respond_to?(:source_position)
207
+ return unless analysis.respond_to?(:comment_region_for_range)
208
+
209
+ pos = node.source_position
210
+ start_line = pos&.dig(:start_line)
211
+ end_line = pos&.dig(:end_line)
212
+ return unless start_line && end_line
213
+
214
+ analysis.comment_region_for_range(start_line..end_line, kind: kind, full_line_only: full_line_only)
215
+ end
216
+
217
+ def preserved_fragment_for_node(
218
+ node,
219
+ analysis,
220
+ template_has_standalone_comments:,
221
+ template_link_definition_signatures:
222
+ )
223
+ if standalone_comment_node?(node, analysis)
224
+ return if template_has_standalone_comments
225
+
226
+ {
227
+ kind: :standalone_comment,
228
+ text: normalized_preserved_fragment_text(source_text_for_preserved_node(node, analysis))
229
+ }
230
+ elsif link_definition_node?(node)
231
+ return if template_link_definition_signatures.include?(node.signature)
232
+
233
+ {
234
+ kind: :link_definition,
235
+ text: normalized_preserved_fragment_text(source_text_for_preserved_node(node, analysis))
236
+ }
237
+ end
238
+ end
239
+
240
+ def preserved_fragment_separator(gap_count:, previous_kind:, current_kind:)
241
+ return "\n\n" if gap_count.positive?
242
+ return "\n" if previous_kind == :link_definition && current_kind == :link_definition
243
+
244
+ "\n\n"
245
+ end
246
+
247
+ def blank_gap_count(gap)
248
+ return 0 unless gap&.respond_to?(:blank_line_count)
249
+
250
+ gap.blank_line_count
251
+ end
252
+
253
+ def attachment_owner_key(owner_or_attachment)
254
+ owner = if owner_or_attachment.respond_to?(:owner)
255
+ owner_or_attachment.owner
256
+ else
257
+ owner_or_attachment
258
+ end
259
+
260
+ owner&.object_id
261
+ end
262
+
263
+ def source_text_for_preserved_node(node, analysis)
264
+ if respond_to?(:node_to_source, true)
265
+ node_to_source(node, analysis)
266
+ else
267
+ ''
268
+ end
269
+ end
270
+
271
+ def append_remove_plan_comment_insertion_spec(specs, region:, insertion_index:, gap_count:, allowed_region_keys:,
272
+ seen_region_keys:)
273
+ return unless region && insertion_index
274
+
275
+ region_key = preserved_comment_region_key(region)
276
+ return unless allowed_region_keys.include?(region_key)
277
+ return if seen_region_keys.include?(region_key)
278
+
279
+ seen_region_keys << region_key
280
+ specs << {
281
+ insertion_index: insertion_index,
282
+ fragment: {
283
+ kind: :standalone_comment,
284
+ text: normalized_preserved_fragment_text(region.text)
285
+ },
286
+ gap_count: gap_count
287
+ }
288
+ end
289
+ end
290
+ end
291
+ end
@@ -0,0 +1,269 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markdown
4
+ module Merge
5
+ # Orchestrates the smart merge process for Markdown files using tree_haver backends.
6
+ #
7
+ # Extends SmartMergerBase with backend-agnostic parsing via tree_haver.
8
+ # Supports both Commonmarker and Markly backends.
9
+ #
10
+ # Uses FileAnalysis, FileAligner, ConflictResolver, and MergeResult to
11
+ # merge two Markdown files intelligently. Freeze blocks marked with
12
+ # HTML comments are preserved exactly as-is.
13
+ #
14
+ # SmartMerger provides flexible configuration for different merge scenarios:
15
+ # - Preserve destination customizations (default)
16
+ # - Apply template updates
17
+ # - Add new sections from template
18
+ # - Inner-merge fenced code blocks using language-specific mergers (optional)
19
+ #
20
+ # @example Basic merge (destination customizations preserved)
21
+ # merger = SmartMerger.new(template_content, dest_content)
22
+ # result = merger.merge
23
+ # if result.success?
24
+ # File.write("output.md", result.content)
25
+ # end
26
+ #
27
+ # @example With specific backend
28
+ # merger = SmartMerger.new(
29
+ # template_content,
30
+ # dest_content,
31
+ # backend: :markly
32
+ # )
33
+ # result = merger.merge
34
+ #
35
+ # @example Template updates win
36
+ # merger = SmartMerger.new(
37
+ # template_content,
38
+ # dest_content,
39
+ # preference: :template,
40
+ # add_template_only_nodes: true
41
+ # )
42
+ # result = merger.merge
43
+ #
44
+ # @example Custom signature matching
45
+ # sig_gen = ->(node) {
46
+ # canonical_type = Ast::Merge::NodeTyping.merge_type_for(node) || node.type
47
+ # if canonical_type == :heading
48
+ # [:heading, node.header_level] # Match by level only, not content
49
+ # else
50
+ # node # Fall through to default
51
+ # end
52
+ # }
53
+ # merger = SmartMerger.new(
54
+ # template_content,
55
+ # dest_content,
56
+ # signature_generator: sig_gen
57
+ # )
58
+ #
59
+ # @see FileAnalysis
60
+ # @see SmartMergerBase
61
+ class SmartMerger < SmartMergerBase
62
+ VALID_BACKENDS = %i[auto commonmarker markly kramdown].freeze
63
+
64
+ class << self
65
+ def default_backend
66
+ :auto
67
+ end
68
+
69
+ def default_freeze_token
70
+ FileAnalysis::DEFAULT_FREEZE_TOKEN
71
+ end
72
+
73
+ def default_inner_merge_code_blocks
74
+ false
75
+ end
76
+
77
+ def default_parser_options
78
+ {}
79
+ end
80
+
81
+ def file_analysis_class
82
+ FileAnalysis
83
+ end
84
+
85
+ def template_parse_error_class
86
+ TemplateParseError
87
+ end
88
+
89
+ def destination_parse_error_class
90
+ DestinationParseError
91
+ end
92
+ end
93
+
94
+ # @return [Symbol] The backend being used (:commonmarker, :markly)
95
+ attr_reader :backend
96
+
97
+ # Creates a new SmartMerger for intelligent Markdown file merging.
98
+ #
99
+ # @param template_content [String] Template Markdown source code
100
+ # @param dest_content [String] Destination Markdown source code
101
+ #
102
+ # @param backend [Symbol] Backend to use for parsing:
103
+ # - `:commonmarker` - Use Commonmarker (comrak Rust parser)
104
+ # - `:markly` - Use Markly (cmark-gfm C library)
105
+ # - `:auto` (default) - Auto-detect available backend
106
+ #
107
+ # @param signature_generator [Proc, nil] Optional proc to generate custom node signatures.
108
+ # The proc receives a node (wrapped with canonical merge_type) and should return one of:
109
+ # - An array representing the node's signature
110
+ # - `nil` to indicate the node should have no signature
111
+ # - The original node to fall through to default signature computation
112
+ #
113
+ # @param preference [Symbol] Controls which version to use when nodes
114
+ # have matching signatures but different content:
115
+ # - `:destination` (default) - Use destination version (preserves customizations)
116
+ # - `:template` - Use template version (applies updates)
117
+ #
118
+ # @param add_template_only_nodes [Boolean] Controls whether to add nodes that only
119
+ # exist in template:
120
+ # - `false` (default) - Skip template-only nodes
121
+ # - `true` - Add template-only nodes to result
122
+ #
123
+ # @param inner_merge_code_blocks [Boolean, CodeBlockMerger] Controls inner-merge for
124
+ # fenced code blocks:
125
+ # - `true` - Enable inner-merge using default CodeBlockMerger
126
+ # - `false` (default) - Disable inner-merge (use standard conflict resolution)
127
+ # - `CodeBlockMerger` instance - Use custom CodeBlockMerger
128
+ #
129
+ # @param remove_template_missing_nodes [Boolean] Controls whether destination-only
130
+ # structural nodes should be removed instead of preserved. Standalone HTML
131
+ # comment-only fragments, freeze blocks, and link reference definitions remain
132
+ # preserved when enabled.
133
+ #
134
+ # @param freeze_token [String] Token to use for freeze block markers.
135
+ # Default: "markdown-merge"
136
+ # Looks for: <!-- markdown-merge:freeze --> / <!-- markdown-merge:unfreeze -->
137
+ #
138
+ # @param match_refiner [#call, nil] Optional match refiner for fuzzy matching of
139
+ # unmatched nodes. Default: nil (fuzzy matching disabled).
140
+ # Set to TableMatchRefiner.new to enable fuzzy table matching.
141
+ #
142
+ # @param node_typing [Hash{Symbol,String => #call}, nil] Node typing configuration
143
+ # for per-node-type merge preferences. Maps node type names to callables.
144
+ #
145
+ # @param parser_options [Hash] Backend-specific parser options.
146
+ # For commonmarker: { options: {} }
147
+ # For markly: { flags: Markly::DEFAULT, extensions: [:table] }
148
+ #
149
+ # @raise [TemplateParseError] If template has syntax errors
150
+ # @raise [DestinationParseError] If destination has syntax errors
151
+ def initialize(
152
+ template_content,
153
+ dest_content,
154
+ backend: self.class.default_backend,
155
+ signature_generator: nil,
156
+ preference: :destination,
157
+ add_template_only_nodes: false,
158
+ inner_merge_code_blocks: self.class.default_inner_merge_code_blocks,
159
+ inner_merge_lists: false,
160
+ remove_template_missing_nodes: false,
161
+ freeze_token: self.class.default_freeze_token,
162
+ match_refiner: nil,
163
+ node_typing: nil,
164
+ **parser_options
165
+ )
166
+ validate_backend!(backend)
167
+
168
+ @requested_backend = backend
169
+ @parser_options = self.class.default_parser_options.merge(parser_options)
170
+
171
+ super(
172
+ template_content,
173
+ dest_content,
174
+ signature_generator: signature_generator,
175
+ preference: preference,
176
+ add_template_only_nodes: add_template_only_nodes,
177
+ inner_merge_code_blocks: inner_merge_code_blocks,
178
+ inner_merge_lists: inner_merge_lists,
179
+ remove_template_missing_nodes: remove_template_missing_nodes,
180
+ freeze_token: freeze_token,
181
+ match_refiner: match_refiner,
182
+ node_typing: node_typing,
183
+ # Pass through for FileAnalysis
184
+ backend: backend,
185
+ **parser_options,
186
+ )
187
+
188
+ # Capture the resolved backend from template analysis
189
+ @backend = @template_analysis.backend
190
+ end
191
+
192
+ # Create a FileAnalysis instance for parsing.
193
+ #
194
+ # @param content [String] Markdown content to analyze
195
+ # @param options [Hash] Analysis options
196
+ # @return [FileAnalysis] File analysis instance
197
+ def create_file_analysis(content, **opts)
198
+ self.class.file_analysis_class.new(
199
+ content,
200
+ backend: opts[:backend] || @requested_backend,
201
+ freeze_token: opts[:freeze_token],
202
+ signature_generator: opts[:signature_generator],
203
+ **@parser_options
204
+ )
205
+ end
206
+
207
+ # Returns the TemplateParseError class to use.
208
+ #
209
+ # @return [Class] Markdown::Merge::TemplateParseError
210
+ def template_parse_error_class
211
+ self.class.template_parse_error_class
212
+ end
213
+
214
+ # Returns the DestinationParseError class to use.
215
+ #
216
+ # @return [Class] Markdown::Merge::DestinationParseError
217
+ def destination_parse_error_class
218
+ self.class.destination_parse_error_class
219
+ end
220
+
221
+ # Convert a node to its source text.
222
+ #
223
+ # Handles wrapped nodes from NodeTypeNormalizer, gap line nodes,
224
+ # and link definition nodes created during gap detection.
225
+ #
226
+ # @param node [Object] Node to convert (may be wrapped)
227
+ # @param analysis [FileAnalysis] Analysis for source lookup
228
+ # @return [String] Source text
229
+ def node_to_source(node, analysis)
230
+ # Check for any FreezeNode type (base class or subclass)
231
+ return node.full_text if node.is_a?(Ast::Merge::FreezeNodeBase)
232
+
233
+ # Handle gap line nodes (created for blank lines and link definitions)
234
+ return node.content if node.is_a?(LinkDefinitionNode) || node.is_a?(GapLineNode)
235
+
236
+ # Unwrap if needed to access source_position
237
+ raw_node = Ast::Merge::NodeTyping.unwrap(node)
238
+
239
+ pos = raw_node.source_position
240
+ start_line = pos&.dig(:start_line)
241
+ end_line = pos&.dig(:end_line)
242
+
243
+ # Fall back to to_commonmark if no position info
244
+ return raw_node.to_commonmark unless start_line && end_line
245
+
246
+ # Get source from line range
247
+ source = analysis.source_range(start_line, end_line)
248
+
249
+ # Handle Markly's buggy position reporting for :html nodes
250
+ # where end_line < start_line results in empty source_range.
251
+ # Fall back to to_commonmark in that case.
252
+ if source.empty? && raw_node.respond_to?(:to_commonmark)
253
+ raw_node.to_commonmark.chomp
254
+ else
255
+ source
256
+ end
257
+ end
258
+
259
+ private
260
+
261
+ def validate_backend!(backend)
262
+ normalized_backend = backend.respond_to?(:to_sym) ? backend.to_sym : backend
263
+ return if VALID_BACKENDS.include?(normalized_backend)
264
+
265
+ raise ArgumentError, "Unknown backend: #{backend}"
266
+ end
267
+ end
268
+ end
269
+ end