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.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +251 -0
  4. data/CITATION.cff +20 -0
  5. data/CODE_OF_CONDUCT.md +134 -0
  6. data/CONTRIBUTING.md +227 -0
  7. data/FUNDING.md +74 -0
  8. data/LICENSE.txt +21 -0
  9. data/README.md +1087 -0
  10. data/REEK +0 -0
  11. data/RUBOCOP.md +71 -0
  12. data/SECURITY.md +21 -0
  13. data/lib/markdown/merge/cleanse/block_spacing.rb +253 -0
  14. data/lib/markdown/merge/cleanse/code_fence_spacing.rb +294 -0
  15. data/lib/markdown/merge/cleanse/condensed_link_refs.rb +405 -0
  16. data/lib/markdown/merge/cleanse.rb +42 -0
  17. data/lib/markdown/merge/code_block_merger.rb +300 -0
  18. data/lib/markdown/merge/conflict_resolver.rb +128 -0
  19. data/lib/markdown/merge/debug_logger.rb +26 -0
  20. data/lib/markdown/merge/document_problems.rb +190 -0
  21. data/lib/markdown/merge/file_aligner.rb +196 -0
  22. data/lib/markdown/merge/file_analysis.rb +353 -0
  23. data/lib/markdown/merge/file_analysis_base.rb +629 -0
  24. data/lib/markdown/merge/freeze_node.rb +93 -0
  25. data/lib/markdown/merge/gap_line_node.rb +136 -0
  26. data/lib/markdown/merge/link_definition_formatter.rb +49 -0
  27. data/lib/markdown/merge/link_definition_node.rb +157 -0
  28. data/lib/markdown/merge/link_parser.rb +421 -0
  29. data/lib/markdown/merge/link_reference_rehydrator.rb +320 -0
  30. data/lib/markdown/merge/markdown_structure.rb +123 -0
  31. data/lib/markdown/merge/merge_result.rb +166 -0
  32. data/lib/markdown/merge/node_type_normalizer.rb +126 -0
  33. data/lib/markdown/merge/output_builder.rb +166 -0
  34. data/lib/markdown/merge/partial_template_merger.rb +334 -0
  35. data/lib/markdown/merge/smart_merger.rb +221 -0
  36. data/lib/markdown/merge/smart_merger_base.rb +621 -0
  37. data/lib/markdown/merge/table_match_algorithm.rb +504 -0
  38. data/lib/markdown/merge/table_match_refiner.rb +136 -0
  39. data/lib/markdown/merge/version.rb +12 -0
  40. data/lib/markdown/merge/whitespace_normalizer.rb +251 -0
  41. data/lib/markdown/merge.rb +149 -0
  42. data/lib/markdown-merge.rb +4 -0
  43. data/sig/markdown/merge.rbs +341 -0
  44. data.tar.gz.sig +0 -0
  45. metadata +365 -0
  46. 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
@@ -0,0 +1,4 @@
1
+ # For technical reasons, if we move to Zeitwerk, this cannot be require_relative.
2
+ # See: https://github.com/fxn/zeitwerk#for_gem_extension
3
+ # Hook for other libraries to load this library (e.g. via bundler)
4
+ require "markdown/merge"