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,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markdown
4
+ module Merge
5
+ # Shared boilerplate for backend-specific Markdown wrapper backends.
6
+ module BackendSupport
7
+ DEFAULT_CAPABILITIES = {
8
+ query: false,
9
+ bytes_field: false,
10
+ incremental: false,
11
+ pure_ruby: false,
12
+ markdown_only: true,
13
+ error_tolerant: true
14
+ }.freeze
15
+
16
+ module_function
17
+
18
+ def install!(backend_module:, backend_name:, gem_name:, require_path:, capabilities: {})
19
+ install_availability_methods!(
20
+ backend_module: backend_module,
21
+ backend_name: backend_name,
22
+ gem_name: gem_name,
23
+ capabilities: capabilities
24
+ )
25
+ install_tree_wrapper!(backend_module)
26
+ register_backend!(
27
+ backend_module: backend_module,
28
+ backend_name: backend_name,
29
+ gem_name: gem_name,
30
+ require_path: require_path
31
+ )
32
+ end
33
+
34
+ def configure_markdown_only_language_class!(klass, backend_label:, factory_method: :markdown,
35
+ unsupported_language_message: nil)
36
+ klass.singleton_class.class_eval do
37
+ define_method(:from_library) do |_path = nil, symbol: nil, name: nil|
38
+ lang_name = name || symbol&.to_s&.sub(/^tree_sitter_/, '')&.to_sym || :markdown
39
+
40
+ unless lang_name == :markdown
41
+ message = if unsupported_language_message.respond_to?(:call)
42
+ unsupported_language_message.call(lang_name)
43
+ else
44
+ unsupported_language_message || "#{backend_label} backend only supports Markdown, not #{lang_name}."
45
+ end
46
+ raise TreeHaver::NotAvailable, message
47
+ end
48
+
49
+ public_send(factory_method)
50
+ end
51
+ end
52
+ end
53
+
54
+ def configure_node_link_and_navigation!(klass, next_sibling_selector:, prev_sibling_selector:,
55
+ parent_selector: :parent)
56
+ klass.class_eval do
57
+ define_method(:url) do
58
+ Markdown::Merge::BackendSupport.send(:safe_inner_node_call, inner_node, :url)
59
+ end
60
+
61
+ define_method(:title) do
62
+ Markdown::Merge::BackendSupport.send(:safe_inner_node_call, inner_node, :title)
63
+ end
64
+
65
+ define_method(:next_sibling) do
66
+ sibling = Markdown::Merge::BackendSupport.send(:safe_inner_node_call, inner_node, next_sibling_selector)
67
+ sibling ? self.class.new(sibling, source: source, lines: lines) : nil
68
+ end
69
+
70
+ define_method(:prev_sibling) do
71
+ sibling = Markdown::Merge::BackendSupport.send(:safe_inner_node_call, inner_node, prev_sibling_selector)
72
+ sibling ? self.class.new(sibling, source: source, lines: lines) : nil
73
+ end
74
+
75
+ define_method(:parent) do
76
+ parent_node = Markdown::Merge::BackendSupport.send(:safe_inner_node_call, inner_node, parent_selector)
77
+ parent_node ? self.class.new(parent_node, source: source, lines: lines) : nil
78
+ end
79
+ end
80
+ end
81
+
82
+ def configure_node_heading_and_code_block_helpers!(klass, heading_matcher:, code_block_matcher:,
83
+ heading_level_extractor: nil,
84
+ code_block_info_extractor: nil)
85
+ klass.class_eval do
86
+ define_method(:header_level) do
87
+ return unless Markdown::Merge::BackendSupport.send(:node_helper_match?, self, heading_matcher)
88
+
89
+ if heading_level_extractor.respond_to?(:call)
90
+ heading_level_extractor.call(inner_node)
91
+ else
92
+ Markdown::Merge::BackendSupport.send(:safe_inner_node_call, inner_node, :header_level)
93
+ end
94
+ end
95
+
96
+ define_method(:fence_info) do
97
+ return unless Markdown::Merge::BackendSupport.send(:node_helper_match?, self, code_block_matcher)
98
+
99
+ if code_block_info_extractor.respond_to?(:call)
100
+ code_block_info_extractor.call(inner_node)
101
+ else
102
+ Markdown::Merge::BackendSupport.send(:safe_inner_node_call, inner_node, :fence_info)
103
+ end
104
+ end
105
+ end
106
+ end
107
+
108
+ def install_availability_methods!(backend_module:, backend_name:, gem_name:, capabilities: {})
109
+ backend_module.instance_variable_set(:@load_attempted, false)
110
+ backend_module.instance_variable_set(:@loaded, false)
111
+
112
+ merged_capabilities = DEFAULT_CAPABILITIES.merge(backend: backend_name).merge(capabilities)
113
+
114
+ backend_module.singleton_class.class_eval do
115
+ define_method(:available?) do
116
+ return @loaded if @load_attempted
117
+
118
+ @load_attempted = true
119
+ begin
120
+ require gem_name
121
+ @loaded = true
122
+ rescue LoadError, StandardError
123
+ @loaded = false
124
+ end
125
+ @loaded
126
+ end
127
+
128
+ define_method(:reset!) do
129
+ @load_attempted = false
130
+ @loaded = false
131
+ end
132
+
133
+ define_method(:capabilities) do
134
+ return {} unless available?
135
+
136
+ merged_capabilities.dup
137
+ end
138
+ end
139
+ end
140
+ private_class_method :install_availability_methods!
141
+
142
+ def install_tree_wrapper!(backend_module)
143
+ return if backend_module.const_defined?(:Tree, false)
144
+
145
+ tree_class = Class.new(::TreeHaver::Base::Tree) do
146
+ define_method(:initialize) do |document, source|
147
+ super(document, source: source)
148
+ end
149
+
150
+ define_method(:root_node) do
151
+ backend_module.const_get(:Node).new(inner_tree, source: source, lines: lines)
152
+ end
153
+ end
154
+
155
+ backend_module.const_set(:Tree, tree_class)
156
+ backend_module.const_set(:Point, ::TreeHaver::Base::Point) unless backend_module.const_defined?(:Point, false)
157
+ end
158
+ private_class_method :install_tree_wrapper!
159
+
160
+ def register_backend!(backend_module:, backend_name:, gem_name:, require_path:)
161
+ ::TreeHaver::BackendRegistry.register(
162
+ ::TreeHaver::BackendReference.new(id: backend_name.to_s, family: 'native')
163
+ )
164
+
165
+ ::TreeHaver.register_language(
166
+ :markdown,
167
+ backend_type: backend_name,
168
+ backend_module: backend_module,
169
+ gem_name: gem_name
170
+ )
171
+
172
+ if ::TreeHaver::BackendRegistry.respond_to?(:register_tag)
173
+ ::TreeHaver::BackendRegistry.register_tag(
174
+ :"#{backend_name}_backend",
175
+ category: :backend,
176
+ backend_name: backend_name,
177
+ require_path: require_path
178
+ ) { backend_module.available? }
179
+ else
180
+ ::TreeHaver::BackendRegistry.register_availability_checker(backend_name) do
181
+ backend_module.available?
182
+ end
183
+ end
184
+ end
185
+ private_class_method :register_backend!
186
+
187
+ def safe_inner_node_call(node, method_name)
188
+ node.public_send(method_name)
189
+ rescue StandardError
190
+ nil
191
+ end
192
+ private_class_method :safe_inner_node_call
193
+
194
+ def node_helper_match?(node, matcher)
195
+ matcher.respond_to?(:call) ? matcher.call(node) : false
196
+ end
197
+ private_class_method :node_helper_match?
198
+ end
199
+ end
200
+ end
@@ -0,0 +1,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'parslet'
4
+
5
+ module Markdown
6
+ module Merge
7
+ module Cleanse
8
+ # Fixes missing blank lines between block elements in Markdown.
9
+ #
10
+ # Markdown best practices require blank lines between:
11
+ # - List items and headings
12
+ # - Thematic breaks (---) and following content
13
+ # - HTML blocks (like </details>) and following markdown
14
+ # - Nested list items and headings
15
+ #
16
+ # This class detects and fixes these issues without using a full
17
+ # markdown parser, making it safe to use on documents that might
18
+ # have syntax issues.
19
+ #
20
+ # @example Basic usage
21
+ # fixer = Markdown::Merge::Cleanse::BlockSpacing.new(content)
22
+ # if fixer.malformed?
23
+ # fixed_content = fixer.fix
24
+ # end
25
+ #
26
+ # @example Check specific issues
27
+ # fixer = Markdown::Merge::Cleanse::BlockSpacing.new(content)
28
+ # fixer.issues.each do |issue|
29
+ # puts "Line #{issue[:line]}: #{issue[:type]}"
30
+ # end
31
+ #
32
+ class BlockSpacing
33
+ # Patterns for block elements that should have blank lines after them
34
+ THEMATIC_BREAK = /\A\s*(?:---+|\*\*\*+|___+)\s*\z/
35
+ HEADING = /\A\s*\#{1,6}\s+/
36
+ LIST_ITEM = /\A\s*(?:[-*+]|\d+\.)\s+/
37
+ HTML_CLOSE_TAG = %r{\A\s*</[a-zA-Z][a-zA-Z0-9]*>\s*\z}
38
+ HTML_OPEN_TAG = /\A\s*<[a-zA-Z][a-zA-Z0-9]*(?:\s|>)/
39
+ HTML_ANY_TAG = %r{\A\s*</?[a-zA-Z]}
40
+ LINK_REF_DEF = /\A\s*\[[^\]]+\]:\s*/
41
+
42
+ # Block-level HTML elements that can span multiple lines
43
+ # These create a context where we shouldn't insert blank lines
44
+ HTML_BLOCK_ELEMENTS = %w[
45
+ ul
46
+ ol
47
+ li
48
+ dl
49
+ dt
50
+ dd
51
+ div
52
+ table
53
+ thead
54
+ tbody
55
+ tfoot
56
+ tr
57
+ th
58
+ td
59
+ blockquote
60
+ pre
61
+ figure
62
+ figcaption
63
+ details
64
+ summary
65
+ section
66
+ article
67
+ aside
68
+ nav
69
+ header
70
+ footer
71
+ main
72
+ address
73
+ form
74
+ fieldset
75
+ ].freeze
76
+
77
+ # Pattern to match opening block-level HTML tags
78
+ HTML_BLOCK_OPEN = /\A\s*<(#{HTML_BLOCK_ELEMENTS.join('|')})(?:\s|>)/i
79
+
80
+ # Pattern to match closing block-level HTML tags
81
+ HTML_BLOCK_CLOSE = %r{\A\s*</(#{HTML_BLOCK_ELEMENTS.join('|')})>}i
82
+
83
+ # HTML elements that contain markdown content (not HTML content)
84
+ # These should have blank lines before their closing tags
85
+ MARKDOWN_CONTAINER_ELEMENTS = %w[details].freeze
86
+
87
+ # Pattern to match closing tags for markdown containers
88
+ MARKDOWN_CONTAINER_CLOSE = %r{\A\s*</(#{MARKDOWN_CONTAINER_ELEMENTS.join('|')})>}i
89
+
90
+ # Markdown content: anything that's not blank, not HTML, and not a link ref def
91
+ MARKDOWN_CONTENT = lambda { |line|
92
+ stripped = line.strip
93
+ return false if stripped.empty?
94
+ return false if stripped.start_with?('<')
95
+ return false if line.match?(LINK_REF_DEF)
96
+
97
+ true
98
+ }
99
+
100
+ # @return [String] The original content
101
+ attr_reader :source
102
+
103
+ # @return [Array<Hash>] Issues found
104
+ attr_reader :issues
105
+
106
+ # Initialize a new BlockSpacing fixer.
107
+ #
108
+ # @param source [String] The markdown content to analyze
109
+ def initialize(source)
110
+ @source = source
111
+ @issues = []
112
+ analyze
113
+ end
114
+
115
+ # Check if the content has block spacing issues.
116
+ #
117
+ # @return [Boolean] true if issues were found
118
+ def malformed?
119
+ @issues.any?
120
+ end
121
+
122
+ # Get the count of issues found.
123
+ #
124
+ # @return [Integer] number of issues
125
+ def issue_count
126
+ @issues.size
127
+ end
128
+
129
+ # Fix the block spacing issues.
130
+ #
131
+ # @return [String] Content with blank lines added where needed
132
+ def fix
133
+ return source unless malformed?
134
+
135
+ lines = source.lines
136
+ result = []
137
+ insertions = @issues.map { |i| i[:line] }.to_set
138
+
139
+ lines.each_with_index do |line, idx|
140
+ result << line
141
+ # If this line needs a blank line after it, add one
142
+ result << "\n" if insertions.include?(idx + 1) && !line.strip.empty? # issues use 1-based line numbers
143
+ end
144
+
145
+ result.join
146
+ end
147
+
148
+ private
149
+
150
+ def analyze
151
+ lines = source.lines
152
+ return if lines.empty?
153
+
154
+ # Track depth of block-level HTML elements
155
+ # When depth > 0, we're inside an HTML block and shouldn't add blank lines
156
+ html_block_depth = 0
157
+
158
+ lines.each_with_index do |line, idx|
159
+ next_line = lines[idx + 1]
160
+ prev_line = idx.positive? ? lines[idx - 1] : nil
161
+
162
+ # Special case: closing tags for markdown containers like </details>
163
+ # These contain markdown content, so we need blank lines before them
164
+ # even when inside an HTML block
165
+ is_markdown_container_close = line.match?(MARKDOWN_CONTAINER_CLOSE)
166
+
167
+ # Check for issues BEFORE updating depth
168
+ if html_block_depth <= 0
169
+ # Check for issues that need blank line AFTER current line
170
+ if next_line && !next_line.strip.empty?
171
+ check_thematic_break(line, next_line, idx)
172
+ check_list_before_heading(line, next_line, idx)
173
+ check_html_close_before_markdown(line, next_line, idx)
174
+ end
175
+
176
+ # Check for issues that need blank line BEFORE current line
177
+ check_markdown_before_html(prev_line, line, idx) if prev_line && !prev_line.strip.empty?
178
+ end
179
+
180
+ # Special case: always check for blank line before </details> etc.
181
+ # because they contain markdown content
182
+ if is_markdown_container_close && prev_line && !prev_line.strip.empty?
183
+ check_markdown_before_html(prev_line, line, idx)
184
+ end
185
+
186
+ # Update HTML block depth AFTER checking for issues
187
+ # Count opening block-level tags
188
+ html_block_depth += 1 if line.match?(HTML_BLOCK_OPEN)
189
+
190
+ # Check for closing block-level tags
191
+ line.scan(HTML_BLOCK_CLOSE) do
192
+ html_block_depth -= 1 if html_block_depth.positive?
193
+ end
194
+ end
195
+ end
196
+
197
+ def check_thematic_break(line, next_line, idx)
198
+ return unless line.match?(THEMATIC_BREAK)
199
+ return if next_line.strip.empty?
200
+
201
+ @issues << {
202
+ type: :thematic_break_needs_blank,
203
+ line: idx + 1,
204
+ description: 'Thematic break should be followed by blank line'
205
+ }
206
+ end
207
+
208
+ def check_list_before_heading(line, next_line, idx)
209
+ return unless line.match?(LIST_ITEM)
210
+ return unless next_line.match?(HEADING)
211
+
212
+ @issues << {
213
+ type: :list_before_heading,
214
+ line: idx + 1,
215
+ description: 'List item should be followed by blank line before heading'
216
+ }
217
+ end
218
+
219
+ def check_html_close_before_markdown(line, next_line, idx)
220
+ return unless line.match?(HTML_CLOSE_TAG)
221
+ # Next line is markdown (heading, list, paragraph start, etc.)
222
+ # but not HTML or blank
223
+ return if next_line.match?(/\A\s*</)
224
+ return if next_line.match?(LINK_REF_DEF)
225
+
226
+ @issues << {
227
+ type: :html_before_markdown,
228
+ line: idx + 1,
229
+ description: 'HTML close tag should be followed by blank line before markdown'
230
+ }
231
+ end
232
+
233
+ def check_markdown_before_html(prev_line, line, idx)
234
+ # Current line is HTML (open or close tag)
235
+ return unless line.match?(HTML_ANY_TAG)
236
+ # Previous line is markdown content (not HTML, not blank, not link ref)
237
+ return unless MARKDOWN_CONTENT.call(prev_line)
238
+
239
+ @issues << {
240
+ type: :markdown_before_html,
241
+ line: idx, # Insert blank line BEFORE this line (so after prev_line)
242
+ description: 'Markdown content should be followed by blank line before HTML'
243
+ }
244
+ end
245
+ end
246
+ end
247
+ end
248
+ end