turndown 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 9e872e69c4203945c84b806f145774f108be74a01347ed147b8ca8c54a5411db
4
+ data.tar.gz: c48f4355527c6b2a15b2a336d4370931c509403f558c36c5e8fb619425572240
5
+ SHA512:
6
+ metadata.gz: 38b924e0043afed737117111f801d1b65a90d8ea20ef0e41fbdf661af9b3ebc92c38c502d702cd9851f0e739a9f37cba03ce7a7a46fb3740433ceb9bf31b1324
7
+ data.tar.gz: f274c12fb369b06dc52327b0e3a31121b5c3b4d3236953351808db44ad4a45bbdf8399a2866a31ecb5b59c94bac5e16ac652ebe8bf19524bcb22295a6014905a
data/CHANGELOG.md ADDED
@@ -0,0 +1,38 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [1.0.0] - 2026-08-08
11
+
12
+ Initial public release.
13
+
14
+ Requires Ruby >= 3.3. The floor tracks Ruby's own maintained branches rather
15
+ than a fixed version, so expect it to move as branches reach end of life.
16
+
17
+ ### Added
18
+
19
+ - `Turndown::Converter` — a Nokogiri-backed HTML to Markdown converter ported
20
+ from [Turndown](https://github.com/mixmark-io/turndown) `7.2.2`.
21
+ - Accepts HTML strings, Nokogiri element nodes, documents, and document
22
+ fragments as input.
23
+ - Converter API: `#convert`, `#use`, `#add_rule`, `#keep`, `#remove`, `#escape`.
24
+ - Options with symbol keys in snake_case: `heading_style`, `hr`,
25
+ `bullet_list_marker`, `code_block_style`, `fence`, `em_delimiter`,
26
+ `strong_delimiter`, `link_style`, `link_reference_style`, `br`,
27
+ `preformatted_code`, `blank_replacement`, `keep_replacement`,
28
+ `default_replacement`.
29
+ - `Turndown::Plugins::GFM`, ported from
30
+ [turndown-plugin-gfm](https://github.com/mixmark-io/turndown-plugin-gfm),
31
+ with `Strikethrough`, `Tables`, `TaskListItems`, and `HighlightedCodeBlock`
32
+ available individually.
33
+ - Custom rule and plugin support via any Ruby callable.
34
+ - Parity test suite running the upstream fixture corpus: 149 core fixtures and
35
+ 18 GFM fixtures.
36
+
37
+ [Unreleased]: https://github.com/bavmind/turndown/compare/v1.0.0...HEAD
38
+ [1.0.0]: https://github.com/bavmind/turndown/releases/tag/v1.0.0
data/LICENSE ADDED
@@ -0,0 +1,81 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Devran Cosmo Uenal
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ -------------------------------------------------------------------------------
24
+
25
+ This project is a Ruby port of Turndown and turndown-plugin-gfm. Its conversion
26
+ rules, behavior, and test fixture corpus are derived from those projects, which
27
+ are distributed under the MIT License:
28
+
29
+ Turndown https://github.com/mixmark-io/turndown
30
+ turndown-plugin-gfm https://github.com/mixmark-io/turndown-plugin-gfm
31
+
32
+ The MIT License (MIT)
33
+
34
+ Copyright (c) 2017 Dom Christie
35
+
36
+ Permission is hereby granted, free of charge, to any person obtaining a copy
37
+ of this software and associated documentation files (the "Software"), to deal
38
+ in the Software without restriction, including without limitation the rights
39
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
40
+ copies of the Software, and to permit persons to whom the Software is
41
+ furnished to do so, subject to the following conditions:
42
+
43
+ The above copyright notice and this permission notice shall be included in all
44
+ copies or substantial portions of the Software.
45
+
46
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
47
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
48
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
49
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
50
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
51
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
52
+ SOFTWARE.
53
+
54
+ -------------------------------------------------------------------------------
55
+
56
+ The whitespace collapsing logic in lib/turndown/collapse_whitespace.rb is
57
+ adapted, by way of Turndown, from collapse-whitespace by Luc Thevenard:
58
+
59
+ collapse-whitespace https://github.com/lucthev/collapse-whitespace
60
+
61
+ The MIT License (MIT)
62
+
63
+ Copyright (c) 2014 Luc Thevenard <lucthevenard@gmail.com>
64
+
65
+ Permission is hereby granted, free of charge, to any person obtaining a copy
66
+ of this software and associated documentation files (the "Software"), to deal
67
+ in the Software without restriction, including without limitation the rights
68
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
69
+ copies of the Software, and to permit persons to whom the Software is
70
+ furnished to do so, subject to the following conditions:
71
+
72
+ The above copyright notice and this permission notice shall be included in
73
+ all copies or substantial portions of the Software.
74
+
75
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
76
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
77
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
78
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
79
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
80
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
81
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,264 @@
1
+ # Turndown
2
+
3
+ [![Gem Version](https://img.shields.io/gem/v/turndown.svg)](https://rubygems.org/gems/turndown)
4
+ [![CI](https://github.com/bavmind/turndown/actions/workflows/ci.yml/badge.svg)](https://github.com/bavmind/turndown/actions/workflows/ci.yml)
5
+
6
+ Convert HTML to Markdown in Ruby.
7
+
8
+ ```ruby
9
+ Turndown::Converter.new.convert("<h1>Hello world</h1>")
10
+ # => "Hello world\n==========="
11
+ ```
12
+
13
+ > **This is a Ruby port of [Turndown](https://github.com/mixmark-io/turndown) by Dom Christie.**
14
+ > It is an unofficial port, not affiliated with or endorsed by the upstream
15
+ > JavaScript project. Conversion behavior, rule semantics, and the test fixture
16
+ > corpus are derived from upstream Turndown `7.2.2` and
17
+ > [turndown-plugin-gfm](https://github.com/mixmark-io/turndown-plugin-gfm).
18
+ > See [Credits](#credits).
19
+
20
+ ## Requirements
21
+
22
+ - Ruby >= 3.3 (tracks Ruby's own maintained branches)
23
+ - Nokogiri >= 1.16
24
+
25
+ ## Installation
26
+
27
+ Add it to your Gemfile:
28
+
29
+ ```ruby
30
+ gem "turndown"
31
+ ```
32
+
33
+ Then run:
34
+
35
+ ```bash
36
+ bundle install
37
+ ```
38
+
39
+ Or install it directly:
40
+
41
+ ```bash
42
+ gem install turndown
43
+ ```
44
+
45
+ ## Usage
46
+
47
+ ### Convert an HTML string
48
+
49
+ ```ruby
50
+ require "turndown"
51
+
52
+ converter = Turndown::Converter.new
53
+ markdown = converter.convert("<h1>Hello world</h1>")
54
+
55
+ puts markdown
56
+ # Hello world
57
+ # ===========
58
+ ```
59
+
60
+ ### Convert a Nokogiri node or fragment
61
+
62
+ ```ruby
63
+ require "nokogiri"
64
+ require "turndown"
65
+
66
+ fragment = Nokogiri::HTML5::DocumentFragment.parse("<p>Hello</p><p>world</p>")
67
+ converter = Turndown::Converter.new
68
+
69
+ puts converter.convert(fragment)
70
+ # Hello
71
+ #
72
+ # world
73
+ ```
74
+
75
+ Accepted inputs:
76
+
77
+ - HTML `String`
78
+ - `Nokogiri` element nodes
79
+ - `Nokogiri` documents
80
+ - `Nokogiri` document fragments
81
+
82
+ ## API
83
+
84
+ - `Turndown::Converter.new(options = {})`
85
+ - `converter.convert(input)`
86
+ - `converter.use(plugin_or_plugins)`
87
+ - `converter.add_rule(name, filter:, replacement:, append: nil)`
88
+ - `converter.keep(filter)`
89
+ - `converter.remove(filter)`
90
+ - `converter.escape(string)`
91
+
92
+ Every method except `convert` and `escape` returns the converter, so calls chain.
93
+
94
+ ## Options
95
+
96
+ Options use symbol keys and snake_case names.
97
+
98
+ | Option | Default | Notes |
99
+ | :-- | :-- | :-- |
100
+ | `heading_style` | `"setext"` | or `"atx"` |
101
+ | `hr` | `"* * *"` | |
102
+ | `bullet_list_marker` | `"*"` | or `"-"`, `"+"` |
103
+ | `code_block_style` | `"indented"` | or `"fenced"` |
104
+ | `fence` | ` "```" ` | or `"~~~"` |
105
+ | `em_delimiter` | `"_"` | or `"*"` |
106
+ | `strong_delimiter` | `"**"` | or `"__"` |
107
+ | `link_style` | `"inlined"` | or `"referenced"` |
108
+ | `link_reference_style` | `"full"` | or `"collapsed"`, `"shortcut"` |
109
+ | `br` | `" "` | |
110
+ | `preformatted_code` | `false` | |
111
+ | `blank_replacement` | proc | |
112
+ | `keep_replacement` | proc | |
113
+ | `default_replacement` | proc | |
114
+
115
+ ```ruby
116
+ converter = Turndown::Converter.new(
117
+ heading_style: "atx",
118
+ code_block_style: "fenced",
119
+ fence: "~~~",
120
+ bullet_list_marker: "-"
121
+ )
122
+ ```
123
+
124
+ ## Plugins
125
+
126
+ GFM support ships in the same gem under `Turndown::Plugins::GFM`.
127
+
128
+ ```ruby
129
+ require "turndown"
130
+
131
+ converter = Turndown::Converter.new
132
+ converter.use(Turndown::Plugins::GFM)
133
+
134
+ puts converter.convert("<strike>Hello</strike>")
135
+ # ~Hello~
136
+ ```
137
+
138
+ Individual plugins can be used on their own:
139
+
140
+ - `Turndown::Plugins::GFM` — all of the below
141
+ - `Turndown::Plugins::GFM::Strikethrough`
142
+ - `Turndown::Plugins::GFM::Tables`
143
+ - `Turndown::Plugins::GFM::TaskListItems`
144
+ - `Turndown::Plugins::GFM::HighlightedCodeBlock`
145
+
146
+ ## Custom rules
147
+
148
+ Rules stay close to upstream Turndown, but use Ruby callables.
149
+
150
+ ### Add a rule
151
+
152
+ ```ruby
153
+ converter = Turndown::Converter.new
154
+
155
+ converter.add_rule(
156
+ :strikethrough,
157
+ filter: %w[del s strike],
158
+ replacement: ->(content, _node, _options) { "~~#{content}~~" }
159
+ )
160
+ ```
161
+
162
+ `filter` may be:
163
+
164
+ - a tag name string
165
+ - a tag name symbol
166
+ - an array of tag names
167
+ - a `Proc` receiving `(node, options)`
168
+
169
+ ### Keep raw HTML
170
+
171
+ ```ruby
172
+ converter.keep(%w[del ins])
173
+ ```
174
+
175
+ ### Remove nodes
176
+
177
+ ```ruby
178
+ converter.remove("script")
179
+ ```
180
+
181
+ ### Write a plugin
182
+
183
+ Plugins are callables that receive the converter instance:
184
+
185
+ ```ruby
186
+ MyPlugin = lambda do |converter|
187
+ converter.add_rule(
188
+ :callout,
189
+ filter: "aside",
190
+ replacement: ->(content, _node, _options) { "\n\n> #{content}\n\n" }
191
+ )
192
+ end
193
+
194
+ converter.use(MyPlugin)
195
+ ```
196
+
197
+ ## Escaping
198
+
199
+ `converter.escape` exposes the Markdown escaping behavior used internally:
200
+
201
+ ```ruby
202
+ converter = Turndown::Converter.new
203
+ converter.escape("`not code`")
204
+ # => "\\`not code\\`"
205
+ ```
206
+
207
+ ## Differences from the JavaScript version
208
+
209
+ - The API is Ruby-first. There is no `TurndownService#turndown` alias — use
210
+ `Turndown::Converter#convert`.
211
+ - Options are symbol keys in snake_case (`heading_style`), not camelCase
212
+ (`headingStyle`).
213
+ - Rules and plugins are Ruby callables (`Proc`, `lambda`, or anything responding
214
+ to `#call`) rather than JavaScript functions.
215
+ - Nokogiri is the only supported parser backend.
216
+
217
+ ## Parity with upstream
218
+
219
+ The suite runs the upstream fixture corpus directly:
220
+
221
+ - 149 upstream Turndown core fixtures
222
+ - 18 upstream turndown-plugin-gfm fixtures
223
+ - 10 Ruby-specific tests for Nokogiri inputs, rule registration, plugins, and
224
+ option handling
225
+
226
+ The vendored fixture files live under `test/fixtures/upstream/`.
227
+
228
+ ## Development
229
+
230
+ ```bash
231
+ bundle install
232
+ bundle exec rake test
233
+ ```
234
+
235
+ ## Contributing
236
+
237
+ Bug reports and pull requests are welcome at
238
+ <https://github.com/bavmind/turndown>. See [CONTRIBUTING.md](CONTRIBUTING.md).
239
+
240
+ Because this is a port, behavior changes should generally track upstream
241
+ Turndown. If you find a case where this gem and upstream Turndown disagree,
242
+ that is a bug — please include the HTML input and both outputs.
243
+
244
+ ## Credits
245
+
246
+ This gem exists because of the work of others:
247
+
248
+ - **[Turndown](https://github.com/mixmark-io/turndown)** by
249
+ **[Dom Christie](https://github.com/domchristie)** — the original project this
250
+ gem ports. Its conversion rules, behavior, and fixture corpus are the
251
+ reference implementation here.
252
+ - **[turndown-plugin-gfm](https://github.com/mixmark-io/turndown-plugin-gfm)**,
253
+ also by Dom Christie, is the source of the bundled GFM plugin behavior.
254
+ - **[collapse-whitespace](https://github.com/lucthev/collapse-whitespace)** by
255
+ **[Luc Thevenard](https://github.com/lucthev)** — Turndown's whitespace
256
+ collapsing logic is adapted from it, and this port follows that behavior.
257
+
258
+ All three are MIT licensed. Their copyright notices are retained in
259
+ [LICENSE](LICENSE).
260
+
261
+ ## License
262
+
263
+ MIT © 2026 Devran Cosmo Uenal. Portions © 2017 Dom Christie and
264
+ © 2014 Luc Thevenard. See [LICENSE](LICENSE).
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Adapted, by way of Turndown, from collapse-whitespace by Luc Thevenard.
4
+ #
5
+ # Copyright (c) 2014 Luc Thevenard <lucthevenard@gmail.com>
6
+ # Released under the MIT License. See LICENSE for the full notice.
7
+
8
+ module Turndown
9
+ module CollapseWhitespace
10
+ module_function
11
+
12
+ def call(element:, is_block:, is_void:, is_pre: nil)
13
+ is_pre ||= ->(node) { node.element? && node.name.casecmp("pre").zero? }
14
+ return if element.children.empty?
15
+ return if is_pre.call(element)
16
+
17
+ prev_text = nil
18
+ keep_leading_ws = false
19
+
20
+ prev = nil
21
+ node = next_node(prev, element, is_pre)
22
+
23
+ until node == element
24
+ if node.text? || node.cdata?
25
+ text = node.content.gsub(/[ \r\n\t]+/, " ")
26
+
27
+ if (!prev_text || prev_text.content.end_with?(" ")) &&
28
+ !keep_leading_ws &&
29
+ text.start_with?(" ")
30
+ text = text[1..] || ""
31
+ end
32
+
33
+ if text.empty?
34
+ node = remove(node)
35
+ next
36
+ end
37
+
38
+ node.content = text
39
+ prev_text = node
40
+ elsif node.element?
41
+ if is_block.call(Node.new(node, {})) || node.name.casecmp("br").zero?
42
+ prev_text.content = prev_text.content.sub(/ $/, "") if prev_text
43
+ prev_text = nil
44
+ keep_leading_ws = false
45
+ elsif is_void.call(Node.new(node, {})) || is_pre.call(node)
46
+ prev_text = nil
47
+ keep_leading_ws = true
48
+ elsif prev_text
49
+ keep_leading_ws = false
50
+ end
51
+ else
52
+ node = remove(node)
53
+ next
54
+ end
55
+
56
+ next_candidate = next_node(prev, node, is_pre)
57
+ prev = node
58
+ node = next_candidate
59
+ end
60
+
61
+ if prev_text
62
+ prev_text.content = prev_text.content.sub(/ $/, "")
63
+ prev_text.remove if prev_text.content.empty?
64
+ end
65
+ end
66
+
67
+ def remove(node)
68
+ next_node = node.next_sibling || node.parent
69
+ node.remove
70
+ next_node
71
+ end
72
+
73
+ def next_node(prev, current, is_pre)
74
+ if (prev && prev.parent == current) || is_pre.call(current)
75
+ current.next_sibling || current.parent
76
+ else
77
+ current.children.first || current.next_sibling || current.parent
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,232 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Turndown
4
+ class ReferenceLinkRule < Rule
5
+ def initialize
6
+ super(
7
+ filter: lambda do |node, options|
8
+ options[:link_style] == "referenced" &&
9
+ node.node_name == "A" &&
10
+ node.get_attribute("href")
11
+ end,
12
+ replacement: method(:replace),
13
+ append: method(:append_references)
14
+ )
15
+ @references = []
16
+ end
17
+
18
+ private
19
+
20
+ def replace(content, node, options)
21
+ href = CommonMarkRules.escape_link_destination(node.get_attribute("href"))
22
+ title = CommonMarkRules.clean_attribute(node.get_attribute("title"))
23
+ title = %( "#{CommonMarkRules.escape_link_title(title)}") unless title.empty?
24
+
25
+ case options[:link_reference_style]
26
+ when "collapsed"
27
+ replacement = "[#{content}][]"
28
+ reference = "[#{content}]: #{href}#{title}"
29
+ when "shortcut"
30
+ replacement = "[#{content}]"
31
+ reference = "[#{content}]: #{href}#{title}"
32
+ else
33
+ id = @references.length + 1
34
+ replacement = "[#{content}][#{id}]"
35
+ reference = "[#{id}]: #{href}#{title}"
36
+ end
37
+
38
+ @references << reference
39
+ replacement
40
+ end
41
+
42
+ def append_references(_options)
43
+ return "" if @references.empty?
44
+
45
+ output = "\n\n#{@references.join("\n")}\n\n"
46
+ @references = []
47
+ output
48
+ end
49
+ end
50
+
51
+ module CommonMarkRules
52
+ module_function
53
+
54
+ def build
55
+ {
56
+ paragraph: Rule.new(
57
+ filter: "p",
58
+ replacement: ->(content, _node, _options) { "\n\n#{content}\n\n" }
59
+ ),
60
+ line_break: Rule.new(
61
+ filter: "br",
62
+ replacement: ->(_content, _node, options) { "#{options[:br]}\n" }
63
+ ),
64
+ heading: Rule.new(
65
+ filter: %w[h1 h2 h3 h4 h5 h6],
66
+ replacement: lambda do |content, node, options|
67
+ h_level = node.node_name_downcase[1].to_i
68
+
69
+ if options[:heading_style] == "setext" && h_level < 3
70
+ underline = Utilities.repeat(h_level == 1 ? "=" : "-", content.length)
71
+ "\n\n#{content}\n#{underline}\n\n"
72
+ else
73
+ "\n\n#{Utilities.repeat('#', h_level)} #{content}\n\n"
74
+ end
75
+ end
76
+ ),
77
+ blockquote: Rule.new(
78
+ filter: "blockquote",
79
+ replacement: lambda do |content, _node, _options|
80
+ content = Utilities.trim_newlines(content).gsub(/^/, "> ")
81
+ "\n\n#{content}\n\n"
82
+ end
83
+ ),
84
+ list: Rule.new(
85
+ filter: %w[ul ol],
86
+ replacement: lambda do |content, node, _options|
87
+ parent = node.parent_node
88
+ if parent&.node_name == "LI" && parent.last_element_child&.same_node?(node)
89
+ "\n#{content}"
90
+ else
91
+ "\n\n#{content}\n\n"
92
+ end
93
+ end
94
+ ),
95
+ list_item: Rule.new(
96
+ filter: "li",
97
+ replacement: lambda do |content, node, options|
98
+ prefix = "#{options[:bullet_list_marker]} "
99
+ parent = node.parent_node
100
+
101
+ if parent&.node_name == "OL"
102
+ start = parent.get_attribute("start")
103
+ index = parent.children.find_index { |child| child.same_node?(node) } || 0
104
+ prefix = "#{start ? start.to_i + index : index + 1}. "
105
+ end
106
+
107
+ paragraph = content.end_with?("\n")
108
+ content = Utilities.trim_newlines(content)
109
+ content += "\n" if paragraph
110
+ content = content.gsub(/\n/, "\n#{' ' * prefix.length}")
111
+ "#{prefix}#{content}#{node.next_sibling ? "\n" : ""}"
112
+ end
113
+ ),
114
+ indented_code_block: Rule.new(
115
+ filter: lambda do |node, options|
116
+ options[:code_block_style] == "indented" &&
117
+ node.node_name == "PRE" &&
118
+ node.first_child &&
119
+ node.first_child.node_name == "CODE"
120
+ end,
121
+ replacement: lambda do |_content, node, _options|
122
+ code = node.first_child.text_content.gsub("\n", "\n ")
123
+ "\n\n #{code}\n\n"
124
+ end
125
+ ),
126
+ fenced_code_block: Rule.new(
127
+ filter: lambda do |node, options|
128
+ options[:code_block_style] == "fenced" &&
129
+ node.node_name == "PRE" &&
130
+ node.first_child &&
131
+ node.first_child.node_name == "CODE"
132
+ end,
133
+ replacement: lambda do |_content, node, options|
134
+ class_name = node.first_child.get_attribute("class").to_s
135
+ language = class_name[/language-(\S+)/, 1].to_s
136
+ code = node.first_child.text_content
137
+ fence_char = options[:fence][0]
138
+ fence_size = 3
139
+
140
+ code.scan(/^#{Regexp.escape(fence_char)}{3,}/).each do |match|
141
+ fence_size = match.length + 1 if match.length >= fence_size
142
+ end
143
+
144
+ fence = Utilities.repeat(fence_char, fence_size)
145
+ "\n\n#{fence}#{language}\n#{code.sub(/\n\z/, '')}\n#{fence}\n\n"
146
+ end
147
+ ),
148
+ horizontal_rule: Rule.new(
149
+ filter: "hr",
150
+ replacement: ->(_content, _node, options) { "\n\n#{options[:hr]}\n\n" }
151
+ ),
152
+ inline_link: Rule.new(
153
+ filter: lambda do |node, options|
154
+ options[:link_style] == "inlined" &&
155
+ node.node_name == "A" &&
156
+ node.get_attribute("href")
157
+ end,
158
+ replacement: lambda do |content, node, _options|
159
+ href = escape_link_destination(node.get_attribute("href"))
160
+ title = escape_link_title(clean_attribute(node.get_attribute("title")))
161
+ title_part = title.empty? ? "" : %( "#{title}")
162
+ "[#{content}](#{href}#{title_part})"
163
+ end
164
+ ),
165
+ reference_link: ReferenceLinkRule.new,
166
+ emphasis: Rule.new(
167
+ filter: %w[em i],
168
+ replacement: lambda do |content, _node, options|
169
+ next "" if content.strip.empty?
170
+
171
+ "#{options[:em_delimiter]}#{content}#{options[:em_delimiter]}"
172
+ end
173
+ ),
174
+ strong: Rule.new(
175
+ filter: %w[strong b],
176
+ replacement: lambda do |content, _node, options|
177
+ next "" if content.strip.empty?
178
+
179
+ "#{options[:strong_delimiter]}#{content}#{options[:strong_delimiter]}"
180
+ end
181
+ ),
182
+ code: Rule.new(
183
+ filter: lambda do |node, _options|
184
+ has_siblings = node.previous_sibling || node.next_sibling
185
+ is_code_block = node.parent_node&.node_name == "PRE" && !has_siblings
186
+ node.node_name == "CODE" && !is_code_block
187
+ end,
188
+ replacement: lambda do |content, _node, _options|
189
+ next "" if content.empty?
190
+
191
+ content = content.gsub(/\r?\n|\r/, " ")
192
+ extra_space =
193
+ if content.match?(/\A`/) || content.match?(/\A .*?[^ ].* \z/) || content.match?(/`\z/)
194
+ " "
195
+ else
196
+ ""
197
+ end
198
+
199
+ delimiter = "`"
200
+ matches = content.scan(/`+/)
201
+ delimiter += "`" while matches.include?(delimiter)
202
+
203
+ "#{delimiter}#{extra_space}#{content}#{extra_space}#{delimiter}"
204
+ end
205
+ ),
206
+ image: Rule.new(
207
+ filter: "img",
208
+ replacement: lambda do |_content, node, _options|
209
+ alt = Utilities.escape_markdown(clean_attribute(node.get_attribute("alt")))
210
+ src = escape_link_destination(node.get_attribute("src").to_s)
211
+ title = clean_attribute(node.get_attribute("title"))
212
+ title_part = title.empty? ? "" : %( "#{escape_link_title(title)}")
213
+ src.empty? ? "" : "![#{alt}](#{src}#{title_part})"
214
+ end
215
+ )
216
+ }
217
+ end
218
+
219
+ def clean_attribute(attribute)
220
+ attribute.to_s.gsub(/(\n+\s*)+/, "\n")
221
+ end
222
+
223
+ def escape_link_destination(destination)
224
+ escaped = destination.to_s.gsub(/([<>()])/, '\\\\\1')
225
+ escaped.include?(" ") ? "<#{escaped}>" : escaped
226
+ end
227
+
228
+ def escape_link_title(title)
229
+ title.to_s.gsub('"', '\"')
230
+ end
231
+ end
232
+ end