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.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/LICENSE.md +13 -0
- data/README.md +681 -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/smart_merger.rb +269 -0
- data/lib/markdown/merge/smart_merger_base.rb +1490 -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 +233 -87
- data/lib/markdown-merge.rb +7 -1
- data/sig/markdown/merge.rbs +8 -0
- data.tar.gz.sig +0 -0
- metadata +287 -15
- 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
|