markdown-merge 7.0.0 → 7.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/LICENSE.md +13 -0
  4. data/README.md +673 -0
  5. data/lib/markdown/merge/backend_support.rb +200 -0
  6. data/lib/markdown/merge/cleanse/block_spacing.rb +248 -0
  7. data/lib/markdown/merge/cleanse/code_fence_spacing.rb +294 -0
  8. data/lib/markdown/merge/cleanse/condensed_link_refs.rb +411 -0
  9. data/lib/markdown/merge/cleanse/list_marker_duplication.rb +66 -0
  10. data/lib/markdown/merge/cleanse/templating_corruption.rb +86 -0
  11. data/lib/markdown/merge/cleanse.rb +44 -0
  12. data/lib/markdown/merge/code_block_match_refiner.rb +111 -0
  13. data/lib/markdown/merge/code_block_merger.rb +742 -0
  14. data/lib/markdown/merge/comment_tracker.rb +42 -0
  15. data/lib/markdown/merge/conflict_resolver.rb +199 -0
  16. data/lib/markdown/merge/debug_logger.rb +26 -0
  17. data/lib/markdown/merge/document_problems.rb +190 -0
  18. data/lib/markdown/merge/file_aligner.rb +496 -0
  19. data/lib/markdown/merge/file_analysis.rb +689 -0
  20. data/lib/markdown/merge/file_analysis_base.rb +766 -0
  21. data/lib/markdown/merge/freeze_node.rb +93 -0
  22. data/lib/markdown/merge/gap_line_node.rb +142 -0
  23. data/lib/markdown/merge/link_definition_formatter.rb +49 -0
  24. data/lib/markdown/merge/link_definition_node.rb +157 -0
  25. data/lib/markdown/merge/link_parser.rb +421 -0
  26. data/lib/markdown/merge/link_reference_rehydrator.rb +320 -0
  27. data/lib/markdown/merge/list_match_refiner.rb +98 -0
  28. data/lib/markdown/merge/list_merger.rb +322 -0
  29. data/lib/markdown/merge/markdown_structure.rb +123 -0
  30. data/lib/markdown/merge/merge_result.rb +483 -0
  31. data/lib/markdown/merge/node_type_normalizer.rb +126 -0
  32. data/lib/markdown/merge/output_builder.rb +248 -0
  33. data/lib/markdown/merge/partial_template_merger.rb +555 -0
  34. data/lib/markdown/merge/preservation_support.rb +291 -0
  35. data/lib/markdown/merge/rspec/shared_examples/source_preserving_provider.rb +338 -0
  36. data/lib/markdown/merge/smart_merger.rb +269 -0
  37. data/lib/markdown/merge/smart_merger_base.rb +1490 -0
  38. data/lib/markdown/merge/source_preserving_provider.rb +814 -0
  39. data/lib/markdown/merge/table_match_algorithm.rb +499 -0
  40. data/lib/markdown/merge/table_match_refiner.rb +132 -0
  41. data/lib/markdown/merge/version.rb +5 -3
  42. data/lib/markdown/merge/whitespace_normalizer.rb +243 -0
  43. data/lib/markdown/merge/wrapper_support.rb +194 -0
  44. data/lib/markdown/merge.rb +271 -87
  45. data/lib/markdown-merge.rb +7 -1
  46. data/sig/markdown/merge.rbs +62 -0
  47. data.tar.gz.sig +0 -0
  48. metadata +289 -15
  49. metadata.gz.sig +0 -0
