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 +7 -0
- data/CHANGELOG.md +38 -0
- data/LICENSE +81 -0
- data/README.md +264 -0
- data/lib/turndown/collapse_whitespace.rb +81 -0
- data/lib/turndown/commonmark_rules.rb +232 -0
- data/lib/turndown/converter.rb +136 -0
- data/lib/turndown/node.rb +245 -0
- data/lib/turndown/plugins/gfm.rb +170 -0
- data/lib/turndown/root_node.rb +43 -0
- data/lib/turndown/rule.rb +38 -0
- data/lib/turndown/rules.rb +53 -0
- data/lib/turndown/utilities.rb +102 -0
- data/lib/turndown/version.rb +5 -0
- data/lib/turndown.rb +20 -0
- metadata +75 -0
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
|
+
[](https://rubygems.org/gems/turndown)
|
|
4
|
+
[](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? ? "" : ""
|
|
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
|