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,251 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Markdown
|
|
4
|
+
module Merge
|
|
5
|
+
# Normalizes whitespace in markdown documents.
|
|
6
|
+
#
|
|
7
|
+
# Supports multiple normalization modes:
|
|
8
|
+
# - `:basic` (or `true`) - Collapse excessive blank lines (3+ → 2)
|
|
9
|
+
# - `:link_refs` - Also remove blank lines between consecutive link reference definitions
|
|
10
|
+
# - `:strict` - All of the above normalizations
|
|
11
|
+
#
|
|
12
|
+
# Uses {LinkParser} for detecting link reference definitions, which supports:
|
|
13
|
+
# - Standard definitions: `[label]: url`
|
|
14
|
+
# - Definitions with titles: `[label]: url "title"`
|
|
15
|
+
# - Angle-bracketed URLs: `[label]: <url>`
|
|
16
|
+
# - Emoji in labels: `[🎨logo]: url`
|
|
17
|
+
#
|
|
18
|
+
# @example Basic normalization (default)
|
|
19
|
+
# content = "Hello\n\n\n\nWorld"
|
|
20
|
+
# normalized = WhitespaceNormalizer.normalize(content)
|
|
21
|
+
# # => "Hello\n\nWorld"
|
|
22
|
+
#
|
|
23
|
+
# @example With link_refs mode
|
|
24
|
+
# content = "[link1]: url1\n\n[link2]: url2"
|
|
25
|
+
# normalized = WhitespaceNormalizer.normalize(content, mode: :link_refs)
|
|
26
|
+
# # => "[link1]: url1\n[link2]: url2"
|
|
27
|
+
#
|
|
28
|
+
# @example With problem tracking
|
|
29
|
+
# normalizer = WhitespaceNormalizer.new(content, mode: :link_refs)
|
|
30
|
+
# result = normalizer.normalize
|
|
31
|
+
# normalizer.problems.by_category(:link_ref_spacing)
|
|
32
|
+
#
|
|
33
|
+
class WhitespaceNormalizer
|
|
34
|
+
# Valid normalization modes
|
|
35
|
+
MODES = %i[basic link_refs strict].freeze
|
|
36
|
+
|
|
37
|
+
# @return [String] The original content
|
|
38
|
+
attr_reader :content
|
|
39
|
+
|
|
40
|
+
# @return [Symbol] The normalization mode
|
|
41
|
+
attr_reader :mode
|
|
42
|
+
|
|
43
|
+
# @return [DocumentProblems] Problems found during normalization
|
|
44
|
+
attr_reader :problems
|
|
45
|
+
|
|
46
|
+
class << self
|
|
47
|
+
# Normalize whitespace in content (class method for convenience).
|
|
48
|
+
#
|
|
49
|
+
# @param content [String] Content to normalize
|
|
50
|
+
# @param mode [Symbol, Boolean] Normalization mode (:basic, :link_refs, :strict, or true for :basic)
|
|
51
|
+
# @return [String] Normalized content
|
|
52
|
+
def normalize(content, mode: :basic)
|
|
53
|
+
new(content, mode: mode).normalize
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Initialize a new normalizer.
|
|
58
|
+
#
|
|
59
|
+
# @param content [String] Content to normalize
|
|
60
|
+
# @param mode [Symbol, Boolean] Normalization mode (:basic, :link_refs, :strict, or true for :basic)
|
|
61
|
+
def initialize(content, mode: :basic)
|
|
62
|
+
@content = content
|
|
63
|
+
@mode = normalize_mode(mode)
|
|
64
|
+
@problems = DocumentProblems.new
|
|
65
|
+
@link_parser = LinkParser.new
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Normalize whitespace based on the configured mode.
|
|
69
|
+
#
|
|
70
|
+
# @return [String] Normalized content
|
|
71
|
+
def normalize
|
|
72
|
+
result = content.dup
|
|
73
|
+
|
|
74
|
+
# Always collapse excessive blank lines (3+ → 2)
|
|
75
|
+
result = collapse_excessive_blank_lines(result)
|
|
76
|
+
|
|
77
|
+
# Remove blank lines between link refs if mode requires it
|
|
78
|
+
if @mode == :link_refs || @mode == :strict
|
|
79
|
+
result = remove_blank_lines_between_link_refs(result)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
result
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Check if normalization made any changes.
|
|
86
|
+
#
|
|
87
|
+
# @return [Boolean] true if content had whitespace issues
|
|
88
|
+
def changed?
|
|
89
|
+
!@problems.empty?
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Get count of normalizations performed.
|
|
93
|
+
#
|
|
94
|
+
# @return [Integer] Number of whitespace issues fixed
|
|
95
|
+
def normalization_count
|
|
96
|
+
@problems.count
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
# Normalize mode parameter to a symbol.
|
|
102
|
+
#
|
|
103
|
+
# @param mode [Symbol, Boolean] Input mode
|
|
104
|
+
# @return [Symbol] Normalized mode
|
|
105
|
+
def normalize_mode(mode)
|
|
106
|
+
case mode
|
|
107
|
+
when true
|
|
108
|
+
:basic
|
|
109
|
+
when false
|
|
110
|
+
:basic # Still do basic normalization
|
|
111
|
+
when Symbol
|
|
112
|
+
raise ArgumentError, "Unknown mode: #{mode}. Valid modes: #{MODES.join(", ")}" unless MODES.include?(mode)
|
|
113
|
+
|
|
114
|
+
mode
|
|
115
|
+
else
|
|
116
|
+
raise ArgumentError, "Mode must be a Symbol or Boolean, got: #{mode.class}"
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Collapse 3+ consecutive newlines to 2.
|
|
121
|
+
#
|
|
122
|
+
# This detects runs of blank lines (empty lines) and collapses them.
|
|
123
|
+
# Note: A blank line is a line containing only whitespace.
|
|
124
|
+
# 3+ consecutive newlines means 2+ blank lines.
|
|
125
|
+
#
|
|
126
|
+
# @param text [String] Text to process
|
|
127
|
+
# @return [String] Processed text
|
|
128
|
+
def collapse_excessive_blank_lines(text)
|
|
129
|
+
lines = text.lines
|
|
130
|
+
result = []
|
|
131
|
+
consecutive_blank_count = 0
|
|
132
|
+
problem_start_line = nil
|
|
133
|
+
line_number = 0
|
|
134
|
+
|
|
135
|
+
lines.each do |line|
|
|
136
|
+
line_number += 1
|
|
137
|
+
|
|
138
|
+
if line.chomp.empty?
|
|
139
|
+
consecutive_blank_count += 1
|
|
140
|
+
# The problem starts at the line BEFORE the first blank line
|
|
141
|
+
# (i.e., the line that ends with the first \n of the excessive sequence)
|
|
142
|
+
problem_start_line ||= line_number - 1
|
|
143
|
+
|
|
144
|
+
# Only add up to 1 blank line (which creates the standard paragraph gap)
|
|
145
|
+
if consecutive_blank_count <= 1
|
|
146
|
+
result << line
|
|
147
|
+
end
|
|
148
|
+
# Skip adding lines when consecutive_blank_count >= 2
|
|
149
|
+
else
|
|
150
|
+
# Record problem if we had 2+ blank lines (which means 3+ newlines)
|
|
151
|
+
# consecutive_blank_count is the count of blank lines, so >= 2 means excessive
|
|
152
|
+
if consecutive_blank_count >= 2
|
|
153
|
+
@problems.add(
|
|
154
|
+
:excessive_whitespace,
|
|
155
|
+
severity: :warning,
|
|
156
|
+
line: problem_start_line,
|
|
157
|
+
newline_count: consecutive_blank_count + 1, # +1 because first line ends with \n too
|
|
158
|
+
collapsed_to: 2,
|
|
159
|
+
)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
consecutive_blank_count = 0
|
|
163
|
+
problem_start_line = nil
|
|
164
|
+
result << line
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Handle trailing blank lines
|
|
169
|
+
if consecutive_blank_count >= 2
|
|
170
|
+
@problems.add(
|
|
171
|
+
:excessive_whitespace,
|
|
172
|
+
severity: :warning,
|
|
173
|
+
line: problem_start_line,
|
|
174
|
+
newline_count: consecutive_blank_count + 1,
|
|
175
|
+
collapsed_to: 2,
|
|
176
|
+
)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
result.join
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Remove blank lines between consecutive link reference definitions.
|
|
183
|
+
#
|
|
184
|
+
# Uses {LinkParser} to detect link definitions, supporting:
|
|
185
|
+
# - Standard: `[label]: url`
|
|
186
|
+
# - With title: `[label]: url "title"`
|
|
187
|
+
# - Angle-bracketed: `[label]: <url>`
|
|
188
|
+
# - Emoji labels: `[🎨logo]: url`
|
|
189
|
+
#
|
|
190
|
+
# @param text [String] Text to process
|
|
191
|
+
# @return [String] Processed text
|
|
192
|
+
def remove_blank_lines_between_link_refs(text)
|
|
193
|
+
lines = text.lines
|
|
194
|
+
result = []
|
|
195
|
+
i = 0
|
|
196
|
+
|
|
197
|
+
while i < lines.length
|
|
198
|
+
line = lines[i]
|
|
199
|
+
result << line
|
|
200
|
+
|
|
201
|
+
# Check if current line is a link ref definition using LinkParser
|
|
202
|
+
if link_definition_line?(line)
|
|
203
|
+
# Look ahead for blank lines followed by another link ref
|
|
204
|
+
j = i + 1
|
|
205
|
+
while j < lines.length
|
|
206
|
+
next_line = lines[j]
|
|
207
|
+
if next_line.chomp.empty?
|
|
208
|
+
# Check if there's a link ref definition after the blank line(s)
|
|
209
|
+
k = j + 1
|
|
210
|
+
while k < lines.length && lines[k].chomp.empty?
|
|
211
|
+
k += 1
|
|
212
|
+
end
|
|
213
|
+
if k < lines.length && link_definition_line?(lines[k])
|
|
214
|
+
# Skip all blank lines between link refs
|
|
215
|
+
blanks_skipped = k - j
|
|
216
|
+
@problems.add(
|
|
217
|
+
:link_ref_spacing,
|
|
218
|
+
severity: :info,
|
|
219
|
+
line: j + 1,
|
|
220
|
+
blank_lines_removed: blanks_skipped,
|
|
221
|
+
)
|
|
222
|
+
j = k
|
|
223
|
+
else
|
|
224
|
+
# Not followed by a link ref, keep the blank line
|
|
225
|
+
break
|
|
226
|
+
end
|
|
227
|
+
else
|
|
228
|
+
break
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
i = j
|
|
232
|
+
else
|
|
233
|
+
i += 1
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
result.join
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Check if a line is a link reference definition using LinkParser.
|
|
241
|
+
#
|
|
242
|
+
# @param line [String] Line to check
|
|
243
|
+
# @return [Boolean] true if line is a link definition
|
|
244
|
+
def link_definition_line?(line)
|
|
245
|
+
# Use LinkParser to attempt parsing the line as a definition
|
|
246
|
+
result = @link_parser.parse_definition_line(line.chomp)
|
|
247
|
+
!result.nil?
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# External gems
|
|
4
|
+
require "version_gem"
|
|
5
|
+
require "set"
|
|
6
|
+
|
|
7
|
+
# Shared merge infrastructure
|
|
8
|
+
require "ast/merge"
|
|
9
|
+
|
|
10
|
+
# tree_haver provides unified markdown parsing via multiple backends
|
|
11
|
+
require "tree_haver"
|
|
12
|
+
|
|
13
|
+
# This gem - only require version
|
|
14
|
+
require_relative "merge/version"
|
|
15
|
+
|
|
16
|
+
module Markdown
|
|
17
|
+
# Smart merging for Markdown files using AST-based parsers via tree_haver.
|
|
18
|
+
#
|
|
19
|
+
# Markdown::Merge provides intelligent Markdown merging with support for
|
|
20
|
+
# multiple parsing backends (Commonmarker, Markly) through tree_haver:
|
|
21
|
+
# - Standalone SmartMerger that works with any available backend
|
|
22
|
+
# - Matching structural elements (headings, paragraphs, lists, etc.) between files
|
|
23
|
+
# - Preserving frozen sections marked with HTML comments
|
|
24
|
+
# - Resolving conflicts based on configurable preferences
|
|
25
|
+
# - Node type normalization for portable merge rules across backends
|
|
26
|
+
#
|
|
27
|
+
# Can be used directly or through parser-specific wrappers
|
|
28
|
+
# (commonmarker-merge, markly-merge) that provide hard dependencies
|
|
29
|
+
# and backend-specific defaults.
|
|
30
|
+
#
|
|
31
|
+
# @example Direct usage with auto backend detection
|
|
32
|
+
# require "markdown/merge"
|
|
33
|
+
# merger = Markdown::Merge::SmartMerger.new(template, destination)
|
|
34
|
+
# result = merger.merge
|
|
35
|
+
#
|
|
36
|
+
# @example With specific backend
|
|
37
|
+
# merger = Markdown::Merge::SmartMerger.new(
|
|
38
|
+
# template,
|
|
39
|
+
# destination,
|
|
40
|
+
# backend: :markly,
|
|
41
|
+
# flags: Markly::DEFAULT,
|
|
42
|
+
# extensions: [:table, :strikethrough]
|
|
43
|
+
# )
|
|
44
|
+
# result = merger.merge
|
|
45
|
+
#
|
|
46
|
+
# @example Using via commonmarker-merge
|
|
47
|
+
# require "commonmarker/merge"
|
|
48
|
+
# merger = Commonmarker::Merge::SmartMerger.new(template, destination)
|
|
49
|
+
# result = merger.merge
|
|
50
|
+
#
|
|
51
|
+
# @see SmartMerger Main entry point for merging
|
|
52
|
+
# @see FileAnalysis For parsing and analyzing Markdown files
|
|
53
|
+
# @see NodeTypeNormalizer For type normalization across backends
|
|
54
|
+
module Merge
|
|
55
|
+
# Base error class for Markdown::Merge
|
|
56
|
+
# Inherits from Ast::Merge::Error for consistency across merge gems.
|
|
57
|
+
class Error < Ast::Merge::Error; end
|
|
58
|
+
|
|
59
|
+
# Raised when a Markdown file has parsing errors.
|
|
60
|
+
# Inherits from Ast::Merge::ParseError for consistency across merge gems.
|
|
61
|
+
#
|
|
62
|
+
# @example Handling parse errors
|
|
63
|
+
# begin
|
|
64
|
+
# analysis = FileAnalysis.new(markdown_content)
|
|
65
|
+
# rescue ParseError => e
|
|
66
|
+
# puts "Markdown syntax error: #{e.message}"
|
|
67
|
+
# e.errors.each { |error| puts " #{error}" }
|
|
68
|
+
# end
|
|
69
|
+
class ParseError < Ast::Merge::ParseError
|
|
70
|
+
# @param message [String, nil] Error message (auto-generated if nil)
|
|
71
|
+
# @param content [String, nil] The Markdown source that failed to parse
|
|
72
|
+
# @param errors [Array] Parse errors from Markdown
|
|
73
|
+
def initialize(message = nil, content: nil, errors: [])
|
|
74
|
+
super(message, errors: errors, content: content)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Raised when the template file has syntax errors.
|
|
79
|
+
#
|
|
80
|
+
# @example Handling template parse errors
|
|
81
|
+
# begin
|
|
82
|
+
# merger = SmartMerger.new(template, destination)
|
|
83
|
+
# result = merger.merge
|
|
84
|
+
# rescue TemplateParseError => e
|
|
85
|
+
# puts "Template syntax error: #{e.message}"
|
|
86
|
+
# e.errors.each { |error| puts " #{error.message}" }
|
|
87
|
+
# end
|
|
88
|
+
class TemplateParseError < ParseError; end
|
|
89
|
+
|
|
90
|
+
# Raised when the destination file has syntax errors.
|
|
91
|
+
#
|
|
92
|
+
# @example Handling destination parse errors
|
|
93
|
+
# begin
|
|
94
|
+
# merger = SmartMerger.new(template, destination)
|
|
95
|
+
# result = merger.merge
|
|
96
|
+
# rescue DestinationParseError => e
|
|
97
|
+
# puts "Destination syntax error: #{e.message}"
|
|
98
|
+
# e.errors.each { |error| puts " #{error.message}" }
|
|
99
|
+
# end
|
|
100
|
+
class DestinationParseError < ParseError; end
|
|
101
|
+
|
|
102
|
+
# Autoload all components - base classes
|
|
103
|
+
autoload :Cleanse, "markdown/merge/cleanse"
|
|
104
|
+
autoload :DebugLogger, "markdown/merge/debug_logger"
|
|
105
|
+
autoload :FreezeNode, "markdown/merge/freeze_node"
|
|
106
|
+
autoload :FileAnalysisBase, "markdown/merge/file_analysis_base"
|
|
107
|
+
autoload :FileAligner, "markdown/merge/file_aligner"
|
|
108
|
+
autoload :ConflictResolver, "markdown/merge/conflict_resolver"
|
|
109
|
+
autoload :MergeResult, "markdown/merge/merge_result"
|
|
110
|
+
autoload :TableMatchAlgorithm, "markdown/merge/table_match_algorithm"
|
|
111
|
+
autoload :TableMatchRefiner, "markdown/merge/table_match_refiner"
|
|
112
|
+
autoload :CodeBlockMerger, "markdown/merge/code_block_merger"
|
|
113
|
+
autoload :SmartMergerBase, "markdown/merge/smart_merger_base"
|
|
114
|
+
autoload :LinkDefinitionNode, "markdown/merge/link_definition_node"
|
|
115
|
+
autoload :GapLineNode, "markdown/merge/gap_line_node"
|
|
116
|
+
autoload :OutputBuilder, "markdown/merge/output_builder"
|
|
117
|
+
autoload :LinkDefinitionFormatter, "markdown/merge/link_definition_formatter"
|
|
118
|
+
autoload :MarkdownStructure, "markdown/merge/markdown_structure"
|
|
119
|
+
autoload :DocumentProblems, "markdown/merge/document_problems"
|
|
120
|
+
autoload :WhitespaceNormalizer, "markdown/merge/whitespace_normalizer"
|
|
121
|
+
autoload :LinkParser, "markdown/merge/link_parser"
|
|
122
|
+
autoload :LinkReferenceRehydrator, "markdown/merge/link_reference_rehydrator"
|
|
123
|
+
|
|
124
|
+
# Autoload concrete implementations (tree_haver-based)
|
|
125
|
+
autoload :NodeTypeNormalizer, "markdown/merge/node_type_normalizer"
|
|
126
|
+
autoload :FileAnalysis, "markdown/merge/file_analysis"
|
|
127
|
+
autoload :SmartMerger, "markdown/merge/smart_merger"
|
|
128
|
+
autoload :PartialTemplateMerger, "markdown/merge/partial_template_merger"
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Register with ast-merge's MergeGemRegistry for RSpec dependency tags
|
|
133
|
+
# Note: markdown-merge requires a backend (markly or commonmarker) to instantiate,
|
|
134
|
+
# so we use skip_instantiation: true
|
|
135
|
+
# Only register if MergeGemRegistry is loaded (i.e., in test environment)
|
|
136
|
+
if defined?(Ast::Merge::RSpec::MergeGemRegistry)
|
|
137
|
+
Ast::Merge::RSpec::MergeGemRegistry.register(
|
|
138
|
+
:markdown_merge,
|
|
139
|
+
require_path: "markdown/merge",
|
|
140
|
+
merger_class: "Markdown::Merge::SmartMerger",
|
|
141
|
+
test_source: "# Test\n\nParagraph",
|
|
142
|
+
category: :markdown,
|
|
143
|
+
skip_instantiation: true,
|
|
144
|
+
)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
Markdown::Merge::Version.class_eval do
|
|
148
|
+
extend VersionGem::Basic
|
|
149
|
+
end
|