data/README.md ADDED
@@ -0,0 +1,673 @@
1
+ <a href="https://github.com/structuredmerge"><img alt="structuredmerge Logo by GitHub" src="https://github.com/structuredmerge.png?size=192" width="14%" align="right"/></a>
2
+
3
+ # ☯️ Markdown::Merge
4
+
5
+ [![Version][👽versioni]][👽version] [![Ruby Users Forum][✉️ruby-forum-top-img]][✉️ruby-forum] [![GitHub tag (latest SemVer)][⛳️tag-img]][⛳️tag] [![License: AGPL-3.0-only OR PolyForm-Small-Business-1.0.0][📄license-img]][📄license] [![Total downloads][👽dl-ranki]][👽dl-rank] [![CI Current][🚎11-c-wfi]][🚎11-c-wf]
6
+
7
+ `if ci_badges.map(&:color).detect { it != "green"}` ☝️ [let me know on Discord][✉️discord-invite] or [RubyForum][✉️ruby-forum], as I may have missed the notification.
8
+
9
+ ---
10
+
11
+ `if ci_badges.map(&:color).all? { it == "green"}` 👇️ send money so I can do more of this. FLOSS maintenance is now my full-time job.
12
+
13
+ [![Sponsor Me on Github][🖇sponsor-img]][🖇sponsor] [![Liberapay Goal Progress][⛳liberapay-img]][⛳liberapay] [![Donate on PayPal][🖇paypal-img]][🖇paypal] [![Buy me a coffee][🖇buyme-small-img]][🖇buyme] [![Donate at ko-fi.com][🖇kofi-img]][🖇kofi]
14
+
15
+ <details markdown="1">
16
+ <summary>👣 How will this project approach the September 2025 hostile takeover of RubyGems? 🚑️</summary>
17
+
18
+ I've summarized my thoughts in [this blog post](https://dev.to/galtzo/hostile-takeover-of-rubygems-my-thoughts-5hlo).
19
+
20
+ </details>
21
+
22
+ ## 🌻 Synopsis <a href="https://discord.gg/3qme4XHNKN"><img alt="Galtzo FLOSS Logo by Aboling0, CC BY-SA 4.0" src="https://logos.galtzo.com/assets/images/galtzo-floss/avatar-128px.svg" width="8%" align="right"/></a> <a href="https://ruby-toolbox.com"><img alt="ruby-lang Logo, Yukihiro Matsumoto, Ruby Visual Identity Team, CC BY-SA 2.5" src="https://logos.galtzo.com/assets/images/ruby-lang/avatar-128px.svg" width="8%" align="right"/></a>
23
+
24
+ Markdown::Merge provides **intelligent Markdown file merging** using tree\_haver backends. It can be used standalone or through parser-specific wrappers.
25
+
26
+ **Direct usage** (with auto-detected or specified backend):
27
+
28
+ ```ruby
29
+ require "markdown/merge"
30
+
31
+ # Auto-detect available backend (commonmarker or markly)
32
+ merger = Markdown::Merge::SmartMerger.new(template_content, dest_content)
33
+ result = merger.merge
34
+
35
+ # Or specify a backend explicitly
36
+ merger = Markdown::Merge::SmartMerger.new(template_content, dest_content, backend: :markly)
37
+ ```
38
+
39
+ **Via parser-specific wrappers** (for hard dependencies and backend-specific defaults):
40
+
41
+ - [commonmarker-merge][commonmarker-merge] - Uses Comrak (Rust) via Commonmarker
42
+ - [markly-merge][markly-merge] - Uses libcmark-gfm (C) via Markly
43
+
44
+ ### Key Features
45
+
46
+ - **Multiple Backends**: Supports Commonmarker and Markly through tree\_haver's unified API
47
+ - **Type Normalization**: Canonical node types (`:heading`, `:paragraph`, etc.) work across all backends
48
+ - **Extensible**: Register custom backends via `NodeTypeNormalizer.register_backend`
49
+ - **Structure-Aware**: Understands headings, paragraphs, lists, code blocks, tables, and other block elements
50
+ - **Freeze Block Support**: Respects freeze markers (default: `markdown-merge:freeze` / `markdown-merge:unfreeze`) for template merge control - customizable to match your project's conventions
51
+ - **Inner-Merge Code Blocks**: Optionally merge fenced code blocks using language-specific mergers (Ruby → prism-merge, YAML → psych-merge, JSON → json-merge, TOML → toml-merge)
52
+ - **Table Match Refiner**: Fuzzy matching algorithm for tables with similar but not identical headers
53
+ - **Full Provenance**: Tracks origin of every node
54
+ - **Customizable**:
55
+ - `backend` - select `:commonmarker`, `:markly`, or `:auto`
56
+ - `signature_generator` - callable custom signature generators
57
+ - `preference` - setting of `:template`, `:destination`, or a Hash for per-node-type preferences
58
+ - `add_template_only_nodes` - setting to retain sections that do not exist in destination
59
+ - `freeze_token` - customize freeze block markers (default: `"markdown-merge"`)
60
+ - `inner_merge_code_blocks` - enable language-aware code block merging
61
+ - `match_refiner` - fuzzy matching for unmatched nodes (e.g., `TableMatchRefiner`)
62
+
63
+ ### Supported Node Types
64
+
65
+ Signatures computed by default for common Markdown block elements:
66
+
67
+ | Node Type | Signature Format | Matching Behavior |
68
+ |---------------------|-----------------------------------------|-----------------------------------------------------|
69
+ | Heading | `[:heading, level, text]` | Headings match by level and text content |
70
+ | Paragraph | `[:paragraph, content_hash]` | Paragraphs match by content hash |
71
+ | List | `[:list, type, item_count]` | Lists match by type (bullet/ordered) and item count |
72
+ | Code Block | `[:code_block, language, content_hash]` | Code blocks match by language and content |
73
+ | Block Quote | `[:blockquote, content_hash]` | Block quotes match by content hash |
74
+ | Table | `[:table, row_count, header_hash]` | Tables match by structure and header content |
75
+ | HTML Block | `[:html, content_hash]` | HTML blocks match by content hash |
76
+ | Thematic Break | `[:hrule]` | Horizontal rules always match |
77
+ | Footnote Definition | `[:footnote_definition, label]` | Footnotes match by label/name |
78
+
79
+ [commonmarker-merge]: https://github.com/structuredmerge/structuredmerge-ruby/tree/main/gems/commonmarker-merge
80
+ [markly-merge]: https://github.com/structuredmerge/structuredmerge-ruby/tree/main/gems/markly-merge
81
+
82
+ ## 💡 Info you can shake a stick at
83
+
84
+ | Tokens to Remember | [![Gem name][⛳️name-img]][⛳️gem-name] [![Gem namespace][⛳️namespace-img]][⛳️gem-namespace] |
85
+ |-------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
86
+ | Works with MRI Ruby 4 | [![Ruby current Compat][💎ruby-c-i]][🚎11-c-wf]|
87
+ | Support & Community | [![Join Me on Daily.dev's RubyFriends][✉️ruby-friends-img]][✉️ruby-friends] [![Get help from RubyForum][✉️ruby-forum-img]][✉️ruby-forum] [![Live Chat on Discord][✉️discord-invite-img-ftb]][✉️discord-invite] [![Get help from me on Upwork][👨🏼‍🏫expsup-upwork-img]][👨🏼‍🏫expsup-upwork] [![Get help from me on Codementor][👨🏼‍🏫expsup-codementor-img]][👨🏼‍🏫expsup-codementor] |
88
+ | Source | [![Source on GitLab.com][📜src-gl-img]][📜src-gl] [![Source on CodeBerg.org][📜src-cb-img]][📜src-cb] [![Source on GitHub.com][📜src-gh-img]][📜src-gh] [![The best SHA: dQw4w9WgXcQ!][🧮kloc-img]][🧮kloc] |
89
+ | Documentation | [![Current release on RubyDoc.info][📜docs-cr-rd-img]][🚎yard-current] [![YARD on Galtzo.com][📜docs-head-rd-img]][🚎yard-head] [![Maintainer Blog][🚂maint-blog-img]][🚂maint-blog] [![GitLab Wiki][📜gl-wiki-img]][📜gl-wiki] [![GitHub Wiki][📜gh-wiki-img]][📜gh-wiki] |
90
+ | Compliance | [![License: AGPL-3.0-only OR PolyForm-Small-Business-1.0.0][📄license-img]][📄license] [![Apache license compatibility: Category X][📄license-compat-img]][📄license-compat] [![📄ilo-declaration-img]][📄ilo-declaration] [![Security Policy][🔐security-img]][🔐security] [![Contributor Covenant 2.1][🪇conduct-img]][🪇conduct] [![SemVer 2.0.0][📌semver-img]][📌semver] |
91
+ | Style | [![Enforced Code Style Linter][💎rlts-img]][💎rlts] [![Keep-A-Changelog 1.0.0][📗keep-changelog-img]][📗keep-changelog] [![Gitmoji Commits][📌gitmoji-img]][📌gitmoji] [![Compatibility appraised by: appraisal2][💎appraisal2-img]][💎appraisal2] |
92
+ | Maintainer 🎖️ | [![Follow Me on LinkedIn][💖🖇linkedin-img]][💖🖇linkedin] [![Follow Me on Ruby.Social][💖🐘ruby-mast-img]][💖🐘ruby-mast] [![Follow Me on Bluesky][💖🦋bluesky-img]][💖🦋bluesky] [![Contact Maintainer][🚂maint-contact-img]][🚂maint-contact] [![My technical writing][💖💁🏼‍♂️devto-img]][💖💁🏼‍♂️devto] |
93
+ | `...` 💖 | [![Find Me on WellFound:][💖✌️wellfound-img]][💖✌️wellfound] [![Find Me on CrunchBase][💖💲crunchbase-img]][💖💲crunchbase] [![My LinkTree][💖🌳linktree-img]][💖🌳linktree] [![More About Me][💖💁🏼‍♂️aboutme-img]][💖💁🏼‍♂️aboutme] [🧊][💖🧊berg] [🐙][💖🐙hub] [🛖][💖🛖hut] [🧪][💖🧪lab] |
94
+
95
+ ### Compatibility
96
+
97
+ Compatible with MRI Ruby 4.0.0+, JRuby, and TruffleRuby.
98
+ CI workflows and Appraisals are generated for MRI Ruby 4.0.0+.
99
+ This test floor is configured by `ruby.test_minimum` in `.kettle-jem.yml` and
100
+ may be higher than the gem's runtime compatibility floor when legacy Rubies are
101
+ not practical for the current toolchain.
102
+
103
+ <a href="https://github.com/kettle-dev"><img alt="kettle-dev Logo by Aboling0, CC BY-SA 4.0" src="https://logos.galtzo.com/assets/images/kettle-dev/avatar-128px.svg" width="14%" align="right"/></a>
104
+
105
+ The _amazing_ test matrix is powered by the kettle-dev stack.
106
+
107
+ <details markdown="1">
108
+ <summary>How kettle-dev manages complexity in tests</summary>
109
+
110
+ | Gem | Source | Role | Total downloads |
111
+ |-----|--------|------|---------------------|
112
+ | [appraisal2](https://clickgems.clickhouse.com/dashboard/appraisal2) | [GitHub](https://github.com/appraisal-rb/appraisal2) | multi-dependency Appraisal matrix generation | [![Total downloads for appraisal2](https://img.shields.io/gem/dt/appraisal2.svg?style=flat-square)](https://clickgems.clickhouse.com/dashboard/appraisal2) |
113
+ | [appraisal2-rubocop](https://clickgems.clickhouse.com/dashboard/appraisal2-rubocop) | [GitHub](https://github.com/appraisal-rb/appraisal2-rubocop) | RuboCop Appraisal generator integration | [![Total downloads for appraisal2-rubocop](https://img.shields.io/gem/dt/appraisal2-rubocop.svg?style=flat-square)](https://clickgems.clickhouse.com/dashboard/appraisal2-rubocop) |
114
+ | [kettle-dev](https://clickgems.clickhouse.com/dashboard/kettle-dev) | [GitHub](https://github.com/kettle-dev/kettle-dev) | development, release, and CI workflow tooling | [![Total downloads for kettle-dev](https://img.shields.io/gem/dt/kettle-dev.svg?style=flat-square)](https://clickgems.clickhouse.com/dashboard/kettle-dev) |
115
+ | [kettle-jem](https://clickgems.clickhouse.com/dashboard/kettle-jem) | [GitHub](https://github.com/kettle-dev/kettle-jem) | Appraisals & CI workflow templates | [![Total downloads for kettle-jem](https://img.shields.io/gem/dt/kettle-jem.svg?style=flat-square)](https://clickgems.clickhouse.com/dashboard/kettle-jem) |
116
+ | [kettle-soup-cover](https://clickgems.clickhouse.com/dashboard/kettle-soup-cover) | [GitHub](https://github.com/kettle-dev/kettle-soup-cover) | SimpleCov coverage policy and reporting | [![Total downloads for kettle-soup-cover](https://img.shields.io/gem/dt/kettle-soup-cover.svg?style=flat-square)](https://clickgems.clickhouse.com/dashboard/kettle-soup-cover) |
117
+ | [kettle-test](https://clickgems.clickhouse.com/dashboard/kettle-test) | [GitHub](https://github.com/kettle-dev/kettle-test) | standard test runner and coverage harness | [![Total downloads for kettle-test](https://img.shields.io/gem/dt/kettle-test.svg?style=flat-square)](https://clickgems.clickhouse.com/dashboard/kettle-test) |
118
+ | [rubocop-lts](https://clickgems.clickhouse.com/dashboard/rubocop-lts) | [GitHub](https://github.com/rubocop-lts/rubocop-lts) | Ruby-version-aware linting | [![Total downloads for rubocop-lts](https://img.shields.io/gem/dt/rubocop-lts.svg?style=flat-square)](https://clickgems.clickhouse.com/dashboard/rubocop-lts) |
119
+ | [turbo_tests2](https://clickgems.clickhouse.com/dashboard/turbo_tests2) | [GitHub](https://github.com/galtzo-floss/turbo_tests2) | parallel test execution | [![Total downloads for turbo_tests2](https://img.shields.io/gem/dt/turbo_tests2.svg?style=flat-square)](https://clickgems.clickhouse.com/dashboard/turbo_tests2) |
120
+
121
+ </details>
122
+
123
+ ## ✨ Installation
124
+
125
+ Install the gem and add to the application's Gemfile by executing:
126
+
127
+ ```console
128
+ bundle add markdown-merge
129
+ ```
130
+
131
+ If bundler is not being used to manage dependencies, install the gem by executing:
132
+
133
+ ```console
134
+ gem install markdown-merge
135
+ ```
136
+
137
+ ## ⚙️ Configuration
138
+
139
+ ### SmartMerger Configuration
140
+
141
+ The `SmartMerger` class is the main entry point for merging Markdown files:
142
+
143
+ ```ruby
144
+ require "markdown/merge"
145
+
146
+ merger = Markdown::Merge::SmartMerger.new(
147
+ template_content,
148
+ dest_content,
149
+
150
+ # Backend selection (default: :auto)
151
+ # :auto - auto-detect available backend (tries commonmarker first, then markly)
152
+ # :commonmarker - use Commonmarker (comrak Rust parser)
153
+ # :markly - use Markly (cmark-gfm C library)
154
+ backend: :auto,
155
+
156
+ # Which version to prefer when nodes match but differ
157
+ # :destination (default) - keep destination content (preserves customizations)
158
+ # :template - use template content (applies updates)
159
+ preference: :destination,
160
+
161
+ # Whether to add template-only nodes to the result
162
+ # false (default) - only include sections that exist in destination
163
+ # true - include all template sections
164
+ add_template_only_nodes: false,
165
+
166
+ # Token for freeze block markers
167
+ # Default: "markdown-merge"
168
+ # Looks for: <!-- markdown-merge:freeze --> / <!-- markdown-merge:unfreeze -->
169
+ freeze_token: "markdown-merge",
170
+
171
+ # Enable inner-merge for fenced code blocks
172
+ # false (default) - use standard conflict resolution for code blocks
173
+ # true - merge code block contents using language-specific mergers
174
+ # CodeBlockMerger instance - use custom CodeBlockMerger
175
+ inner_merge_code_blocks: false,
176
+
177
+ # Match refiner for fuzzy matching of unmatched nodes
178
+ # nil (default) - exact matching only
179
+ # TableMatchRefiner.new - enable fuzzy table matching
180
+ match_refiner: nil,
181
+
182
+ # Custom signature generator (optional)
183
+ # Receives a node (wrapped with canonical merge_type), returns a signature array or nil
184
+ # Return the node itself to fall through to default signature
185
+ signature_generator: nil,
186
+
187
+ # Backend-specific options (passed through to parser)
188
+ # For commonmarker: options: {}
189
+ # For markly: flags: Markly::DEFAULT, extensions: [:table]
190
+ )
191
+ ```
192
+
193
+ ### Text Matching Behavior
194
+
195
+ **Important**: When matching nodes by text content (such as for anchor patterns in
196
+ `PartialTemplateMerger`), the `.text` method returns **plain text without markdown formatting**.
197
+
198
+ This means:
199
+
200
+ - Markdown: `` ### The `*-merge` Gem Family ``
201
+ - `.text` returns: `"The *-merge Gem Family\n"`
202
+
203
+ The backticks around `*-merge` are stripped because they are inline formatting, not content.
204
+ This is true for both Commonmarker and Markly backends.
205
+
206
+ **Anchor pattern examples**:
207
+
208
+ ```ruby
209
+ # ❌ WRONG - backticks are stripped, so this won't match
210
+ anchor: { type: :heading, text: /`\*-merge` Gem Family/ }
211
+
212
+ # ✅ CORRECT - match the plain text content
213
+ anchor: { type: :heading, text: /\*-merge.*Gem Family/ }
214
+
215
+ # ✅ CORRECT - use beginning anchor for exact heading match
216
+ anchor: { type: :heading, text: /^The \*-merge Gem Family/ }
217
+ ```
218
+
219
+ **Other markdown formatting that is stripped from `.text`**:
220
+
221
+ - Bold: `**text**` → `text`
222
+ - Italic: `*text*` or `_text_` → `text`
223
+ - Code: `` `code` `` → `code`
224
+ - Links: `[text](url)` → `text`
225
+ - Images: `![alt](src)` → `alt`
226
+
227
+ **Note**: Different parsers may have other idiosyncrasies. For example:
228
+
229
+ - Trailing newlines may or may not be present
230
+ - Whitespace normalization may differ
231
+ - Entity encoding may vary
232
+
233
+ Always test your patterns against actual parsed content when building merge recipes.
234
+
235
+ ### Node Type Normalization
236
+
237
+ markdown-merge normalizes node types across backends so merge rules are portable:
238
+
239
+ ```ruby
240
+ # These are equivalent regardless of backend
241
+ # Markly's :header becomes :heading
242
+ # Markly's :hrule becomes :thematic_break
243
+ # etc.
244
+
245
+ # Register a custom backend's type mappings
246
+ Markdown::Merge::NodeTypeNormalizer.register_backend(:my_parser, {
247
+ h1: :heading,
248
+ h2: :heading,
249
+ para: :paragraph,
250
+ # ...
251
+ })
252
+ ```
253
+
254
+ ### Parser-Specific Wrappers
255
+
256
+ For convenience, parser-specific wrappers provide backend-specific defaults:
257
+
258
+ ```ruby
259
+ # commonmarker-merge (freeze_token: "commonmarker-merge", inner_merge_code_blocks: false)
260
+ require "commonmarker/merge"
261
+ merger = Commonmarker::Merge::SmartMerger.new(template, dest, options: {})
262
+
263
+ # markly-merge (freeze_token: "markly-merge", inner_merge_code_blocks: true)
264
+ require "markly/merge"
265
+ merger = Markly::Merge::SmartMerger.new(template, dest, flags: Markly::DEFAULT, extensions: [:table])
266
+ ```
267
+
268
+ ### Freeze Blocks
269
+
270
+ Freeze blocks protect sections from being modified during merges. They are marked
271
+ with HTML comments that are invisible when the Markdown is rendered:
272
+
273
+ ```markdown
274
+ <!-- markdown-merge:freeze -->
275
+
276
+ ## This Section Is Protected
277
+
278
+ Any content here will be preserved exactly as-is during merges.
279
+ The merge tool will not modify, replace, or remove this content.
280
+
281
+ <!-- markdown-merge:unfreeze -->
282
+ ```
283
+
284
+ Add an optional frozen reason to document why:
285
+
286
+ ```markdown
287
+ <!-- markdown-merge:freeze Custom table - manually maintained -->
288
+ | Feature | Status |
289
+ |---------|--------|
290
+ | Custom | ✅ |
291
+ <!-- markdown-merge:unfreeze -->
292
+ ```
293
+
294
+ ### Inner-Merge Code Blocks
295
+
296
+ When enabled, fenced code blocks are merged using language-specific `*-merge` gems:
297
+
298
+ ```ruby
299
+ merger = SomeParser::Merge::SmartMerger.new(
300
+ template,
301
+ destination,
302
+ inner_merge_code_blocks: true,
303
+ )
304
+ ```
305
+
306
+ Supported languages and their mergers:
307
+
308
+ | Language | Fence Info | Merger |
309
+ | --- | --- | --- |
310
+ | Ruby | `ruby`, `rb` | prism-merge |
311
+ | YAML | `yaml`, `yml` | psych-merge |
312
+ | JSON | `json` | json-merge |
313
+ | TOML | `toml` | toml-merge |
314
+
315
+ Example with a Ruby code block:
316
+
317
+ ````markdown
318
+ ```ruby
319
+
320
+ # Template
321
+
322
+ class MyClass
323
+ def new_method
324
+ puts "from template"
325
+ end
326
+ end
327
+ ```
328
+ ````
329
+
330
+ When merged(with:
331
+
332
+ ````markdown
333
+ ```ruby
334
+
335
+ # Destination
336
+
337
+ class MyClass
338
+ def existing_method
339
+ puts "custom"
340
+ end
341
+ end)
342
+ ```
343
+ ````
344
+
345
+ Result (with `inner_merge_code_blocks: true`):
346
+
347
+ ````markdown
348
+ ```ruby
349
+ class MyClass
350
+ def existing_method
351
+ puts "custom"
352
+ end
353
+
354
+ def new_method
355
+ puts "from template"
356
+ end
357
+ end
358
+ ```
359
+ ````
360
+
361
+ ### Table Match Refiner
362
+
363
+ When tables don't match by exact signature, the `TableMatchRefiner` uses
364
+ fuzzy matching to pair tables with similar structure:
365
+
366
+ ```ruby
367
+ refiner = Markdown::Merge::TableMatchRefiner.new(
368
+ threshold: 0.5, # Minimum similarity (0.0-1.0)
369
+ algorithm_options: {
370
+ weights: {
371
+ header_match: 0.25, # Header cell similarity
372
+ first_column: 0.20, # Row label similarity
373
+ row_content: 0.25, # Row content overlap
374
+ total_cells: 0.15, # Overall cell matching
375
+ position: 0.15, # Position distance
376
+ },
377
+ },
378
+ )
379
+
380
+ merger = SomeParser::Merge::SmartMerger.new(
381
+ template,
382
+ destination,
383
+ match_refiner: refiner,
384
+ )
385
+ ```
386
+
387
+ ### Debug Logging
388
+
389
+ Enable debug logging to see merge decisions:
390
+
391
+ ```bash
392
+ export MARKDOWN_MERGE_DEBUG=1
393
+ ```
394
+
395
+ ## 🔧 Basic Usage
396
+
397
+ **Note:** This gem provides base classes for implementers. End users should use
398
+ [commonmarker-merge][commonmarker-merge] or
399
+ [markly-merge][markly-merge] instead.
400
+
401
+ ### For End Users
402
+
403
+ Use a parser-specific implementation:
404
+
405
+ #### Option 1: Using commonmarker-merge (Comrak/Rust)
406
+
407
+ ```ruby
408
+ require "commonmarker/merge"
409
+
410
+ template = File.read("template.md")
411
+ destination = File.read("destination.md")
412
+
413
+ merger = Commonmarker::Merge::SmartMerger.new(template, destination)
414
+ result = merger.merge
415
+
416
+ File.write("merged.md", result.content)
417
+ ```
418
+
419
+ #### Option 2: Using markly-merge (libcmark-gfm/C)
420
+
421
+ ```ruby
422
+ require "markly/merge"
423
+
424
+ template = File.read("template.md")
425
+ destination = File.read("destination.md")
426
+
427
+ merger = Markly::Merge::SmartMerger.new(template, destination)
428
+ result = merger.merge
429
+
430
+ File.write("merged.md", result.to_markdown)
431
+ ```
432
+
433
+ ### For Implementers
434
+
435
+ Creating a new parser-specific implementation:
436
+
437
+ ```ruby
438
+ require "markdown/merge"
439
+
440
+ module MyParser
441
+ module Merge
442
+ class FileAnalysis < Markdown::Merge::FileAnalysisBase
443
+ def parse_document(source)
444
+ # Parse source and return root document node
445
+ MyParser.parse(source)
446
+ end
447
+
448
+ def next_sibling(node)
449
+ # Return the next sibling of a node
450
+ node.next_sibling
451
+ end
452
+
453
+ def compute_parser_signature(node)
454
+ # Compute signature for parser-specific nodes
455
+ # Or call super for default implementation
456
+ super
457
+ end
458
+ end
459
+
460
+ class SmartMerger < Markdown::Merge::SmartMergerBase
461
+ def create_file_analysis(content, **options)
462
+ FileAnalysis.new(content, **options)
463
+ end
464
+
465
+ def node_to_source(node, analysis)
466
+ case node
467
+ when Markdown::Merge::FreezeNode
468
+ node.full_text
469
+ else
470
+ # Convert node back to source text
471
+ node.to_markdown
472
+ end
473
+ end
474
+ end
475
+ end
476
+ end
477
+ ```
478
+
479
+ ### Freeze Block Protection
480
+
481
+ Both implementations support freeze blocks for protecting customized sections:
482
+
483
+ ```markdown
484
+
485
+ # My Project
486
+
487
+ ## Installation
488
+
489
+ <!-- markdown-merge:freeze Custom install instructions -->
490
+ This installation section has been customized and will be preserved
491
+ during template merges, regardless of what the template contains.
492
+ <!-- markdown-merge:unfreeze -->
493
+
494
+ ## Usage
495
+
496
+ Standard usage section - can be updated from template.
497
+ ```
498
+
499
+ Content between freeze markers is always preserved from the destination file,
500
+ even when the template has different content for that section.
501
+
502
+ ## 🔐 Security
503
+
504
+ See [SECURITY.md][🔐security].
505
+
506
+ ## 🤝 Contributing
507
+
508
+ If you need some ideas of where to help, you could work on adding more code coverage,
509
+ check [issues][🤝gh-issues] or [PRs][🤝gh-pulls], or use the gem and think about how it could be better.
510
+
511
+ We [![Keep A Changelog][📗keep-changelog-img]][📗keep-changelog] so if you make changes, remember to update it.
512
+
513
+ See [CONTRIBUTING.md][🤝contributing] for more detailed instructions.
514
+
515
+ ## 📌 Versioning
516
+
517
+ This library follows [![Semantic Versioning 2.0.0][📌semver-img]][📌semver] for its public API where practical.
518
+ For most applications, prefer the [Pessimistic Version Constraint][📌pvc] with two digits of precision.
519
+
520
+ For example:
521
+
522
+ ```ruby
523
+ spec.add_dependency("markdown-merge", "~> 7.0")
524
+ ```
525
+
526
+ <details markdown="1">
527
+ <summary>📌 Is "Platform Support" part of the public API? More details inside.</summary>
528
+
529
+ Dropping support for a platform can be a breaking change for affected users.
530
+ If a release changes supported platforms, it should be called out clearly in the changelog and versioned with that impact in mind.
531
+
532
+ To get a better understanding of how SemVer is intended to work over a project's lifetime,
533
+ read this article from the creator of SemVer:
534
+
535
+ - ["Major Version Numbers are Not Sacred"][📌major-versions-not-sacred]
536
+
537
+ </details>
538
+
539
+ See [CHANGELOG.md][📌changelog] for a list of releases.
540
+
541
+ ## 📄 License
542
+
543
+ The gem is available under the following licenses: [AGPL-3.0-only](https://github.com/structuredmerge/structuredmerge-ruby/blob/main/AGPL-3.0-only.md), [PolyForm-Small-Business-1.0.0](https://github.com/structuredmerge/structuredmerge-ruby/blob/main/PolyForm-Small-Business-1.0.0.md).
544
+ See [LICENSE.md][📄license] for details.
545
+
546
+ If none of the available licenses suit your use case, please [contact us](mailto:floss@galtzo.com) to discuss a custom commercial license.
547
+
548
+ [⛳liberapay-img]: https://img.shields.io/liberapay/goal/pboling.svg?logo=liberapay&color=a51611&style=flat
549
+ [⛳liberapay-bottom-img]: https://img.shields.io/liberapay/goal/pboling.svg?style=for-the-badge&logo=liberapay&color=a51611
550
+ [⛳liberapay]: https://liberapay.com/pboling/donate
551
+ [🖇sponsor-img]: https://img.shields.io/badge/Sponsor_Me!-pboling.svg?style=social&logo=github
552
+ [🖇sponsor-bottom-img]: https://img.shields.io/badge/Sponsor_Me!-pboling-blue?style=for-the-badge&logo=github
553
+ [🖇sponsor]: https://github.com/sponsors/pboling
554
+ [🖇kofi-img]: https://img.shields.io/badge/ko--fi-%E2%9C%93-a51611.svg?style=flat
555
+ [🖇kofi]: https://ko-fi.com/pboling
556
+ [🖇buyme-small-img]: https://img.shields.io/badge/buy_me_a_coffee-%E2%9C%93-a51611.svg?style=flat
557
+ [🖇buyme-img]: https://img.buymeacoffee.com/button-api/?text=Buy%20me%20a%20latte&emoji=&slug=pboling&button_colour=FFDD00&font_colour=000000&font_family=Cookie&outline_colour=000000&coffee_colour=ffffff
558
+ [🖇buyme]: https://www.buymeacoffee.com/pboling
559
+ [🖇paypal-img]: https://img.shields.io/badge/donate-paypal-a51611.svg?style=flat&logo=paypal
560
+ [🖇paypal-bottom-img]: https://img.shields.io/badge/donate-paypal-a51611.svg?style=for-the-badge&logo=paypal&color=0A0A0A
561
+ [🖇paypal]: https://www.paypal.com/paypalme/peterboling
562
+ [🖇floss-funding.dev]: https://floss-funding.dev
563
+ [🖇floss-funding-gem]: https://github.com/galtzo-floss/floss_funding
564
+ [✉️discord-invite]: https://discord.gg/3qme4XHNKN
565
+ [✉️discord-invite-img-ftb]: https://img.shields.io/discord/1373797679469170758?style=for-the-badge&logo=discord
566
+ [✉️ruby-friends-img]: https://img.shields.io/badge/daily.dev-%F0%9F%92%8E_Ruby_Friends-0A0A0A?style=for-the-badge&logo=dailydotdev&logoColor=white
567
+ [✉️ruby-friends]: https://app.daily.dev/squads/rubyfriends
568
+ [✉️ruby-forum-top-img]: https://img.shields.io/discourse/topics?server=https%3A%2F%2Fwww.rubyforum.org&style=flat&logo=discourse&label=Ruby%20Users%20Forum
569
+ [✉️ruby-forum-img]: https://img.shields.io/discourse/topics?server=https%3A%2F%2Fwww.rubyforum.org&style=for-the-badge&logo=discourse&label=Ruby%20Users%20Forum
570
+ [✉️ruby-forum]: https://www.rubyforum.org/tag/structuredmerge
571
+ [✇bundle-group-pattern]: https://gist.github.com/pboling/4564780
572
+ [⛳️gem-namespace]: https://github.com/structuredmerge/structuredmerge-ruby/tree/main/gems/markdown-merge
573
+ [⛳️namespace-img]: https://img.shields.io/badge/namespace-Markdown::Merge-3C2D2D.svg?style=square&logo=ruby&logoColor=white
574
+ [⛳️gem-name]: https://clickgems.clickhouse.com/dashboard/markdown-merge
575
+ [⛳️name-img]: https://img.shields.io/badge/name-markdown--merge-3C2D2D.svg?style=square&logo=rubygems&logoColor=red
576
+ [⛳️tag-img]: https://img.shields.io/github/tag/structuredmerge/structuredmerge-ruby.svg
577
+ [⛳️tag]: https://github.com/structuredmerge/structuredmerge-ruby/releases
578
+ [🚂maint-blog]: http://www.railsbling.com/tags/markdown-merge
579
+ [🚂maint-blog-img]: https://img.shields.io/badge/blog-railsbling-0093D0.svg?style=for-the-badge&logo=rubyonrails&logoColor=orange
580
+ [🚂maint-contact]: http://www.railsbling.com/contact
581
+ [🚂maint-contact-img]: https://img.shields.io/badge/Contact-Maintainer-0093D0.svg?style=flat&logo=rubyonrails&logoColor=red
582
+ [💖🖇linkedin]: http://www.linkedin.com/in/peterboling
583
+ [💖🖇linkedin-img]: https://img.shields.io/badge/LinkedIn-Profile-0B66C2?style=flat&logo=newjapanprowrestling
584
+ [💖✌️wellfound]: https://wellfound.com/u/peter-boling
585
+ [💖✌️wellfound-img]: https://img.shields.io/badge/peter--boling-orange?style=flat&logo=wellfound
586
+ [💖💲crunchbase]: https://www.crunchbase.com/person/peter-boling
587
+ [💖💲crunchbase-img]: https://img.shields.io/badge/peter--boling-purple?style=flat&logo=crunchbase
588
+ [💖🐘ruby-mast]: https://ruby.social/@galtzo
589
+ [💖🐘ruby-mast-img]: https://img.shields.io/mastodon/follow/109447111526622197?domain=https://ruby.social&style=flat&logo=mastodon&label=Ruby%20@galtzo
590
+ [💖🦋bluesky]: https://bsky.app/profile/galtzo.com
591
+ [💖🦋bluesky-img]: https://img.shields.io/badge/@galtzo.com-0285FF?style=flat&logo=bluesky&logoColor=white
592
+ [💖🌳linktree]: https://linktr.ee/galtzo
593
+ [💖🌳linktree-img]: https://img.shields.io/badge/galtzo-purple?style=flat&logo=linktree
594
+ [💖💁🏼‍♂️devto]: https://dev.to/galtzo
595
+ [💖💁🏼‍♂️devto-img]: https://img.shields.io/badge/dev.to-0A0A0A?style=flat&logo=devdotto&logoColor=white
596
+ [💖💁🏼‍♂️aboutme]: https://about.me/peter.boling
597
+ [💖💁🏼‍♂️aboutme-img]: https://img.shields.io/badge/about.me-0A0A0A?style=flat&logo=aboutme&logoColor=white
598
+ [💖🧊berg]: https://codeberg.org/pboling
599
+ [💖🐙hub]: https://github.org/pboling
600
+ [💖🛖hut]: https://sr.ht/~galtzo/
601
+ [💖🧪lab]: https://gitlab.com/pboling
602
+ [👨🏼‍🏫expsup-upwork]: https://www.upwork.com/freelancers/~014942e9b056abdf86?mp_source=share
603
+ [👨🏼‍🏫expsup-upwork-img]: https://img.shields.io/badge/UpWork-13544E?style=for-the-badge&logo=Upwork&logoColor=white
604
+ [👨🏼‍🏫expsup-codementor]: https://www.codementor.io/peterboling?utm_source=github&utm_medium=button&utm_term=peterboling&utm_campaign=github
605
+ [👨🏼‍🏫expsup-codementor-img]: https://img.shields.io/badge/CodeMentor-Get_Help-1abc9c?style=for-the-badge&logo=CodeMentor&logoColor=white
606
+ [🏙️entsup-tidelift]: https://tidelift.com/subscription/pkg/rubygems-markdown-merge?utm_source=rubygems-markdown-merge&utm_medium=referral&utm_campaign=readme
607
+ [🏙️entsup-tidelift-img]: https://img.shields.io/badge/Tidelift_and_Sonar-Enterprise_Support-FD3456?style=for-the-badge&logo=sonar&logoColor=white
608
+ [🏙️entsup-tidelift-sonar]: https://blog.tidelift.com/tidelift-joins-sonar
609
+ [💁🏼‍♂️peterboling]: http://www.peterboling.com
610
+ [🚂railsbling]: http://www.railsbling.com
611
+ [📜src-gl-img]: https://img.shields.io/badge/GitLab-FBA326?style=for-the-badge&logo=Gitlab&logoColor=orange
612
+ [📜src-gl]: https://gitlab.com/structuredmerge/structuredmerge-ruby/-/tree/main/gems/markdown-merge
613
+ [📜src-cb-img]: https://img.shields.io/badge/CodeBerg-4893CC?style=for-the-badge&logo=CodeBerg&logoColor=blue
614
+ [📜src-cb]: https://codeberg.org/structuredmerge/structuredmerge-ruby/src/branch/main/gems/markdown-merge
615
+ [📜src-gh-img]: https://img.shields.io/badge/GitHub-238636?style=for-the-badge&logo=GitHub&logoColor=green
616
+ [📜src-gh]: https://github.com/structuredmerge/structuredmerge-ruby/tree/main/gems/markdown-merge
617
+ [📜docs-cr-rd-img]: https://img.shields.io/badge/RubyDoc-Current_Release-943CD2?style=for-the-badge&logo=readthedocs&logoColor=white
618
+ [📜docs-head-rd-img]: https://img.shields.io/badge/YARD_on_Galtzo.com-HEAD-943CD2?style=for-the-badge&logo=readthedocs&logoColor=white
619
+ [📜gl-wiki]: https://gitlab.com/structuredmerge/structuredmerge-ruby/-/wikis/home
620
+ [📜gh-wiki]: https://github.com/structuredmerge/structuredmerge-ruby/wiki
621
+ [📜gl-wiki-img]: https://img.shields.io/badge/wiki-gitlab-943CD2.svg?style=for-the-badge&logo=gitlab&logoColor=white
622
+ [📜gh-wiki-img]: https://img.shields.io/badge/wiki-github-943CD2.svg?style=for-the-badge&logo=github&logoColor=white
623
+ [👽dl-rank]: https://clickgems.clickhouse.com/dashboard/markdown-merge
624
+ [👽dl-ranki]: https://img.shields.io/gem/dt/markdown-merge.svg
625
+ [👽version]: https://clickgems.clickhouse.com/dashboard/markdown-merge
626
+ [👽versioni]: https://img.shields.io/gem/v/markdown-merge.svg
627
+ [🚎11-c-wf]: https://github.com/structuredmerge/structuredmerge-ruby/actions/workflows/current.yml
628
+ [🚎11-c-wfi]: https://github.com/structuredmerge/structuredmerge-ruby/actions/workflows/current.yml/badge.svg
629
+ [💎ruby-c-i]: https://img.shields.io/badge/Ruby-current-CC342D?style=for-the-badge&logo=ruby&logoColor=green
630
+ [🤝gh-issues]: https://github.com/structuredmerge/structuredmerge-ruby/issues
631
+ [🤝gh-pulls]: https://github.com/structuredmerge/structuredmerge-ruby/pulls
632
+ [🤝gl-issues]: https://gitlab.com/structuredmerge/structuredmerge-ruby/-/issues
633
+ [🤝gl-pulls]: https://gitlab.com/structuredmerge/structuredmerge-ruby/-/merge_requests
634
+ [🤝cb-issues]: https://codeberg.org/structuredmerge/structuredmerge-ruby/issues
635
+ [🤝cb-pulls]: https://codeberg.org/structuredmerge/structuredmerge-ruby/pulls
636
+ [🤝cb-donate]: https://donate.codeberg.org/
637
+ [🤝contributing]: https://github.com/structuredmerge/structuredmerge-ruby/blob/main/CONTRIBUTING.md
638
+ [🖐contrib-rocks]: https://contrib.rocks
639
+ [🖐contributors]: https://github.com/structuredmerge/structuredmerge-ruby/graphs/contributors
640
+ [🖐contributors-img]: https://contrib.rocks/image?repo=structuredmerge/structuredmerge-ruby
641
+ [🪇conduct]: https://github.com/structuredmerge/structuredmerge-ruby/blob/main/CODE_OF_CONDUCT.md
642
+ [🪇conduct-img]: https://img.shields.io/badge/Contributor_Covenant-2.1-259D6C.svg
643
+ [📌pvc]: http://guides.rubygems.org/patterns/#pessimistic-version-constraint
644
+ [📌semver]: https://semver.org/spec/v2.0.0.html
645
+ [📌semver-img]: https://img.shields.io/badge/semver-2.0.0-259D6C.svg?style=flat
646
+ [📌semver-breaking]: https://github.com/semver/semver/issues/716#issuecomment-869336139
647
+ [📌major-versions-not-sacred]: https://tom.preston-werner.com/2022/05/23/major-version-numbers-are-not-sacred.html
648
+ [📌changelog]: https://github.com/structuredmerge/structuredmerge-ruby/blob/main/CHANGELOG.md
649
+ [📗keep-changelog]: https://keepachangelog.com/en/1.0.0/
650
+ [📗keep-changelog-img]: https://img.shields.io/badge/keep--a--changelog-1.0.0-34495e.svg?style=flat
651
+ [📌gitmoji]: https://gitmoji.dev
652
+ [📌gitmoji-img]: https://img.shields.io/badge/gitmoji_commits-%20%F0%9F%98%9C%20%F0%9F%98%8D-34495e.svg?style=flat-square
653
+ [🧮kloc]: https://www.youtube.com/watch?v=dQw4w9WgXcQ
654
+ [🧮kloc-img]: https://img.shields.io/badge/KLOC-4.086-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
655
+ [🔐security]: https://github.com/structuredmerge/structuredmerge-ruby/blob/main/SECURITY.md
656
+ [🔐security-img]: https://img.shields.io/badge/security-policy-259D6C.svg?style=flat
657
+ [📄copyright-notice-explainer]: https://opensource.stackexchange.com/questions/5778/why-do-licenses-such-as-the-mit-license-specify-a-single-year
658
+ [📄license]: LICENSE.md
659
+ [📄license-ref]: LICENSE.md
660
+ [📄license-img]: https://img.shields.io/badge/License-AGPL--3.0--only_OR_PolyForm--Small--Business--1.0.0-259D6C.svg
661
+ [📄license-compat]: https://www.apache.org/legal/resolved.html#category-x
662
+ [📄license-compat-img]: https://img.shields.io/badge/Apache_Incompatible:_Category_X-%E2%9C%97-C0392B.svg?style=flat&logo=Apache
663
+ [📄ilo-declaration]: https://www.ilo.org/declaration/lang--en/index.htm
664
+ [📄ilo-declaration-img]: https://img.shields.io/badge/ILO_Fundamental_Principles-✓-259D6C.svg?style=flat
665
+ [🚎yard-current]: http://rubydoc.info/gems/markdown-merge
666
+ [🚎yard-head]: https://markdown-merge.galtzo.com
667
+ [💎stone_checksums]: https://github.com/galtzo-floss/stone_checksums
668
+ [💎SHA_checksums]: https://gitlab.com/structuredmerge/structuredmerge-ruby/-/tree/main/checksums
669
+ [💎rlts]: https://github.com/rubocop-lts/rubocop-lts
670
+ [💎rlts-img]: https://img.shields.io/badge/code_style_&_linting-rubocop--lts-34495e.svg?plastic&logo=ruby&logoColor=white
671
+ [💎appraisal2]: https://github.com/appraisal-rb/appraisal2
672
+ [💎appraisal2-img]: https://img.shields.io/badge/appraised_by-appraisal2-34495e.svg?plastic&logo=ruby&logoColor=white
673
+ [💎d-in-dvcs]: https://railsbling.com/posts/dvcs/put_the_d_in_dvcs/