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,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Markdown
|
|
4
|
+
module Merge
|
|
5
|
+
# Alias for the shared normalizer module from ast-merge
|
|
6
|
+
NodeTypingNormalizer = Ast::Merge::NodeTyping::Normalizer
|
|
7
|
+
|
|
8
|
+
# Normalizes backend-specific node types to canonical markdown types.
|
|
9
|
+
#
|
|
10
|
+
# Uses Ast::Merge::NodeTyping::Wrapper to wrap nodes with canonical
|
|
11
|
+
# merge_type, allowing portable merge rules across backends.
|
|
12
|
+
#
|
|
13
|
+
# ## Thread Safety
|
|
14
|
+
#
|
|
15
|
+
# All backend registration and lookup operations are thread-safe via
|
|
16
|
+
# the shared Ast::Merge::NodeTyping::Normalizer module.
|
|
17
|
+
#
|
|
18
|
+
# ## Extensibility
|
|
19
|
+
#
|
|
20
|
+
# New backends can be registered at runtime:
|
|
21
|
+
#
|
|
22
|
+
# @example Registering a new backend
|
|
23
|
+
# NodeTypeNormalizer.register_backend(:tree_sitter_markdown, {
|
|
24
|
+
# atx_heading: :heading,
|
|
25
|
+
# setext_heading: :heading,
|
|
26
|
+
# fenced_code_block: :code_block,
|
|
27
|
+
# indented_code_block: :code_block,
|
|
28
|
+
# paragraph: :paragraph,
|
|
29
|
+
# bullet_list: :list,
|
|
30
|
+
# ordered_list: :list,
|
|
31
|
+
# block_quote: :block_quote,
|
|
32
|
+
# thematic_break: :thematic_break,
|
|
33
|
+
# html_block: :html_block,
|
|
34
|
+
# pipe_table: :table,
|
|
35
|
+
# })
|
|
36
|
+
#
|
|
37
|
+
# ## Canonical Types
|
|
38
|
+
#
|
|
39
|
+
# The following canonical types are used for portable merge rules:
|
|
40
|
+
# - `:heading` - Headers/headings (H1-H6)
|
|
41
|
+
# - `:paragraph` - Text paragraphs
|
|
42
|
+
# - `:code_block` - Fenced or indented code blocks
|
|
43
|
+
# - `:list` - Ordered or unordered lists
|
|
44
|
+
# - `:block_quote` - Block quotations
|
|
45
|
+
# - `:thematic_break` - Horizontal rules
|
|
46
|
+
# - `:html_block` - Raw HTML blocks
|
|
47
|
+
# - `:table` - Tables (GFM extension)
|
|
48
|
+
# - `:footnote_definition` - Footnote definitions
|
|
49
|
+
# - `:custom_block` - Custom/extension blocks
|
|
50
|
+
#
|
|
51
|
+
# @see Ast::Merge::NodeTyping::Wrapper
|
|
52
|
+
# @see Ast::Merge::NodeTyping::Normalizer
|
|
53
|
+
module NodeTypeNormalizer
|
|
54
|
+
extend NodeTypingNormalizer
|
|
55
|
+
|
|
56
|
+
# Configure default backend mappings.
|
|
57
|
+
# Maps backend-specific type symbols to canonical type symbols.
|
|
58
|
+
#
|
|
59
|
+
# Includes both top-level block types and child node types (table rows, cells, etc.)
|
|
60
|
+
# to enable consistent type checking across the entire AST.
|
|
61
|
+
configure_normalizer(
|
|
62
|
+
commonmarker: {
|
|
63
|
+
# Block types (top-level statements)
|
|
64
|
+
heading: :heading,
|
|
65
|
+
paragraph: :paragraph,
|
|
66
|
+
code_block: :code_block,
|
|
67
|
+
list: :list,
|
|
68
|
+
block_quote: :block_quote,
|
|
69
|
+
thematic_break: :thematic_break,
|
|
70
|
+
html_block: :html_block,
|
|
71
|
+
table: :table,
|
|
72
|
+
footnote_definition: :footnote_definition,
|
|
73
|
+
# Table child types
|
|
74
|
+
table_row: :table_row,
|
|
75
|
+
table_cell: :table_cell,
|
|
76
|
+
table_header: :table_header, # Some parsers distinguish header rows
|
|
77
|
+
# List child types
|
|
78
|
+
list_item: :list_item,
|
|
79
|
+
item: :list_item, # Alias
|
|
80
|
+
# Inline types (usually not top-level, but map them anyway)
|
|
81
|
+
text: :text,
|
|
82
|
+
softbreak: :softbreak,
|
|
83
|
+
linebreak: :linebreak,
|
|
84
|
+
code: :code,
|
|
85
|
+
code_inline: :code, # Alias used by some parsers
|
|
86
|
+
html_inline: :html_inline,
|
|
87
|
+
emph: :emph,
|
|
88
|
+
strong: :strong,
|
|
89
|
+
link: :link,
|
|
90
|
+
image: :image,
|
|
91
|
+
}.freeze,
|
|
92
|
+
markly: {
|
|
93
|
+
# Block types - note different names from commonmarker
|
|
94
|
+
header: :heading, # markly uses :header, not :heading
|
|
95
|
+
paragraph: :paragraph,
|
|
96
|
+
code_block: :code_block,
|
|
97
|
+
list: :list,
|
|
98
|
+
blockquote: :block_quote, # markly uses :blockquote, not :block_quote
|
|
99
|
+
hrule: :thematic_break, # markly uses :hrule, not :thematic_break
|
|
100
|
+
html: :html_block, # markly uses :html, not :html_block
|
|
101
|
+
table: :table,
|
|
102
|
+
footnote_definition: :footnote_definition,
|
|
103
|
+
custom_block: :custom_block,
|
|
104
|
+
# Table child types
|
|
105
|
+
table_row: :table_row,
|
|
106
|
+
table_cell: :table_cell,
|
|
107
|
+
table_header: :table_header,
|
|
108
|
+
# List child types
|
|
109
|
+
list_item: :list_item,
|
|
110
|
+
item: :list_item,
|
|
111
|
+
# Inline types
|
|
112
|
+
text: :text,
|
|
113
|
+
softbreak: :softbreak,
|
|
114
|
+
linebreak: :linebreak,
|
|
115
|
+
code: :code,
|
|
116
|
+
code_inline: :code,
|
|
117
|
+
html_inline: :html_inline,
|
|
118
|
+
emph: :emph,
|
|
119
|
+
strong: :strong,
|
|
120
|
+
link: :link,
|
|
121
|
+
image: :image,
|
|
122
|
+
}.freeze,
|
|
123
|
+
)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Markdown
|
|
4
|
+
module Merge
|
|
5
|
+
# Builds markdown output from merge operations.
|
|
6
|
+
#
|
|
7
|
+
# Handles markdown-specific concerns like:
|
|
8
|
+
# - Extracting source from original nodes
|
|
9
|
+
# - Reconstructing consumed link reference definitions
|
|
10
|
+
# - Preserving gap lines (blank line spacing)
|
|
11
|
+
# - Automatic structural spacing (blank lines between tables, headings, etc.)
|
|
12
|
+
# - Assembling final merged content
|
|
13
|
+
#
|
|
14
|
+
# Unlike Emitter classes used in JSON/YAML/etc, OutputBuilder focuses on
|
|
15
|
+
# source preservation and reconstruction rather than generation from scratch.
|
|
16
|
+
#
|
|
17
|
+
# @example Basic usage
|
|
18
|
+
# builder = OutputBuilder.new
|
|
19
|
+
# builder.add_node_source(node, analysis)
|
|
20
|
+
# builder.add_link_definition(link_def_node)
|
|
21
|
+
# builder.add_gap_line(count: 2)
|
|
22
|
+
# content = builder.to_s
|
|
23
|
+
class OutputBuilder
|
|
24
|
+
# Initialize a new OutputBuilder
|
|
25
|
+
#
|
|
26
|
+
# @param preserve_formatting [Boolean] Whether to preserve original formatting
|
|
27
|
+
# @param auto_spacing [Boolean] Whether to automatically insert blank lines between structural elements
|
|
28
|
+
def initialize(preserve_formatting: true, auto_spacing: true)
|
|
29
|
+
@parts = []
|
|
30
|
+
@preserve_formatting = preserve_formatting
|
|
31
|
+
@auto_spacing = auto_spacing
|
|
32
|
+
@last_node_type = nil # Track previous node type for spacing decisions
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Add a node's source content
|
|
36
|
+
#
|
|
37
|
+
# Automatically inserts structural blank lines when transitioning between
|
|
38
|
+
# certain node types (tables, headings, code blocks, etc.) if auto_spacing is enabled.
|
|
39
|
+
#
|
|
40
|
+
# @param node [Object] Node to add (can be parser node, FreezeNode, LinkDefinitionNode, etc.)
|
|
41
|
+
# @param analysis [FileAnalysisBase] Analysis for accessing source
|
|
42
|
+
def add_node_source(node, analysis)
|
|
43
|
+
# Determine node type for spacing decisions
|
|
44
|
+
current_type = MarkdownStructure.node_type(node)
|
|
45
|
+
|
|
46
|
+
# Auto-spacing logic:
|
|
47
|
+
# - Skip for gap_line and freeze_block (they handle their own spacing)
|
|
48
|
+
# - Skip if last node was a gap_line (we already have spacing)
|
|
49
|
+
# - Otherwise, check MarkdownStructure.needs_blank_between? which handles
|
|
50
|
+
# contiguous types (like link_definitions that shouldn't have blanks between them)
|
|
51
|
+
unless [:gap_line, :freeze_block].include?(current_type) ||
|
|
52
|
+
@last_node_type == :gap_line
|
|
53
|
+
if @auto_spacing && @last_node_type && current_type
|
|
54
|
+
if MarkdownStructure.needs_blank_between?(@last_node_type, current_type)
|
|
55
|
+
# Only add spacing if we don't already have adequate blank lines
|
|
56
|
+
# Check the last part to see if it already ends with blank line(s)
|
|
57
|
+
unless @parts.empty? || @parts.last&.end_with?("\n\n")
|
|
58
|
+
add_gap_line(count: 1)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
content = extract_source(node, analysis)
|
|
65
|
+
if content && !content.empty?
|
|
66
|
+
@parts << content
|
|
67
|
+
# Update last node type (track all node types for proper spacing)
|
|
68
|
+
@last_node_type = current_type
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Add a reconstructed link definition
|
|
73
|
+
#
|
|
74
|
+
# @param node [LinkDefinitionNode] Link definition node
|
|
75
|
+
def add_link_definition(node)
|
|
76
|
+
formatted = LinkDefinitionFormatter.format(node)
|
|
77
|
+
@parts << formatted if formatted && !formatted.empty?
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Add gap lines (blank line preservation)
|
|
81
|
+
#
|
|
82
|
+
# @param count [Integer] Number of blank lines to add
|
|
83
|
+
def add_gap_line(count: 1)
|
|
84
|
+
@parts << ("\n" * count) if count > 0
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Add raw text content
|
|
88
|
+
#
|
|
89
|
+
# @param text [String] Raw text to add
|
|
90
|
+
def add_raw(text)
|
|
91
|
+
@parts << text if text && !text.empty?
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Get final content
|
|
95
|
+
#
|
|
96
|
+
# @return [String] Assembled markdown content
|
|
97
|
+
def to_s
|
|
98
|
+
@parts.join
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Check if builder has any content
|
|
102
|
+
#
|
|
103
|
+
# @return [Boolean]
|
|
104
|
+
def empty?
|
|
105
|
+
@parts.empty?
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Clear all content
|
|
109
|
+
def clear
|
|
110
|
+
@parts.clear
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
# Extract source content from a node
|
|
116
|
+
#
|
|
117
|
+
# @param node [Object] Node to extract from
|
|
118
|
+
# @param analysis [FileAnalysisBase] Analysis for source access
|
|
119
|
+
# @return [String, nil] Extracted content
|
|
120
|
+
def extract_source(node, analysis)
|
|
121
|
+
case node
|
|
122
|
+
when LinkDefinitionNode
|
|
123
|
+
# Link definitions need reconstruction with trailing newline
|
|
124
|
+
"#{LinkDefinitionFormatter.format(node)}\n"
|
|
125
|
+
when GapLineNode
|
|
126
|
+
# Gap lines are single blank lines
|
|
127
|
+
"\n"
|
|
128
|
+
when Ast::Merge::FreezeNodeBase
|
|
129
|
+
# Freeze blocks have their full text
|
|
130
|
+
node.full_text
|
|
131
|
+
else
|
|
132
|
+
# Regular nodes - extract from source
|
|
133
|
+
extract_parser_node_source(node, analysis)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Extract source from a parser-specific node
|
|
138
|
+
#
|
|
139
|
+
# @param node [Object] Parser node
|
|
140
|
+
# @param analysis [FileAnalysisBase] Analysis for source access
|
|
141
|
+
# @return [String, nil] Extracted content
|
|
142
|
+
def extract_parser_node_source(node, analysis)
|
|
143
|
+
# Try source_position method first (used by some nodes)
|
|
144
|
+
if node.respond_to?(:source_position)
|
|
145
|
+
pos = node.source_position
|
|
146
|
+
start_line = pos&.dig(:start_line)
|
|
147
|
+
end_line = pos&.dig(:end_line)
|
|
148
|
+
|
|
149
|
+
if start_line && end_line
|
|
150
|
+
return analysis.source_range(start_line, end_line)
|
|
151
|
+
elsif node.respond_to?(:to_commonmark)
|
|
152
|
+
# Fallback to commonmark rendering
|
|
153
|
+
return node.to_commonmark
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Try direct start_line/end_line attributes
|
|
158
|
+
return unless node.respond_to?(:start_line) && node.respond_to?(:end_line)
|
|
159
|
+
return unless node.start_line && node.end_line
|
|
160
|
+
|
|
161
|
+
# Extract source range (formatting preservation handled elsewhere)
|
|
162
|
+
analysis.source_range(node.start_line, node.end_line)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Markdown
|
|
4
|
+
module Merge
|
|
5
|
+
# Markdown-specific implementation of PartialTemplateMerger.
|
|
6
|
+
#
|
|
7
|
+
# Merges a partial template into a specific section of a destination markdown document.
|
|
8
|
+
# This class extends the parser-agnostic base with markdown-specific logic for:
|
|
9
|
+
# - Heading-level-aware section boundaries
|
|
10
|
+
# - Source-based text extraction to preserve link references and table formatting
|
|
11
|
+
# - Backend-specific parser initialization (Markly, Commonmarker)
|
|
12
|
+
#
|
|
13
|
+
# @example Basic usage
|
|
14
|
+
# merger = Markdown::Merge::PartialTemplateMerger.new(
|
|
15
|
+
# template: template_content,
|
|
16
|
+
# destination: destination_content,
|
|
17
|
+
# anchor: { type: :heading, text: /Gem Family/ },
|
|
18
|
+
# backend: :markly
|
|
19
|
+
# )
|
|
20
|
+
# result = merger.merge
|
|
21
|
+
# puts result.content
|
|
22
|
+
#
|
|
23
|
+
# @example With boundary
|
|
24
|
+
# merger = Markdown::Merge::PartialTemplateMerger.new(
|
|
25
|
+
# template: template_content,
|
|
26
|
+
# destination: destination_content,
|
|
27
|
+
# anchor: { type: :heading, text: /Installation/ },
|
|
28
|
+
# boundary: { type: :heading }, # Stop at next heading
|
|
29
|
+
# backend: :markly
|
|
30
|
+
# )
|
|
31
|
+
#
|
|
32
|
+
class PartialTemplateMerger < Ast::Merge::PartialTemplateMergerBase
|
|
33
|
+
# Re-export Result class from base for convenience
|
|
34
|
+
Result = Ast::Merge::PartialTemplateMergerBase::Result
|
|
35
|
+
|
|
36
|
+
# @return [Symbol] Backend to use (:markly, :commonmarker)
|
|
37
|
+
attr_reader :backend
|
|
38
|
+
|
|
39
|
+
# Initialize a markdown PartialTemplateMerger.
|
|
40
|
+
#
|
|
41
|
+
# @param template [String] The template content (the section to merge in)
|
|
42
|
+
# @param destination [String] The destination content
|
|
43
|
+
# @param anchor [Hash] Anchor matcher: { type: :heading, text: /pattern/ }
|
|
44
|
+
# @param boundary [Hash, nil] Boundary matcher (defaults to same type as anchor)
|
|
45
|
+
# @param backend [Symbol] Backend to use (:markly, :commonmarker)
|
|
46
|
+
# @param preference [Symbol, Hash] Which content wins (:template, :destination, or per-type hash)
|
|
47
|
+
# @param add_missing [Boolean, Proc] Whether to add template nodes not in destination
|
|
48
|
+
# @param when_missing [Symbol] What to do if section not found (:skip, :append, :prepend)
|
|
49
|
+
# @param replace_mode [Boolean] If true, template replaces section entirely (no merge)
|
|
50
|
+
# @param signature_generator [Proc, nil] Custom signature generator for SmartMerger
|
|
51
|
+
# @param node_typing [Hash, nil] Node typing configuration for per-type preferences
|
|
52
|
+
# @param match_refiner [Object, nil] Match refiner for fuzzy matching (e.g., ContentMatchRefiner)
|
|
53
|
+
# @param normalize_whitespace [Boolean] If true, collapse excessive blank lines. Default: false
|
|
54
|
+
# @param rehydrate_link_references [Boolean] If true, convert inline links to reference style. Default: false
|
|
55
|
+
def initialize(
|
|
56
|
+
template:,
|
|
57
|
+
destination:,
|
|
58
|
+
anchor:,
|
|
59
|
+
boundary: nil,
|
|
60
|
+
backend: :markly,
|
|
61
|
+
preference: :template,
|
|
62
|
+
add_missing: true,
|
|
63
|
+
when_missing: :skip,
|
|
64
|
+
replace_mode: false,
|
|
65
|
+
signature_generator: nil,
|
|
66
|
+
node_typing: nil,
|
|
67
|
+
match_refiner: nil,
|
|
68
|
+
normalize_whitespace: false,
|
|
69
|
+
rehydrate_link_references: false
|
|
70
|
+
)
|
|
71
|
+
validate_backend!(backend)
|
|
72
|
+
@backend = backend
|
|
73
|
+
@normalize_whitespace = normalize_whitespace
|
|
74
|
+
@rehydrate_link_references = rehydrate_link_references
|
|
75
|
+
super(
|
|
76
|
+
template: template,
|
|
77
|
+
destination: destination,
|
|
78
|
+
anchor: anchor,
|
|
79
|
+
boundary: boundary,
|
|
80
|
+
preference: preference,
|
|
81
|
+
add_missing: add_missing,
|
|
82
|
+
when_missing: when_missing,
|
|
83
|
+
replace_mode: replace_mode,
|
|
84
|
+
signature_generator: signature_generator,
|
|
85
|
+
node_typing: node_typing,
|
|
86
|
+
match_refiner: match_refiner,
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Perform the partial template merge with post-processing.
|
|
91
|
+
#
|
|
92
|
+
# @return [Result] The merge result
|
|
93
|
+
def merge
|
|
94
|
+
result = super
|
|
95
|
+
|
|
96
|
+
# Apply post-processing if enabled
|
|
97
|
+
if result.changed && (@normalize_whitespace || @rehydrate_link_references)
|
|
98
|
+
content = result.content
|
|
99
|
+
problems = DocumentProblems.new
|
|
100
|
+
|
|
101
|
+
if @normalize_whitespace
|
|
102
|
+
normalizer = WhitespaceNormalizer.new(content)
|
|
103
|
+
content = normalizer.normalize
|
|
104
|
+
problems.merge!(normalizer.problems)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
if @rehydrate_link_references
|
|
108
|
+
rehydrator = LinkReferenceRehydrator.new(content)
|
|
109
|
+
content = rehydrator.rehydrate
|
|
110
|
+
problems.merge!(rehydrator.problems)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Return new result with transformed content and problems
|
|
114
|
+
Result.new(
|
|
115
|
+
content: content,
|
|
116
|
+
has_section: result.has_section,
|
|
117
|
+
changed: result.changed,
|
|
118
|
+
stats: result.stats.merge(problems: problems.all),
|
|
119
|
+
injection_point: result.injection_point,
|
|
120
|
+
message: result.message,
|
|
121
|
+
)
|
|
122
|
+
else
|
|
123
|
+
result
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
protected
|
|
128
|
+
|
|
129
|
+
# Validate the backend parameter.
|
|
130
|
+
#
|
|
131
|
+
# @param backend [Symbol] The backend to validate
|
|
132
|
+
# @raise [ArgumentError] If backend is not supported
|
|
133
|
+
def validate_backend!(backend)
|
|
134
|
+
valid_backends = [:markly, :commonmarker]
|
|
135
|
+
return if valid_backends.include?(backend.to_sym)
|
|
136
|
+
|
|
137
|
+
raise ArgumentError, "Unknown backend: #{backend}. Supported: #{valid_backends.join(", ")}"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Create a FileAnalysis for the given content.
|
|
141
|
+
#
|
|
142
|
+
# @param content [String] The content to analyze
|
|
143
|
+
# @return [FileAnalysis] A FileAnalysis instance
|
|
144
|
+
def create_analysis(content)
|
|
145
|
+
FileAnalysis.new(content, backend: backend)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Create a SmartMerger for merging the section.
|
|
149
|
+
#
|
|
150
|
+
# @param template_content [String] The template content
|
|
151
|
+
# @param destination_content [String] The destination section content
|
|
152
|
+
# @return [SmartMerger] A SmartMerger instance
|
|
153
|
+
def create_smart_merger(template_content, destination_content)
|
|
154
|
+
# Build options hash, only including non-nil values
|
|
155
|
+
options = {
|
|
156
|
+
preference: preference,
|
|
157
|
+
add_template_only_nodes: add_missing,
|
|
158
|
+
backend: backend,
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
# Use custom signature generator if provided, otherwise use position-based
|
|
162
|
+
# table matching to ensure tables with different structures still match
|
|
163
|
+
# within a section merge context.
|
|
164
|
+
options[:signature_generator] = signature_generator || build_position_based_signature_generator
|
|
165
|
+
|
|
166
|
+
options[:node_typing] = node_typing if node_typing
|
|
167
|
+
options[:match_refiner] = match_refiner if match_refiner
|
|
168
|
+
|
|
169
|
+
SmartMerger.new(template_content, destination_content, **options)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Build a signature generator that uses type-based matching for tables.
|
|
173
|
+
#
|
|
174
|
+
# This ensures that tables within a section are matched by type alone,
|
|
175
|
+
# allowing template tables to replace destination tables regardless of
|
|
176
|
+
# their exact structure (different headers, columns, etc.).
|
|
177
|
+
#
|
|
178
|
+
# In the context of partial template merging, this is the desired behavior:
|
|
179
|
+
# - Sections typically contain one table of each logical role
|
|
180
|
+
# - Template table should replace the destination table
|
|
181
|
+
# - Different table structures should still match by ordinal position
|
|
182
|
+
#
|
|
183
|
+
# The algorithm uses a stateless approach that assigns the same signature
|
|
184
|
+
# to all tables. Since PartialTemplateMerger merges **one section at a time**,
|
|
185
|
+
# each section typically has few tables, and the first table in template
|
|
186
|
+
# will match and replace the first table in destination.
|
|
187
|
+
#
|
|
188
|
+
# For more precise control over multiple tables within a section, provide
|
|
189
|
+
# a custom signature_generator.
|
|
190
|
+
#
|
|
191
|
+
# @return [Proc] A signature generator proc
|
|
192
|
+
def build_position_based_signature_generator
|
|
193
|
+
# Simple stateless approach: all tables get the same base signature.
|
|
194
|
+
# When preference is :template, this causes template table to replace
|
|
195
|
+
# destination table, which is the desired behavior.
|
|
196
|
+
#
|
|
197
|
+
# NOTE: If a section has multiple tables, they will ALL match each other,
|
|
198
|
+
# potentially causing unexpected behavior. For such cases, users should
|
|
199
|
+
# provide a custom signature_generator.
|
|
200
|
+
lambda do |node|
|
|
201
|
+
type_str = node.type.to_s
|
|
202
|
+
if type_str == "table"
|
|
203
|
+
# All tables within a section merge get the same signature.
|
|
204
|
+
# This ensures template table replaces destination table.
|
|
205
|
+
[:table, :section_table]
|
|
206
|
+
else
|
|
207
|
+
# Return node for default signature computation
|
|
208
|
+
node
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Find where the section ends.
|
|
214
|
+
#
|
|
215
|
+
# For headings, finds the next heading of same or higher level.
|
|
216
|
+
# For other node types, finds the next node of the same type.
|
|
217
|
+
#
|
|
218
|
+
# NOTE: For headings, we ALWAYS use heading-level-aware logic, ignoring
|
|
219
|
+
# any boundary from InjectionPointFinder. This is because InjectionPointFinder
|
|
220
|
+
# uses tree_depth for boundary detection, but in Markdown all headings are
|
|
221
|
+
# siblings at the same tree depth regardless of their level (H2, H3, H4 etc).
|
|
222
|
+
# Heading level semantics require comparing the actual heading level numbers.
|
|
223
|
+
#
|
|
224
|
+
# @param statements [Array<Navigable::Statement>] All statements
|
|
225
|
+
# @param injection_point [Navigable::InjectionPoint] The injection point
|
|
226
|
+
# @return [Integer] Index of the last statement in the section
|
|
227
|
+
def find_section_end(statements, injection_point)
|
|
228
|
+
anchor = injection_point.anchor
|
|
229
|
+
anchor_type = anchor.type
|
|
230
|
+
|
|
231
|
+
# For headings, ALWAYS use heading-level-aware logic
|
|
232
|
+
# This overrides any boundary from InjectionPointFinder because tree_depth
|
|
233
|
+
# doesn't reflect heading level semantics in Markdown
|
|
234
|
+
if heading_type?(anchor_type)
|
|
235
|
+
anchor_level = get_heading_level(anchor)
|
|
236
|
+
|
|
237
|
+
((anchor.index + 1)...statements.length).each do |idx|
|
|
238
|
+
stmt = statements[idx]
|
|
239
|
+
if heading_type?(stmt.type)
|
|
240
|
+
stmt_level = get_heading_level(stmt)
|
|
241
|
+
if stmt_level && anchor_level && stmt_level <= anchor_level
|
|
242
|
+
# Found next heading of same or higher level - section ends before it
|
|
243
|
+
return idx - 1
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# No boundary heading found - section extends to end of document
|
|
249
|
+
return statements.length - 1
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# For non-headings, use boundary if specified and found
|
|
253
|
+
if injection_point.boundary
|
|
254
|
+
return injection_point.boundary.index - 1
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Otherwise, find next node of same type
|
|
258
|
+
((anchor.index + 1)...statements.length).each do |idx|
|
|
259
|
+
stmt = statements[idx]
|
|
260
|
+
if stmt.type == anchor_type
|
|
261
|
+
return idx - 1
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Section extends to end of document
|
|
266
|
+
statements.length - 1
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# Convert a node to its source text.
|
|
270
|
+
#
|
|
271
|
+
# Prefers source-based extraction to preserve original formatting
|
|
272
|
+
# (link references, table padding, etc.). Falls back to to_commonmark.
|
|
273
|
+
#
|
|
274
|
+
# @param node [Object] The node to convert
|
|
275
|
+
# @param analysis [FileAnalysis, nil] The analysis object for source lookup
|
|
276
|
+
# @return [String] The source text
|
|
277
|
+
def node_to_text(node, analysis = nil)
|
|
278
|
+
# Unwrap if needed
|
|
279
|
+
inner = node
|
|
280
|
+
while inner.respond_to?(:inner_node) && inner.inner_node != inner
|
|
281
|
+
inner = inner.inner_node
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Prefer source-based extraction to preserve original formatting
|
|
285
|
+
# (link references, table padding, etc.)
|
|
286
|
+
if analysis&.respond_to?(:source_range)
|
|
287
|
+
pos = inner.source_position if inner.respond_to?(:source_position)
|
|
288
|
+
if pos
|
|
289
|
+
start_line = pos[:start_line]
|
|
290
|
+
end_line = pos[:end_line]
|
|
291
|
+
if start_line && end_line && start_line > 0
|
|
292
|
+
source_text = analysis.source_range(start_line, end_line)
|
|
293
|
+
# source_range already adds trailing newlines, don't add another
|
|
294
|
+
return source_text unless source_text.empty?
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Fallback to to_commonmark (for nodes without source position)
|
|
300
|
+
if inner.respond_to?(:to_commonmark)
|
|
301
|
+
inner.to_commonmark.to_s
|
|
302
|
+
elsif inner.respond_to?(:to_s)
|
|
303
|
+
inner.to_s
|
|
304
|
+
else
|
|
305
|
+
""
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
private
|
|
310
|
+
|
|
311
|
+
# Check if a type represents a heading node.
|
|
312
|
+
#
|
|
313
|
+
# @param type [Symbol, String] The node type
|
|
314
|
+
# @return [Boolean] true if this is a heading type
|
|
315
|
+
def heading_type?(type)
|
|
316
|
+
type.to_s == "heading" || type == :heading || type == :header
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# Get the heading level from a statement.
|
|
320
|
+
#
|
|
321
|
+
# @param stmt [NavigableStatement] The statement
|
|
322
|
+
# @return [Integer, nil] The heading level (1-6) or nil
|
|
323
|
+
def get_heading_level(stmt)
|
|
324
|
+
inner = stmt.respond_to?(:unwrapped_node) ? stmt.unwrapped_node : stmt.node
|
|
325
|
+
|
|
326
|
+
if inner.respond_to?(:header_level)
|
|
327
|
+
inner.header_level
|
|
328
|
+
elsif inner.respond_to?(:level)
|
|
329
|
+
inner.level
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
end
|