markdown-merge 1.0.3 → 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 +99 -482
- data/lib/markdown/merge/backend_support.rb +200 -0
- data/lib/markdown/merge/cleanse/block_spacing.rb +18 -23
- data/lib/markdown/merge/cleanse/code_fence_spacing.rb +16 -16
- data/lib/markdown/merge/cleanse/condensed_link_refs.rb +36 -30
- 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 +5 -3
- data/lib/markdown/merge/code_block_match_refiner.rb +111 -0
- data/lib/markdown/merge/code_block_merger.rb +489 -47
- data/lib/markdown/merge/comment_tracker.rb +42 -0
- data/lib/markdown/merge/conflict_resolver.rb +77 -6
- data/lib/markdown/merge/debug_logger.rb +2 -2
- data/lib/markdown/merge/document_problems.rb +3 -3
- data/lib/markdown/merge/file_aligner.rb +433 -133
- data/lib/markdown/merge/file_analysis.rb +387 -51
- data/lib/markdown/merge/file_analysis_base.rb +188 -51
- data/lib/markdown/merge/gap_line_node.rb +14 -8
- data/lib/markdown/merge/link_definition_node.rb +5 -5
- data/lib/markdown/merge/link_parser.rb +60 -60
- data/lib/markdown/merge/link_reference_rehydrator.rb +14 -14
- 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 +3 -3
- data/lib/markdown/merge/merge_result.rb +321 -4
- data/lib/markdown/merge/node_type_normalizer.rb +6 -6
- data/lib/markdown/merge/output_builder.rb +101 -19
- data/lib/markdown/merge/partial_template_merger.rb +248 -27
- data/lib/markdown/merge/preservation_support.rb +291 -0
- data/lib/markdown/merge/smart_merger.rb +62 -14
- data/lib/markdown/merge/smart_merger_base.rb +929 -60
- data/lib/markdown/merge/table_match_algorithm.rb +22 -27
- data/lib/markdown/merge/table_match_refiner.rb +6 -10
- data/lib/markdown/merge/version.rb +5 -4
- data/lib/markdown/merge/whitespace_normalizer.rb +25 -33
- data/lib/markdown/merge/wrapper_support.rb +194 -0
- data/lib/markdown/merge.rb +669 -122
- data/lib/markdown-merge.rb +9 -4
- data/sig/markdown/merge.rbs +3 -336
- data.tar.gz.sig +0 -0
- metadata +104 -93
- metadata.gz.sig +0 -0
- data/CHANGELOG.md +0 -308
- data/CITATION.cff +0 -20
- data/CODE_OF_CONDUCT.md +0 -134
- data/CONTRIBUTING.md +0 -227
- data/FUNDING.md +0 -74
- data/LICENSE.txt +0 -21
- data/REEK +0 -0
- data/RUBOCOP.md +0 -71
- data/SECURITY.md +0 -21
|
@@ -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
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
3
|
+
require 'parslet'
|
|
4
4
|
|
|
5
5
|
module Markdown
|
|
6
6
|
module Merge
|
|
@@ -34,9 +34,9 @@ module Markdown
|
|
|
34
34
|
THEMATIC_BREAK = /\A\s*(?:---+|\*\*\*+|___+)\s*\z/
|
|
35
35
|
HEADING = /\A\s*\#{1,6}\s+/
|
|
36
36
|
LIST_ITEM = /\A\s*(?:[-*+]|\d+\.)\s+/
|
|
37
|
-
HTML_CLOSE_TAG =
|
|
37
|
+
HTML_CLOSE_TAG = %r{\A\s*</[a-zA-Z][a-zA-Z0-9]*>\s*\z}
|
|
38
38
|
HTML_OPEN_TAG = /\A\s*<[a-zA-Z][a-zA-Z0-9]*(?:\s|>)/
|
|
39
|
-
HTML_ANY_TAG =
|
|
39
|
+
HTML_ANY_TAG = %r{\A\s*</?[a-zA-Z]}
|
|
40
40
|
LINK_REF_DEF = /\A\s*\[[^\]]+\]:\s*/
|
|
41
41
|
|
|
42
42
|
# Block-level HTML elements that can span multiple lines
|
|
@@ -75,24 +75,25 @@ module Markdown
|
|
|
75
75
|
].freeze
|
|
76
76
|
|
|
77
77
|
# Pattern to match opening block-level HTML tags
|
|
78
|
-
HTML_BLOCK_OPEN = /\A\s*<(#{HTML_BLOCK_ELEMENTS.join(
|
|
78
|
+
HTML_BLOCK_OPEN = /\A\s*<(#{HTML_BLOCK_ELEMENTS.join('|')})(?:\s|>)/i
|
|
79
79
|
|
|
80
80
|
# Pattern to match closing block-level HTML tags
|
|
81
|
-
HTML_BLOCK_CLOSE =
|
|
81
|
+
HTML_BLOCK_CLOSE = %r{\A\s*</(#{HTML_BLOCK_ELEMENTS.join('|')})>}i
|
|
82
82
|
|
|
83
83
|
# HTML elements that contain markdown content (not HTML content)
|
|
84
84
|
# These should have blank lines before their closing tags
|
|
85
85
|
MARKDOWN_CONTAINER_ELEMENTS = %w[details].freeze
|
|
86
86
|
|
|
87
87
|
# Pattern to match closing tags for markdown containers
|
|
88
|
-
MARKDOWN_CONTAINER_CLOSE =
|
|
88
|
+
MARKDOWN_CONTAINER_CLOSE = %r{\A\s*</(#{MARKDOWN_CONTAINER_ELEMENTS.join('|')})>}i
|
|
89
89
|
|
|
90
90
|
# Markdown content: anything that's not blank, not HTML, and not a link ref def
|
|
91
|
-
MARKDOWN_CONTENT =
|
|
91
|
+
MARKDOWN_CONTENT = lambda { |line|
|
|
92
92
|
stripped = line.strip
|
|
93
93
|
return false if stripped.empty?
|
|
94
|
-
return false if stripped.start_with?(
|
|
94
|
+
return false if stripped.start_with?('<')
|
|
95
95
|
return false if line.match?(LINK_REF_DEF)
|
|
96
|
+
|
|
96
97
|
true
|
|
97
98
|
}
|
|
98
99
|
|
|
@@ -138,9 +139,7 @@ module Markdown
|
|
|
138
139
|
lines.each_with_index do |line, idx|
|
|
139
140
|
result << line
|
|
140
141
|
# If this line needs a blank line after it, add one
|
|
141
|
-
if insertions.include?(idx + 1) # issues use 1-based line numbers
|
|
142
|
-
result << "\n" unless line.strip.empty?
|
|
143
|
-
end
|
|
142
|
+
result << "\n" if insertions.include?(idx + 1) && !line.strip.empty? # issues use 1-based line numbers
|
|
144
143
|
end
|
|
145
144
|
|
|
146
145
|
result.join
|
|
@@ -158,7 +157,7 @@ module Markdown
|
|
|
158
157
|
|
|
159
158
|
lines.each_with_index do |line, idx|
|
|
160
159
|
next_line = lines[idx + 1]
|
|
161
|
-
prev_line =
|
|
160
|
+
prev_line = idx.positive? ? lines[idx - 1] : nil
|
|
162
161
|
|
|
163
162
|
# Special case: closing tags for markdown containers like </details>
|
|
164
163
|
# These contain markdown content, so we need blank lines before them
|
|
@@ -175,9 +174,7 @@ module Markdown
|
|
|
175
174
|
end
|
|
176
175
|
|
|
177
176
|
# Check for issues that need blank line BEFORE current line
|
|
178
|
-
if prev_line && !prev_line.strip.empty?
|
|
179
|
-
check_markdown_before_html(prev_line, line, idx)
|
|
180
|
-
end
|
|
177
|
+
check_markdown_before_html(prev_line, line, idx) if prev_line && !prev_line.strip.empty?
|
|
181
178
|
end
|
|
182
179
|
|
|
183
180
|
# Special case: always check for blank line before </details> etc.
|
|
@@ -188,13 +185,11 @@ module Markdown
|
|
|
188
185
|
|
|
189
186
|
# Update HTML block depth AFTER checking for issues
|
|
190
187
|
# Count opening block-level tags
|
|
191
|
-
if line.match?(HTML_BLOCK_OPEN)
|
|
192
|
-
html_block_depth += 1
|
|
193
|
-
end
|
|
188
|
+
html_block_depth += 1 if line.match?(HTML_BLOCK_OPEN)
|
|
194
189
|
|
|
195
190
|
# Check for closing block-level tags
|
|
196
191
|
line.scan(HTML_BLOCK_CLOSE) do
|
|
197
|
-
html_block_depth -= 1 if html_block_depth
|
|
192
|
+
html_block_depth -= 1 if html_block_depth.positive?
|
|
198
193
|
end
|
|
199
194
|
end
|
|
200
195
|
end
|
|
@@ -206,7 +201,7 @@ module Markdown
|
|
|
206
201
|
@issues << {
|
|
207
202
|
type: :thematic_break_needs_blank,
|
|
208
203
|
line: idx + 1,
|
|
209
|
-
description:
|
|
204
|
+
description: 'Thematic break should be followed by blank line'
|
|
210
205
|
}
|
|
211
206
|
end
|
|
212
207
|
|
|
@@ -217,7 +212,7 @@ module Markdown
|
|
|
217
212
|
@issues << {
|
|
218
213
|
type: :list_before_heading,
|
|
219
214
|
line: idx + 1,
|
|
220
|
-
description:
|
|
215
|
+
description: 'List item should be followed by blank line before heading'
|
|
221
216
|
}
|
|
222
217
|
end
|
|
223
218
|
|
|
@@ -231,7 +226,7 @@ module Markdown
|
|
|
231
226
|
@issues << {
|
|
232
227
|
type: :html_before_markdown,
|
|
233
228
|
line: idx + 1,
|
|
234
|
-
description:
|
|
229
|
+
description: 'HTML close tag should be followed by blank line before markdown'
|
|
235
230
|
}
|
|
236
231
|
end
|
|
237
232
|
|
|
@@ -244,7 +239,7 @@ module Markdown
|
|
|
244
239
|
@issues << {
|
|
245
240
|
type: :markdown_before_html,
|
|
246
241
|
line: idx, # Insert blank line BEFORE this line (so after prev_line)
|
|
247
|
-
description:
|
|
242
|
+
description: 'Markdown content should be followed by blank line before HTML'
|
|
248
243
|
}
|
|
249
244
|
end
|
|
250
245
|
end
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
3
|
+
require 'parslet'
|
|
4
4
|
|
|
5
5
|
module Markdown
|
|
6
6
|
module Merge
|
|
@@ -93,11 +93,11 @@ module Markdown
|
|
|
93
93
|
class CodeFenceGrammar < Parslet::Parser
|
|
94
94
|
# Any amount of indentation (handles code blocks in lists)
|
|
95
95
|
# Captured as string, not array
|
|
96
|
-
rule(:indent) { match(
|
|
96
|
+
rule(:indent) { match('[ ]').repeat }
|
|
97
97
|
|
|
98
98
|
# Fence markers - 3+ backticks or tildes
|
|
99
|
-
rule(:backtick) { str(
|
|
100
|
-
rule(:tilde) { str(
|
|
99
|
+
rule(:backtick) { str('`') }
|
|
100
|
+
rule(:tilde) { str('~') }
|
|
101
101
|
rule(:backtick_fence) { backtick.repeat(3, nil) }
|
|
102
102
|
rule(:tilde_fence) { tilde.repeat(3, nil) }
|
|
103
103
|
rule(:fence) { backtick_fence | tilde_fence }
|
|
@@ -117,9 +117,9 @@ module Markdown
|
|
|
117
117
|
|
|
118
118
|
# Fence line with optional indentation, optional spacing, optional info
|
|
119
119
|
# Capture: indent (raw), fence (as :fence), spacing (as :spacing), info (as :info)
|
|
120
|
-
rule(:fence_line)
|
|
120
|
+
rule(:fence_line) do
|
|
121
121
|
indent.as(:indent) >> fence.as(:fence) >> spaces?.as(:spacing) >> info_string.maybe.as(:info) >> line_end
|
|
122
|
-
|
|
122
|
+
end
|
|
123
123
|
|
|
124
124
|
root(:fence_line)
|
|
125
125
|
end
|
|
@@ -175,9 +175,9 @@ module Markdown
|
|
|
175
175
|
|
|
176
176
|
fence = parsed[:fence]
|
|
177
177
|
fence_char = fence[0]
|
|
178
|
-
spacing = parsed[:spacing] ||
|
|
179
|
-
info = parsed[:info] ||
|
|
180
|
-
indent = parsed[:indent] ||
|
|
178
|
+
spacing = parsed[:spacing] || ''
|
|
179
|
+
info = parsed[:info] || ''
|
|
180
|
+
indent = parsed[:indent] || ''
|
|
181
181
|
|
|
182
182
|
# Closing fence: matches current fence type and has no info
|
|
183
183
|
if in_code_block && fence_char == current_fence_char && info.empty?
|
|
@@ -192,7 +192,7 @@ module Markdown
|
|
|
192
192
|
|
|
193
193
|
# Extract just the language (first word of info string)
|
|
194
194
|
language = info.strip.split(/\s+/).first
|
|
195
|
-
language = nil if language
|
|
195
|
+
language = nil if language && language.empty?
|
|
196
196
|
|
|
197
197
|
@code_blocks << {
|
|
198
198
|
indent: indent,
|
|
@@ -202,7 +202,7 @@ module Markdown
|
|
|
202
202
|
spacing: spacing,
|
|
203
203
|
malformed: !spacing.empty? && !language.nil?,
|
|
204
204
|
line_number: line_number,
|
|
205
|
-
original: line.chomp
|
|
205
|
+
original: line.chomp
|
|
206
206
|
}
|
|
207
207
|
end
|
|
208
208
|
|
|
@@ -259,16 +259,16 @@ module Markdown
|
|
|
259
259
|
|
|
260
260
|
info_val = tree[:info]
|
|
261
261
|
info_str = if info_val.is_a?(Array)
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
262
|
+
info_val.join
|
|
263
|
+
else
|
|
264
|
+
(info_val ? info_val.to_s : '')
|
|
265
|
+
end
|
|
266
266
|
|
|
267
267
|
{
|
|
268
268
|
indent: indent_str,
|
|
269
269
|
fence: tree[:fence].to_s,
|
|
270
270
|
spacing: spacing_str,
|
|
271
|
-
info: info_str
|
|
271
|
+
info: info_str
|
|
272
272
|
}
|
|
273
273
|
rescue Parslet::ParseFailed
|
|
274
274
|
nil
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
3
|
+
require 'parslet'
|
|
4
4
|
|
|
5
5
|
module Markdown
|
|
6
6
|
module Merge
|
|
@@ -105,14 +105,14 @@ module Markdown
|
|
|
105
105
|
|
|
106
106
|
# Bracket content: handles nested brackets recursively
|
|
107
107
|
# Same as LinkParser::DefinitionGrammar
|
|
108
|
-
rule(:bracket_content)
|
|
108
|
+
rule(:bracket_content) do
|
|
109
109
|
(
|
|
110
|
-
str(
|
|
111
|
-
str(
|
|
110
|
+
str('[') >> bracket_content.maybe >> str(']') |
|
|
111
|
+
str(']').absent? >> any
|
|
112
112
|
).repeat
|
|
113
|
-
|
|
113
|
+
end
|
|
114
114
|
|
|
115
|
-
rule(:label) { str(
|
|
115
|
+
rule(:label) { str('[') >> bracket_content.as(:label) >> str(']') }
|
|
116
116
|
|
|
117
117
|
# URL characters - everything except whitespace, >, and [
|
|
118
118
|
# The [ is excluded because it signals the start of the next definition
|
|
@@ -120,36 +120,36 @@ module Markdown
|
|
|
120
120
|
rule(:bare_url) { url_char.repeat(1) }
|
|
121
121
|
|
|
122
122
|
# Angled URLs can contain [ since they're delimited by <>
|
|
123
|
-
rule(:angled_url_char) { match(
|
|
124
|
-
rule(:angled_url) { str(
|
|
123
|
+
rule(:angled_url_char) { match('[^>]') }
|
|
124
|
+
rule(:angled_url) { str('<') >> angled_url_char.repeat(1) >> str('>') }
|
|
125
125
|
|
|
126
126
|
rule(:url) { (angled_url | bare_url).as(:url) }
|
|
127
127
|
|
|
128
128
|
# Title handling (same as LinkParser)
|
|
129
129
|
rule(:title_content_double) { (str('"').absent? >> any).repeat }
|
|
130
130
|
rule(:title_content_single) { (str("'").absent? >> any).repeat }
|
|
131
|
-
rule(:title_content_paren) { (str(
|
|
131
|
+
rule(:title_content_paren) { (str(')').absent? >> any).repeat }
|
|
132
132
|
|
|
133
133
|
rule(:title_double) { str('"') >> title_content_double.as(:title) >> str('"') }
|
|
134
134
|
rule(:title_single) { str("'") >> title_content_single.as(:title) >> str("'") }
|
|
135
|
-
rule(:title_paren) { str(
|
|
135
|
+
rule(:title_paren) { str('(') >> title_content_paren.as(:title) >> str(')') }
|
|
136
136
|
rule(:title) { title_double | title_single | title_paren }
|
|
137
137
|
|
|
138
138
|
# A single definition
|
|
139
|
-
rule(:definition)
|
|
139
|
+
rule(:definition) do
|
|
140
140
|
spaces? >>
|
|
141
141
|
label >>
|
|
142
|
-
str(
|
|
142
|
+
str(':') >>
|
|
143
143
|
spaces? >>
|
|
144
144
|
url >>
|
|
145
145
|
(spaces >> title).maybe >>
|
|
146
146
|
spaces?
|
|
147
|
-
|
|
147
|
+
end
|
|
148
148
|
|
|
149
149
|
# Multiple definitions, possibly with or without newlines between them
|
|
150
|
-
rule(:definitions)
|
|
150
|
+
rule(:definitions) do
|
|
151
151
|
(definition.as(:definition) >> newlines?).repeat(1)
|
|
152
|
-
|
|
152
|
+
end
|
|
153
153
|
|
|
154
154
|
root(:definitions)
|
|
155
155
|
end
|
|
@@ -209,8 +209,8 @@ module Markdown
|
|
|
209
209
|
next unless parsed && !parsed.empty?
|
|
210
210
|
|
|
211
211
|
# Check if line has content before first definition
|
|
212
|
-
first_bracket = line.index(
|
|
213
|
-
has_prefix = first_bracket &&
|
|
212
|
+
first_bracket = line.index('[')
|
|
213
|
+
has_prefix = first_bracket&.positive? && !line[0...first_bracket].strip.empty?
|
|
214
214
|
|
|
215
215
|
# Include if: multiple definitions OR single definition with prefix
|
|
216
216
|
next unless parsed.size > 1 || has_prefix
|
|
@@ -272,11 +272,12 @@ module Markdown
|
|
|
272
272
|
# @return [Boolean] true if there's content before first `[label]:`
|
|
273
273
|
def has_content_before_definition?(line)
|
|
274
274
|
# Skip if no link definition pattern
|
|
275
|
-
return false unless line.include?(
|
|
275
|
+
return false unless line.include?(']:')
|
|
276
276
|
|
|
277
277
|
# Find first occurrence of [label]:
|
|
278
|
-
first_bracket = line.index(
|
|
278
|
+
first_bracket = line.index('[')
|
|
279
279
|
return false unless first_bracket
|
|
280
|
+
return false if inside_inline_code?(line, first_bracket)
|
|
280
281
|
|
|
281
282
|
# Check if there's non-whitespace content before it
|
|
282
283
|
prefix = line[0...first_bracket].strip
|
|
@@ -296,12 +297,13 @@ module Markdown
|
|
|
296
297
|
# @return [Array<Hash>, nil] array of definition parse trees, or nil if parse fails
|
|
297
298
|
def parse_line(line)
|
|
298
299
|
# Skip lines that don't look like link definitions
|
|
299
|
-
return unless line.include?(
|
|
300
|
+
return unless line.include?(']:')
|
|
300
301
|
|
|
301
302
|
# First, try to find where the first link definition starts
|
|
302
303
|
# Look for pattern: [anything]:
|
|
303
|
-
first_bracket = line.index(
|
|
304
|
+
first_bracket = line.index('[')
|
|
304
305
|
return unless first_bracket
|
|
306
|
+
return if inside_inline_code?(line, first_bracket)
|
|
305
307
|
|
|
306
308
|
# Try parsing from the first bracket onward
|
|
307
309
|
candidate = line[first_bracket..]
|
|
@@ -315,7 +317,7 @@ module Markdown
|
|
|
315
317
|
|
|
316
318
|
# Filter out non-definition nodes and return only definitions
|
|
317
319
|
defs.select { |node| node.is_a?(Hash) && node.key?(:definition) }
|
|
318
|
-
|
|
320
|
+
.map { |node| node[:definition] }
|
|
319
321
|
rescue Parslet::ParseFailed
|
|
320
322
|
nil
|
|
321
323
|
end
|
|
@@ -335,7 +337,7 @@ module Markdown
|
|
|
335
337
|
|
|
336
338
|
{
|
|
337
339
|
label: label,
|
|
338
|
-
url: clean_url(url)
|
|
340
|
+
url: clean_url(url)
|
|
339
341
|
}
|
|
340
342
|
end
|
|
341
343
|
|
|
@@ -352,8 +354,8 @@ module Markdown
|
|
|
352
354
|
return line unless parsed && !parsed.empty?
|
|
353
355
|
|
|
354
356
|
# Find where the first definition starts
|
|
355
|
-
first_bracket = line.index(
|
|
356
|
-
prefix =
|
|
357
|
+
first_bracket = line.index('[')
|
|
358
|
+
prefix = first_bracket&.positive? ? line[0...first_bracket].strip : ''
|
|
357
359
|
|
|
358
360
|
# Case 1: Multiple definitions - always expand
|
|
359
361
|
if parsed.size > 1
|
|
@@ -361,10 +363,10 @@ module Markdown
|
|
|
361
363
|
|
|
362
364
|
# First definition gets the prefix if present
|
|
363
365
|
result = if prefix && !prefix.empty?
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
366
|
+
"#{prefix}\n#{definitions.join("\n")}"
|
|
367
|
+
else
|
|
368
|
+
definitions.join("\n")
|
|
369
|
+
end
|
|
368
370
|
|
|
369
371
|
result += "\n" if line.end_with?("\n")
|
|
370
372
|
return result
|
|
@@ -397,7 +399,11 @@ module Markdown
|
|
|
397
399
|
# @return [String] cleaned URL
|
|
398
400
|
def clean_url(url)
|
|
399
401
|
url = url.strip
|
|
400
|
-
|
|
402
|
+
url.start_with?('<') && url.end_with?('>') ? url[1..-2] : url
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def inside_inline_code?(line, index)
|
|
406
|
+
line[0...index].count('`').odd?
|
|
401
407
|
end
|
|
402
408
|
end
|
|
403
409
|
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Markdown
|
|
4
|
+
module Merge
|
|
5
|
+
module Cleanse
|
|
6
|
+
# Repairs templating corruption where an ordered-list marker was prefixed
|
|
7
|
+
# onto an existing unordered-list marker, producing lines like:
|
|
8
|
+
#
|
|
9
|
+
# 1. - item
|
|
10
|
+
# 2. * item
|
|
11
|
+
#
|
|
12
|
+
# The repair keeps the original inner bullet marker and removes the
|
|
13
|
+
# synthetic ordered marker.
|
|
14
|
+
class ListMarkerDuplication
|
|
15
|
+
DUPLICATED_MARKER = /\A(?<indent>\s*)(?<number>\d+)\.\s+(?<bullet>[-*+])(?<tail>(?:\s+.*)?\s*)\z/
|
|
16
|
+
|
|
17
|
+
attr_reader :source, :issues
|
|
18
|
+
|
|
19
|
+
def initialize(source)
|
|
20
|
+
@source = source.to_s
|
|
21
|
+
@issues = []
|
|
22
|
+
analyze
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def malformed?
|
|
26
|
+
issues.any?
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def issue_count
|
|
30
|
+
issues.length
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def fix
|
|
34
|
+
return source unless malformed?
|
|
35
|
+
|
|
36
|
+
source.each_line.with_index(1).map do |line, line_number|
|
|
37
|
+
duplicated_marker_line?(line) ? repaired_line(line, line_number) : line
|
|
38
|
+
end.join
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def analyze
|
|
44
|
+
source.each_line.with_index(1) do |line, line_number|
|
|
45
|
+
next unless duplicated_marker_line?(line)
|
|
46
|
+
|
|
47
|
+
issues << {
|
|
48
|
+
type: :duplicated_list_marker,
|
|
49
|
+
line: line_number,
|
|
50
|
+
description: 'Ordered-list marker duplicated an existing unordered-list marker'
|
|
51
|
+
}
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def duplicated_marker_line?(line)
|
|
56
|
+
line.match?(DUPLICATED_MARKER)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def repaired_line(line, _line_number)
|
|
60
|
+
match = line.match(DUPLICATED_MARKER)
|
|
61
|
+
"#{match[:indent]}#{match[:bullet]}#{match[:tail]}"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|