markdown-merge 1.0.0
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 +7 -0
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +251 -0
- data/CITATION.cff +20 -0
- data/CODE_OF_CONDUCT.md +134 -0
- data/CONTRIBUTING.md +227 -0
- data/FUNDING.md +74 -0
- data/LICENSE.txt +21 -0
- data/README.md +1087 -0
- data/REEK +0 -0
- data/RUBOCOP.md +71 -0
- data/SECURITY.md +21 -0
- data/lib/markdown/merge/cleanse/block_spacing.rb +253 -0
- data/lib/markdown/merge/cleanse/code_fence_spacing.rb +294 -0
- data/lib/markdown/merge/cleanse/condensed_link_refs.rb +405 -0
- data/lib/markdown/merge/cleanse.rb +42 -0
- data/lib/markdown/merge/code_block_merger.rb +300 -0
- data/lib/markdown/merge/conflict_resolver.rb +128 -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 +196 -0
- data/lib/markdown/merge/file_analysis.rb +353 -0
- data/lib/markdown/merge/file_analysis_base.rb +629 -0
- data/lib/markdown/merge/freeze_node.rb +93 -0
- data/lib/markdown/merge/gap_line_node.rb +136 -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/markdown_structure.rb +123 -0
- data/lib/markdown/merge/merge_result.rb +166 -0
- data/lib/markdown/merge/node_type_normalizer.rb +126 -0
- data/lib/markdown/merge/output_builder.rb +166 -0
- data/lib/markdown/merge/partial_template_merger.rb +334 -0
- data/lib/markdown/merge/smart_merger.rb +221 -0
- data/lib/markdown/merge/smart_merger_base.rb +621 -0
- data/lib/markdown/merge/table_match_algorithm.rb +504 -0
- data/lib/markdown/merge/table_match_refiner.rb +136 -0
- data/lib/markdown/merge/version.rb +12 -0
- data/lib/markdown/merge/whitespace_normalizer.rb +251 -0
- data/lib/markdown/merge.rb +149 -0
- data/lib/markdown-merge.rb +4 -0
- data/sig/markdown/merge.rbs +341 -0
- data.tar.gz.sig +0 -0
- metadata +365 -0
- metadata.gz.sig +0 -0
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "set"
|
|
5
|
+
|
|
6
|
+
module Markdown
|
|
7
|
+
module Merge
|
|
8
|
+
# Base class for file analysis for Markdown files.
|
|
9
|
+
#
|
|
10
|
+
# Parses Markdown source code and extracts:
|
|
11
|
+
# - Top-level block elements (headings, paragraphs, lists, code blocks, etc.)
|
|
12
|
+
# - Freeze blocks marked with HTML comments
|
|
13
|
+
# - Structural signatures for matching elements between files
|
|
14
|
+
#
|
|
15
|
+
# Subclasses must implement parser-specific methods:
|
|
16
|
+
# - #parse_document(source) - Parse source and return document node
|
|
17
|
+
# - #next_sibling(node) - Get next sibling of a node
|
|
18
|
+
# - #compute_parser_signature(node) - Compute signature for parser-specific nodes
|
|
19
|
+
# - #node_type_name(type) - Map canonical type names if needed
|
|
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 (subclass)
|
|
27
|
+
# class FileAnalysis < Markdown::Merge::FileAnalysisBase
|
|
28
|
+
# def parse_document(source)
|
|
29
|
+
# Markly.parse(source, flags: @flags)
|
|
30
|
+
# end
|
|
31
|
+
#
|
|
32
|
+
# def next_sibling(node)
|
|
33
|
+
# node.next
|
|
34
|
+
# end
|
|
35
|
+
# end
|
|
36
|
+
#
|
|
37
|
+
# @abstract Subclass and implement parser-specific methods
|
|
38
|
+
class FileAnalysisBase
|
|
39
|
+
include Ast::Merge::FileAnalyzable
|
|
40
|
+
|
|
41
|
+
# Default freeze token for identifying freeze blocks
|
|
42
|
+
# @return [String]
|
|
43
|
+
DEFAULT_FREEZE_TOKEN = "markdown-merge"
|
|
44
|
+
|
|
45
|
+
# @return [Object] The root document node
|
|
46
|
+
attr_reader :document
|
|
47
|
+
|
|
48
|
+
# @return [Array] Parse errors if any
|
|
49
|
+
attr_reader :errors
|
|
50
|
+
|
|
51
|
+
# Note: :source is inherited from Ast::Merge::FileAnalyzable
|
|
52
|
+
|
|
53
|
+
# Initialize file analysis
|
|
54
|
+
#
|
|
55
|
+
# @param source [String] Markdown source code to analyze
|
|
56
|
+
# @param freeze_token [String] Token for freeze block markers
|
|
57
|
+
# @param signature_generator [Proc, nil] Custom signature generator
|
|
58
|
+
def initialize(source, freeze_token: DEFAULT_FREEZE_TOKEN, signature_generator: nil, **parser_options)
|
|
59
|
+
@source = source
|
|
60
|
+
# Split by newlines, keeping trailing empty strings (-1)
|
|
61
|
+
# But remove the final empty string if source ends with newline
|
|
62
|
+
# (that empty string represents the "line after the last newline" which doesn't exist)
|
|
63
|
+
@lines = source.split("\n", -1)
|
|
64
|
+
@lines.pop if @lines.last == "" && source.end_with?("\n")
|
|
65
|
+
|
|
66
|
+
@freeze_token = freeze_token
|
|
67
|
+
@signature_generator = signature_generator
|
|
68
|
+
@parser_options = parser_options
|
|
69
|
+
@errors = []
|
|
70
|
+
|
|
71
|
+
# Parse the Markdown source - subclasses implement this
|
|
72
|
+
@document = DebugLogger.time("FileAnalysisBase#parse") do
|
|
73
|
+
parse_document(source)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Extract and integrate all nodes including freeze blocks
|
|
77
|
+
@statements = extract_and_integrate_all_nodes
|
|
78
|
+
|
|
79
|
+
DebugLogger.debug("FileAnalysisBase initialized", {
|
|
80
|
+
signature_generator: signature_generator ? "custom" : "default",
|
|
81
|
+
document_children: count_children(@document),
|
|
82
|
+
statements_count: @statements.size,
|
|
83
|
+
freeze_blocks: freeze_blocks.size,
|
|
84
|
+
})
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Parse the source document.
|
|
88
|
+
#
|
|
89
|
+
# @abstract Subclasses must implement this method
|
|
90
|
+
# @param source [String] Markdown source to parse
|
|
91
|
+
# @return [Object] Root document node
|
|
92
|
+
def parse_document(source)
|
|
93
|
+
raise NotImplementedError, "#{self.class} must implement #parse_document"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Get the next sibling of a node.
|
|
97
|
+
#
|
|
98
|
+
# Different parsers use different methods (next vs next_sibling).
|
|
99
|
+
#
|
|
100
|
+
# @abstract Subclasses must implement this method
|
|
101
|
+
# @param node [Object] Current node
|
|
102
|
+
# @return [Object, nil] Next sibling or nil
|
|
103
|
+
def next_sibling(node)
|
|
104
|
+
raise NotImplementedError, "#{self.class} must implement #next_sibling"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Check if parse was successful
|
|
108
|
+
# @return [Boolean]
|
|
109
|
+
def valid?
|
|
110
|
+
@errors.empty? && !@document.nil?
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Get all statements (block nodes outside freeze blocks + FreezeNode instances)
|
|
114
|
+
# @return [Array<Object, FreezeNode>]
|
|
115
|
+
attr_reader :statements
|
|
116
|
+
|
|
117
|
+
# Compute default signature for a node
|
|
118
|
+
# @param node [Object] The parser node or FreezeNode
|
|
119
|
+
# @return [Array, nil] Signature array
|
|
120
|
+
def compute_node_signature(node)
|
|
121
|
+
case node
|
|
122
|
+
when Ast::Merge::FreezeNodeBase
|
|
123
|
+
node.signature
|
|
124
|
+
when LinkDefinitionNode
|
|
125
|
+
node.signature
|
|
126
|
+
when GapLineNode
|
|
127
|
+
node.signature
|
|
128
|
+
else
|
|
129
|
+
compute_parser_signature(node)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Override to detect parser nodes for signature generator fallthrough
|
|
134
|
+
# @param value [Object] The value to check
|
|
135
|
+
# @return [Boolean] true if this is a fallthrough node
|
|
136
|
+
def fallthrough_node?(value)
|
|
137
|
+
value.is_a?(Ast::Merge::FreezeNodeBase) ||
|
|
138
|
+
value.is_a?(LinkDefinitionNode) ||
|
|
139
|
+
value.is_a?(GapLineNode) ||
|
|
140
|
+
parser_node?(value) ||
|
|
141
|
+
super
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Check if value is a parser-specific node.
|
|
145
|
+
#
|
|
146
|
+
# @param value [Object] Value to check
|
|
147
|
+
# @return [Boolean] true if this is a parser node
|
|
148
|
+
def parser_node?(value)
|
|
149
|
+
# Default: check if it responds to :type (common for AST nodes)
|
|
150
|
+
value.respond_to?(:type)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Compute signature for a parser-specific node.
|
|
154
|
+
#
|
|
155
|
+
# @abstract Subclasses should override this method
|
|
156
|
+
# @param node [Object] The parser node
|
|
157
|
+
# @return [Array, nil] Signature array
|
|
158
|
+
def compute_parser_signature(node)
|
|
159
|
+
type = node.type
|
|
160
|
+
case type
|
|
161
|
+
when :heading, :header
|
|
162
|
+
# Content-based: Match headings by level and text content
|
|
163
|
+
[:heading, node.header_level, extract_text_content(node)]
|
|
164
|
+
when :paragraph
|
|
165
|
+
# Content-based: Match paragraphs by content hash (first 32 chars of digest)
|
|
166
|
+
text = extract_text_content(node)
|
|
167
|
+
[:paragraph, Digest::SHA256.hexdigest(text)[0, 32]]
|
|
168
|
+
when :code_block
|
|
169
|
+
# Content-based: Match code blocks by fence info and content hash
|
|
170
|
+
content = safe_string_content(node)
|
|
171
|
+
fence_info = node.respond_to?(:fence_info) ? node.fence_info : nil
|
|
172
|
+
[:code_block, fence_info, Digest::SHA256.hexdigest(content)[0, 16]]
|
|
173
|
+
when :list
|
|
174
|
+
# Structure-based: Match lists by type and item count (content may differ)
|
|
175
|
+
list_type = node.respond_to?(:list_type) ? node.list_type : nil
|
|
176
|
+
[:list, list_type, count_children(node)]
|
|
177
|
+
when :block_quote, :blockquote
|
|
178
|
+
# Content-based: Match block quotes by content hash
|
|
179
|
+
text = extract_text_content(node)
|
|
180
|
+
[:blockquote, Digest::SHA256.hexdigest(text)[0, 16]]
|
|
181
|
+
when :thematic_break, :hrule
|
|
182
|
+
# Structure-based: All thematic breaks are equivalent
|
|
183
|
+
[:hrule]
|
|
184
|
+
when :html_block, :html
|
|
185
|
+
# Content-based: Match HTML blocks by content hash
|
|
186
|
+
content = safe_string_content(node)
|
|
187
|
+
[:html, Digest::SHA256.hexdigest(content)[0, 16]]
|
|
188
|
+
when :table
|
|
189
|
+
# Content-based: Match tables by structure and header content
|
|
190
|
+
header_content = extract_table_header_content(node)
|
|
191
|
+
[:table, count_children(node), Digest::SHA256.hexdigest(header_content)[0, 16]]
|
|
192
|
+
when :footnote_definition
|
|
193
|
+
# Name/label-based: Match footnotes by name or label
|
|
194
|
+
label = node.respond_to?(:name) ? node.name : safe_string_content(node)
|
|
195
|
+
[:footnote_definition, label]
|
|
196
|
+
when :custom_block
|
|
197
|
+
# Content-based: Match custom blocks by content hash
|
|
198
|
+
text = extract_text_content(node)
|
|
199
|
+
[:custom_block, Digest::SHA256.hexdigest(text)[0, 16]]
|
|
200
|
+
else
|
|
201
|
+
# Unknown type - use type and position
|
|
202
|
+
pos = node.source_position
|
|
203
|
+
[:unknown, type, pos&.dig(:start_line)]
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Safely get string content from a node
|
|
208
|
+
# @param node [Object] The node
|
|
209
|
+
# @return [String] String content or empty string
|
|
210
|
+
def safe_string_content(node)
|
|
211
|
+
node.string_content.to_s
|
|
212
|
+
rescue TypeError
|
|
213
|
+
# Some node types don't support string_content
|
|
214
|
+
extract_text_content(node)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Extract all text content from a node and its children
|
|
218
|
+
# @param node [Object] The node
|
|
219
|
+
# @return [String] Concatenated text content
|
|
220
|
+
def extract_text_content(node)
|
|
221
|
+
text_parts = []
|
|
222
|
+
node.walk do |child|
|
|
223
|
+
if child.type == :text
|
|
224
|
+
text_parts << child.string_content.to_s
|
|
225
|
+
elsif child.type == :code
|
|
226
|
+
text_parts << child.string_content.to_s
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
text_parts.join
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Get the source text for a range of lines
|
|
233
|
+
#
|
|
234
|
+
# Lines are joined with newlines, and each line gets a trailing newline
|
|
235
|
+
# except for the last line of the file (which may or may not have one in the original).
|
|
236
|
+
#
|
|
237
|
+
# @param start_line [Integer] Start line (1-indexed)
|
|
238
|
+
# @param end_line [Integer] End line (1-indexed)
|
|
239
|
+
# @return [String] Source text
|
|
240
|
+
def source_range(start_line, end_line)
|
|
241
|
+
return "" if start_line < 1 || end_line < start_line
|
|
242
|
+
|
|
243
|
+
extracted_lines = @lines[(start_line - 1)..(end_line - 1)]
|
|
244
|
+
return "" if extracted_lines.empty?
|
|
245
|
+
|
|
246
|
+
# Add newlines between and after lines, but not after the last line of the file
|
|
247
|
+
# unless it originally had one
|
|
248
|
+
result = extracted_lines.join("\n")
|
|
249
|
+
|
|
250
|
+
# Add trailing newline if this isn't the last line of the file
|
|
251
|
+
# (the last line may or may not have a trailing newline in the original source)
|
|
252
|
+
if end_line < @lines.length
|
|
253
|
+
result += "\n"
|
|
254
|
+
elsif @source&.end_with?("\n")
|
|
255
|
+
# Last line of file, but original source ends with newline
|
|
256
|
+
result += "\n"
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
result
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
protected
|
|
263
|
+
|
|
264
|
+
# Extract header content from a table node
|
|
265
|
+
# @param node [Object] The table node
|
|
266
|
+
# @return [String] Header row content
|
|
267
|
+
def extract_table_header_content(node)
|
|
268
|
+
# First row of a table is typically the header
|
|
269
|
+
first_row = node.first_child
|
|
270
|
+
return "" unless first_row
|
|
271
|
+
|
|
272
|
+
extract_text_content(first_row)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Count children of a node
|
|
276
|
+
# @param node [Object] The node
|
|
277
|
+
# @return [Integer] Child count
|
|
278
|
+
def count_children(node)
|
|
279
|
+
count = 0
|
|
280
|
+
child = node.first_child
|
|
281
|
+
while child
|
|
282
|
+
count += 1
|
|
283
|
+
child = next_sibling(child)
|
|
284
|
+
end
|
|
285
|
+
count
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
private
|
|
289
|
+
|
|
290
|
+
# Extract all nodes and integrate freeze blocks
|
|
291
|
+
# @return [Array<Object>] Integrated list of nodes and freeze blocks
|
|
292
|
+
def extract_and_integrate_all_nodes
|
|
293
|
+
freeze_markers = find_freeze_markers
|
|
294
|
+
|
|
295
|
+
# Use gap-aware collection to preserve link definitions and gap lines
|
|
296
|
+
base_nodes = collect_top_level_nodes_with_gaps
|
|
297
|
+
|
|
298
|
+
return base_nodes if freeze_markers.empty?
|
|
299
|
+
|
|
300
|
+
# Build freeze blocks from markers
|
|
301
|
+
freeze_blocks = build_freeze_blocks(freeze_markers)
|
|
302
|
+
return base_nodes if freeze_blocks.empty?
|
|
303
|
+
|
|
304
|
+
# Integrate nodes with freeze blocks
|
|
305
|
+
integrate_nodes_with_freeze_blocks(freeze_blocks, base_nodes)
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Collect top-level nodes from document
|
|
309
|
+
# @return [Array<Object>]
|
|
310
|
+
def collect_top_level_nodes
|
|
311
|
+
nodes = []
|
|
312
|
+
child = @document.first_child
|
|
313
|
+
while child
|
|
314
|
+
nodes << child
|
|
315
|
+
child = next_sibling(child)
|
|
316
|
+
end
|
|
317
|
+
nodes
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# Collect top-level nodes with gap line detection.
|
|
321
|
+
#
|
|
322
|
+
# Markdown parsers consume certain content (like link reference definitions)
|
|
323
|
+
# during parsing. This method detects "gap" lines that aren't covered by any
|
|
324
|
+
# node and creates synthetic nodes for them.
|
|
325
|
+
#
|
|
326
|
+
# @return [Array<Object>] Nodes including gap line nodes
|
|
327
|
+
def collect_top_level_nodes_with_gaps
|
|
328
|
+
parser_nodes = collect_top_level_nodes
|
|
329
|
+
return parser_nodes if @lines.empty?
|
|
330
|
+
|
|
331
|
+
# Track which lines are covered by parser nodes
|
|
332
|
+
covered_lines = Set.new
|
|
333
|
+
parser_nodes.each do |node|
|
|
334
|
+
pos = node.source_position
|
|
335
|
+
next unless pos
|
|
336
|
+
|
|
337
|
+
start_line = pos[:start_line]
|
|
338
|
+
end_line = pos[:end_line]
|
|
339
|
+
|
|
340
|
+
# Handle Markly's buggy position reporting for :html nodes
|
|
341
|
+
# where end_line can be less than start_line (e.g., "lines 3-2")
|
|
342
|
+
if end_line < start_line
|
|
343
|
+
# Just mark the start_line as covered
|
|
344
|
+
covered_lines << start_line
|
|
345
|
+
else
|
|
346
|
+
(start_line..end_line).each { |l| covered_lines << l }
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# Find gap lines (lines not covered by any node)
|
|
351
|
+
total_lines = @lines.size
|
|
352
|
+
gap_line_numbers = (1..total_lines).to_a - covered_lines.to_a
|
|
353
|
+
|
|
354
|
+
# Create nodes for gap lines
|
|
355
|
+
gap_nodes = create_gap_nodes(gap_line_numbers)
|
|
356
|
+
|
|
357
|
+
# Integrate gap nodes with parser nodes in line order
|
|
358
|
+
integrate_gap_nodes(parser_nodes, gap_nodes)
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# Create nodes for gap lines.
|
|
362
|
+
#
|
|
363
|
+
# Link reference definitions get LinkDefinitionNode, others get GapLineNode.
|
|
364
|
+
# Every gap line gets a node so we can reconstruct the document exactly.
|
|
365
|
+
#
|
|
366
|
+
# @param line_numbers [Array<Integer>] Gap line numbers (1-based)
|
|
367
|
+
# @return [Array<Object>] Gap nodes
|
|
368
|
+
def create_gap_nodes(line_numbers)
|
|
369
|
+
line_numbers.map do |line_num|
|
|
370
|
+
content = @lines[line_num - 1] || ""
|
|
371
|
+
|
|
372
|
+
# Try to parse as link definition first
|
|
373
|
+
link_node = LinkDefinitionNode.parse(content, line_number: line_num)
|
|
374
|
+
if link_node
|
|
375
|
+
link_node
|
|
376
|
+
else
|
|
377
|
+
GapLineNode.new(content, line_number: line_num)
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# Integrate gap nodes with parser nodes in line order.
|
|
383
|
+
# Sets preceding_node for gap lines to enable context-aware signatures.
|
|
384
|
+
#
|
|
385
|
+
# @param parser_nodes [Array<Object>] Parser-generated nodes
|
|
386
|
+
# @param gap_nodes [Array<Object>] Gap line nodes
|
|
387
|
+
# @return [Array<Object>] All nodes in line order
|
|
388
|
+
def integrate_gap_nodes(parser_nodes, gap_nodes)
|
|
389
|
+
all_nodes = parser_nodes + gap_nodes
|
|
390
|
+
|
|
391
|
+
# Sort by start line
|
|
392
|
+
sorted_nodes = all_nodes.sort_by do |node|
|
|
393
|
+
pos = node.source_position
|
|
394
|
+
pos ? pos[:start_line] : 0
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
# Set preceding_node for gap lines based on their position in the sorted list
|
|
398
|
+
# This allows gap lines to have context-aware signatures
|
|
399
|
+
sorted_nodes.each_with_index do |node, idx|
|
|
400
|
+
if node.is_a?(GapLineNode) && idx > 0
|
|
401
|
+
# Find the previous non-gap-line node (structural node)
|
|
402
|
+
preceding = sorted_nodes[0...idx].reverse.find { |n| !n.is_a?(GapLineNode) }
|
|
403
|
+
node.preceding_node = preceding
|
|
404
|
+
end
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
sorted_nodes
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
# Find freeze markers from parsed HTML nodes.
|
|
411
|
+
#
|
|
412
|
+
# Freeze markers are HTML comments that Markly parses as :html nodes.
|
|
413
|
+
# By analyzing the parsed nodes (not raw source lines), we automatically ignore
|
|
414
|
+
# freeze markers that appear inside code blocks (they're part of the code block's
|
|
415
|
+
# string content, not separate nodes).
|
|
416
|
+
#
|
|
417
|
+
# Note: We only support freeze markers as standalone HTML comment nodes.
|
|
418
|
+
# Freeze markers embedded inside other HTML tags (e.g., `<div><!-- freeze -->text</div>`)
|
|
419
|
+
# are not detected because they're part of a larger HTML node's content.
|
|
420
|
+
#
|
|
421
|
+
# @return [Array<Hash>] Marker information
|
|
422
|
+
def find_freeze_markers
|
|
423
|
+
markers = []
|
|
424
|
+
pattern = Ast::Merge::FreezeNodeBase.pattern_for(:html_comment, @freeze_token)
|
|
425
|
+
|
|
426
|
+
return markers unless @document
|
|
427
|
+
|
|
428
|
+
# Walk through top-level nodes looking for HTML nodes with freeze markers
|
|
429
|
+
child = @document.first_child
|
|
430
|
+
while child
|
|
431
|
+
node_type = child.type
|
|
432
|
+
|
|
433
|
+
# Check HTML nodes for freeze markers
|
|
434
|
+
# Handle both raw Markly (:html) and TreeHaver normalized ("html_block", :html_block) types
|
|
435
|
+
if node_type == :html || node_type == :html_block || node_type == "html_block" || node_type == "html"
|
|
436
|
+
# Try multiple content extraction methods:
|
|
437
|
+
# 1. string_content (raw Markly/Commonmarker)
|
|
438
|
+
# 2. to_commonmark on wrapper
|
|
439
|
+
# 3. inner_node.to_commonmark (TreeHaver Commonmarker wrapper)
|
|
440
|
+
content = nil
|
|
441
|
+
|
|
442
|
+
if child.respond_to?(:string_content)
|
|
443
|
+
begin
|
|
444
|
+
content = child.string_content.to_s
|
|
445
|
+
rescue TypeError
|
|
446
|
+
# Some nodes don't have string_content
|
|
447
|
+
content = nil
|
|
448
|
+
end
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
if content.nil? || content.empty?
|
|
452
|
+
if child.respond_to?(:to_commonmark)
|
|
453
|
+
content = child.to_commonmark.to_s
|
|
454
|
+
end
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
# TreeHaver Commonmarker wrapper stores content in inner_node
|
|
458
|
+
if (content.nil? || content.empty?) && child.respond_to?(:inner_node)
|
|
459
|
+
inner = child.inner_node
|
|
460
|
+
if inner.respond_to?(:to_commonmark)
|
|
461
|
+
content = inner.to_commonmark.to_s
|
|
462
|
+
end
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
content ||= ""
|
|
466
|
+
match = content.match(pattern)
|
|
467
|
+
|
|
468
|
+
if match
|
|
469
|
+
pos = child.source_position
|
|
470
|
+
marker_type = match[1] # "freeze" or "unfreeze"
|
|
471
|
+
reason = match[2] # optional reason
|
|
472
|
+
|
|
473
|
+
markers << {
|
|
474
|
+
line: pos ? pos[:start_line] : 0,
|
|
475
|
+
type: marker_type.to_sym,
|
|
476
|
+
text: content.strip,
|
|
477
|
+
reason: reason,
|
|
478
|
+
node: child, # Keep reference to the actual node
|
|
479
|
+
}
|
|
480
|
+
end
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
child = next_sibling(child)
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
DebugLogger.debug("Found freeze markers", {count: markers.size})
|
|
487
|
+
markers
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
# Build freeze blocks from markers
|
|
491
|
+
# @param markers [Array<Hash>] Marker information
|
|
492
|
+
# @return [Array<FreezeNode>] Freeze blocks
|
|
493
|
+
def build_freeze_blocks(markers)
|
|
494
|
+
blocks = []
|
|
495
|
+
stack = []
|
|
496
|
+
|
|
497
|
+
markers.each do |marker|
|
|
498
|
+
case marker[:type]
|
|
499
|
+
when :freeze
|
|
500
|
+
stack.push(marker)
|
|
501
|
+
when :unfreeze
|
|
502
|
+
if stack.any?
|
|
503
|
+
start_marker = stack.pop
|
|
504
|
+
blocks << create_freeze_block(start_marker, marker)
|
|
505
|
+
else
|
|
506
|
+
DebugLogger.debug("Unmatched unfreeze marker", {line: marker[:line]})
|
|
507
|
+
end
|
|
508
|
+
end
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
# Warn about unclosed freeze blocks
|
|
512
|
+
stack.each do |unclosed|
|
|
513
|
+
DebugLogger.debug("Unclosed freeze marker", {line: unclosed[:line]})
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
blocks.sort_by(&:start_line)
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
# Create a freeze block from start and end markers.
|
|
520
|
+
#
|
|
521
|
+
# Subclasses may override to provide parser-specific FreezeNode subclass.
|
|
522
|
+
#
|
|
523
|
+
# @param start_marker [Hash] Start marker info
|
|
524
|
+
# @param end_marker [Hash] End marker info
|
|
525
|
+
# @return [FreezeNode]
|
|
526
|
+
def create_freeze_block(start_marker, end_marker)
|
|
527
|
+
start_line = start_marker[:line]
|
|
528
|
+
end_line = end_marker[:line]
|
|
529
|
+
|
|
530
|
+
# Content is between the markers (exclusive)
|
|
531
|
+
content_start = start_line + 1
|
|
532
|
+
content_end = end_line - 1
|
|
533
|
+
|
|
534
|
+
content = if content_start <= content_end
|
|
535
|
+
source_range(content_start, content_end)
|
|
536
|
+
else
|
|
537
|
+
""
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
# Parse the content to get nodes (for nested analysis)
|
|
541
|
+
parsed_nodes = parse_freeze_block_content(content)
|
|
542
|
+
|
|
543
|
+
freeze_node_class.new(
|
|
544
|
+
start_line: start_line,
|
|
545
|
+
end_line: end_line,
|
|
546
|
+
content: content,
|
|
547
|
+
start_marker: start_marker[:text],
|
|
548
|
+
end_marker: end_marker[:text],
|
|
549
|
+
nodes: parsed_nodes,
|
|
550
|
+
reason: start_marker[:reason],
|
|
551
|
+
)
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
# Returns the FreezeNode class to use.
|
|
555
|
+
#
|
|
556
|
+
# Subclasses should override this to return their own FreezeNode class.
|
|
557
|
+
#
|
|
558
|
+
# @return [Class] FreezeNode class
|
|
559
|
+
def freeze_node_class
|
|
560
|
+
Ast::Merge::FreezeNodeBase
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
# Parse content within a freeze block.
|
|
564
|
+
#
|
|
565
|
+
# Subclasses should override this to use their parser.
|
|
566
|
+
#
|
|
567
|
+
# @param content [String] Content to parse
|
|
568
|
+
# @return [Array<Object>] Parsed nodes
|
|
569
|
+
def parse_freeze_block_content(content)
|
|
570
|
+
return [] if content.empty?
|
|
571
|
+
|
|
572
|
+
begin
|
|
573
|
+
content_doc = parse_document(content)
|
|
574
|
+
nodes = []
|
|
575
|
+
child = content_doc.first_child
|
|
576
|
+
while child
|
|
577
|
+
nodes << child
|
|
578
|
+
child = next_sibling(child)
|
|
579
|
+
end
|
|
580
|
+
nodes
|
|
581
|
+
rescue StandardError => e
|
|
582
|
+
# :nocov: defensive - parser rarely fails on valid markdown subset
|
|
583
|
+
DebugLogger.debug("Failed to parse freeze block content", {error: e.message})
|
|
584
|
+
[]
|
|
585
|
+
# :nocov:
|
|
586
|
+
end
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
# Integrate nodes with freeze blocks
|
|
590
|
+
# @param freeze_blocks [Array<FreezeNode>] Freeze blocks
|
|
591
|
+
# @param base_nodes [Array<Object>, nil] Base nodes (defaults to collect_top_level_nodes_with_gaps)
|
|
592
|
+
# @return [Array<Object>] Integrated list
|
|
593
|
+
def integrate_nodes_with_freeze_blocks(freeze_blocks, base_nodes = nil)
|
|
594
|
+
result = []
|
|
595
|
+
freeze_index = 0
|
|
596
|
+
current_freeze = freeze_blocks[freeze_index]
|
|
597
|
+
|
|
598
|
+
top_level_nodes = base_nodes || collect_top_level_nodes_with_gaps
|
|
599
|
+
|
|
600
|
+
top_level_nodes.each do |node|
|
|
601
|
+
node_start = node.source_position&.dig(:start_line) || 0
|
|
602
|
+
node_end = node.source_position&.dig(:end_line) || node_start
|
|
603
|
+
|
|
604
|
+
# Add any freeze blocks that come before this node
|
|
605
|
+
while current_freeze && current_freeze.start_line < node_start
|
|
606
|
+
result << current_freeze
|
|
607
|
+
freeze_index += 1
|
|
608
|
+
current_freeze = freeze_blocks[freeze_index]
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
# Skip nodes that are inside a freeze block
|
|
612
|
+
inside_freeze = freeze_blocks.any? do |fb|
|
|
613
|
+
node_start >= fb.start_line && node_end <= fb.end_line
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
result << node unless inside_freeze
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
# Add remaining freeze blocks
|
|
620
|
+
while freeze_index < freeze_blocks.size
|
|
621
|
+
result << freeze_blocks[freeze_index]
|
|
622
|
+
freeze_index += 1
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
result
|
|
626
|
+
end
|
|
627
|
+
end
|
|
628
|
+
end
|
|
629
|
+
end
|