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
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Turndown
|
|
4
|
+
class Converter
|
|
5
|
+
attr_reader :options, :rules
|
|
6
|
+
|
|
7
|
+
def initialize(options = {})
|
|
8
|
+
normalized_options = normalize_options(options)
|
|
9
|
+
@options = Utilities.extend(default_options, normalized_options)
|
|
10
|
+
@rules = Rules.new(@options)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def convert(input)
|
|
14
|
+
raise TypeError, "#{input} is not a string, or an element/document/fragment node." unless can_convert?(input)
|
|
15
|
+
return "" if input == ""
|
|
16
|
+
|
|
17
|
+
output = process(RootNode.build(input, options))
|
|
18
|
+
post_process(output)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def use(plugin)
|
|
22
|
+
if plugin.is_a?(Array)
|
|
23
|
+
plugin.each { |entry| use(entry) }
|
|
24
|
+
elsif plugin.respond_to?(:call)
|
|
25
|
+
plugin.call(self)
|
|
26
|
+
else
|
|
27
|
+
raise TypeError, "plugin must be a callable or an Array of callables"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
self
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def add_rule(name, filter:, replacement:, append: nil)
|
|
34
|
+
rules.add(name, Rule.new(filter: filter, replacement: replacement, append: append))
|
|
35
|
+
self
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def keep(filter)
|
|
39
|
+
rules.keep(filter)
|
|
40
|
+
self
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def remove(filter)
|
|
44
|
+
rules.remove(filter)
|
|
45
|
+
self
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def escape(string)
|
|
49
|
+
Utilities.escape_markdown(string)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def process(parent_raw)
|
|
55
|
+
parent = parent_raw.is_a?(Node) ? parent_raw : Node.new(parent_raw, options)
|
|
56
|
+
|
|
57
|
+
parent.child_nodes.reduce("") do |output, node|
|
|
58
|
+
replacement =
|
|
59
|
+
if node.text_node?
|
|
60
|
+
node.code? ? node.node_value : escape(node.node_value)
|
|
61
|
+
elsif node.element?
|
|
62
|
+
replacement_for_node(node)
|
|
63
|
+
else
|
|
64
|
+
""
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
join(output, replacement)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def post_process(output)
|
|
72
|
+
rules.each do |rule|
|
|
73
|
+
appended = rule.append(options)
|
|
74
|
+
output = join(output, appended) unless appended.empty?
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
output.sub(/\A[\t\r\n]+/, "").sub(/\s+\z/, "")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def replacement_for_node(node)
|
|
81
|
+
rule = rules.for_node(node)
|
|
82
|
+
content = process(node)
|
|
83
|
+
whitespace = node.flanking_whitespace
|
|
84
|
+
if !whitespace[:leading].empty? || !whitespace[:trailing].empty?
|
|
85
|
+
content = content.gsub(/\A[[:space:]]+|[[:space:]]+\z/, "")
|
|
86
|
+
end
|
|
87
|
+
"#{whitespace[:leading]}#{rule.replacement(content, node, options)}#{whitespace[:trailing]}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def join(output, replacement)
|
|
91
|
+
s1 = Utilities.trim_trailing_newlines(output)
|
|
92
|
+
s2 = Utilities.trim_leading_newlines(replacement)
|
|
93
|
+
newlines = [output.length - s1.length, replacement.length - s2.length].max
|
|
94
|
+
separator = "\n\n"[0, newlines]
|
|
95
|
+
"#{s1}#{separator}#{s2}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def normalize_options(options)
|
|
99
|
+
options.each_with_object({}) do |(key, value), normalized|
|
|
100
|
+
normalized[normalize_option_key(key)] = value
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def normalize_option_key(key)
|
|
105
|
+
key.to_s.gsub(/([A-Z])/, '_\1').downcase.sub(/\A_/, "").to_sym
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def can_convert?(input)
|
|
109
|
+
input.is_a?(String) || input.is_a?(Nokogiri::XML::Node) || input.is_a?(Nokogiri::XML::DocumentFragment)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def default_options
|
|
113
|
+
{
|
|
114
|
+
rules: CommonMarkRules.build,
|
|
115
|
+
heading_style: "setext",
|
|
116
|
+
hr: "* * *",
|
|
117
|
+
bullet_list_marker: "*",
|
|
118
|
+
code_block_style: "indented",
|
|
119
|
+
fence: "```",
|
|
120
|
+
em_delimiter: "_",
|
|
121
|
+
strong_delimiter: "**",
|
|
122
|
+
link_style: "inlined",
|
|
123
|
+
link_reference_style: "full",
|
|
124
|
+
br: " ",
|
|
125
|
+
preformatted_code: false,
|
|
126
|
+
blank_replacement: ->(_content, node, _options) { node.block? ? "\n\n" : "" },
|
|
127
|
+
keep_replacement: lambda do |_content, node, _options|
|
|
128
|
+
node.block? ? "\n\n#{node.outer_html}\n\n" : node.outer_html
|
|
129
|
+
end,
|
|
130
|
+
default_replacement: lambda do |content, node, _options|
|
|
131
|
+
node.block? ? "\n\n#{content}\n\n" : content
|
|
132
|
+
end
|
|
133
|
+
}
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Turndown
|
|
4
|
+
class Node
|
|
5
|
+
attr_reader :raw, :options
|
|
6
|
+
|
|
7
|
+
def initialize(raw, options)
|
|
8
|
+
@raw = raw
|
|
9
|
+
@options = options
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def node_name
|
|
13
|
+
raw.name.to_s.upcase
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def node_name_downcase
|
|
17
|
+
raw.name.to_s.downcase
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def node_type
|
|
21
|
+
return 9 if raw.is_a?(Nokogiri::HTML5::Document) || raw.is_a?(Nokogiri::HTML4::Document)
|
|
22
|
+
return 11 if raw.is_a?(Nokogiri::HTML5::DocumentFragment) || raw.is_a?(Nokogiri::XML::DocumentFragment)
|
|
23
|
+
return 1 if raw.element?
|
|
24
|
+
return 4 if raw.cdata?
|
|
25
|
+
return 3 if raw.text?
|
|
26
|
+
|
|
27
|
+
0
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def text_node?
|
|
31
|
+
node_type == 3 || node_type == 4
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def element?
|
|
35
|
+
node_type == 1
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def document?
|
|
39
|
+
node_type == 9
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def document_fragment?
|
|
43
|
+
node_type == 11
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def block?
|
|
47
|
+
Utilities.is_block?(self)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def void?
|
|
51
|
+
Utilities.is_void?(self)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def meaningful_when_blank?
|
|
55
|
+
Utilities.is_meaningful_when_blank?(self)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def code?
|
|
59
|
+
node_name == "CODE" || parent_node&.code?
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def blank?
|
|
63
|
+
!void? &&
|
|
64
|
+
!meaningful_when_blank? &&
|
|
65
|
+
text_content.match?(/\A\s*\z/) &&
|
|
66
|
+
!Utilities.has_void?(raw) &&
|
|
67
|
+
!Utilities.has_meaningful_when_blank?(raw)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def flanking_whitespace
|
|
71
|
+
return { leading: "", trailing: "" } if block? || (options[:preformatted_code] && code?)
|
|
72
|
+
|
|
73
|
+
edges = edge_whitespace(text_content)
|
|
74
|
+
if edges[:leading_ascii] != "" && flanked_by_whitespace?(:left)
|
|
75
|
+
edges[:leading] = edges[:leading_non_ascii]
|
|
76
|
+
end
|
|
77
|
+
if edges[:trailing_ascii] != "" && flanked_by_whitespace?(:right)
|
|
78
|
+
edges[:trailing] = edges[:trailing_non_ascii]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
{ leading: edges[:leading], trailing: edges[:trailing] }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def node_value
|
|
85
|
+
raw.content
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def text_content
|
|
89
|
+
raw.text
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def outer_html
|
|
93
|
+
raw.to_html
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def class_name
|
|
97
|
+
get_attribute("class").to_s
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def type
|
|
101
|
+
get_attribute("type")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def checked?
|
|
105
|
+
!get_attribute("checked").nil?
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def get_attribute(name)
|
|
109
|
+
raw[name]
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def child_nodes
|
|
113
|
+
raw.children.map { |child| self.class.new(child, options) }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def parent_node
|
|
117
|
+
return nil unless raw.respond_to?(:parent) && raw.parent
|
|
118
|
+
|
|
119
|
+
self.class.new(raw.parent, options)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def previous_sibling
|
|
123
|
+
return nil unless raw.respond_to?(:previous_sibling) && raw.previous_sibling
|
|
124
|
+
|
|
125
|
+
self.class.new(raw.previous_sibling, options)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def next_sibling
|
|
129
|
+
return nil unless raw.respond_to?(:next_sibling) && raw.next_sibling
|
|
130
|
+
|
|
131
|
+
self.class.new(raw.next_sibling, options)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def first_child
|
|
135
|
+
child = raw.children.first
|
|
136
|
+
return nil unless child
|
|
137
|
+
|
|
138
|
+
self.class.new(child, options)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def last_element_child
|
|
142
|
+
child = raw.element_children.last
|
|
143
|
+
child && self.class.new(child, options)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def children
|
|
147
|
+
raw.element_children.map { |child| self.class.new(child, options) }
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def rows
|
|
151
|
+
raw.css("tr").map { |row| self.class.new(row, options) }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def same_node?(other)
|
|
155
|
+
raw == other.raw
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def inspect
|
|
159
|
+
"#<Turndown::Node #{node_name}>"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
private
|
|
163
|
+
|
|
164
|
+
def edge_whitespace(string)
|
|
165
|
+
chars = string.chars
|
|
166
|
+
ascii_whitespace = +""
|
|
167
|
+
non_ascii_leading = +""
|
|
168
|
+
index = 0
|
|
169
|
+
|
|
170
|
+
while index < chars.length && ascii_whitespace?(chars[index])
|
|
171
|
+
ascii_whitespace << chars[index]
|
|
172
|
+
index += 1
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
while index < chars.length && whitespace?(chars[index])
|
|
176
|
+
non_ascii_leading << chars[index]
|
|
177
|
+
index += 1
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
if index == chars.length
|
|
181
|
+
leading = ascii_whitespace + non_ascii_leading
|
|
182
|
+
return {
|
|
183
|
+
leading: leading,
|
|
184
|
+
leading_ascii: ascii_whitespace,
|
|
185
|
+
leading_non_ascii: non_ascii_leading,
|
|
186
|
+
trailing: "",
|
|
187
|
+
trailing_non_ascii: "",
|
|
188
|
+
trailing_ascii: ""
|
|
189
|
+
}
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
trailing_ascii = +""
|
|
193
|
+
trailing_non_ascii = +""
|
|
194
|
+
index = chars.length - 1
|
|
195
|
+
|
|
196
|
+
while index >= 0 && ascii_whitespace?(chars[index])
|
|
197
|
+
trailing_ascii.prepend(chars[index])
|
|
198
|
+
index -= 1
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
while index >= 0 && whitespace?(chars[index])
|
|
202
|
+
trailing_non_ascii.prepend(chars[index])
|
|
203
|
+
index -= 1
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
{
|
|
207
|
+
leading: ascii_whitespace + non_ascii_leading,
|
|
208
|
+
leading_ascii: ascii_whitespace,
|
|
209
|
+
leading_non_ascii: non_ascii_leading,
|
|
210
|
+
trailing: trailing_non_ascii + trailing_ascii,
|
|
211
|
+
trailing_non_ascii: trailing_non_ascii,
|
|
212
|
+
trailing_ascii: trailing_ascii
|
|
213
|
+
}
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def ascii_whitespace?(char)
|
|
217
|
+
[" ", "\t", "\r", "\n"].include?(char)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def whitespace?(char)
|
|
221
|
+
char.match?(/[[:space:]]/)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def flanked_by_whitespace?(side)
|
|
225
|
+
sibling, pattern =
|
|
226
|
+
if side == :left
|
|
227
|
+
[raw.previous_sibling, / $/]
|
|
228
|
+
else
|
|
229
|
+
[raw.next_sibling, /^ /]
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
return false unless sibling
|
|
233
|
+
|
|
234
|
+
if sibling.text? || sibling.cdata?
|
|
235
|
+
sibling.content.match?(pattern)
|
|
236
|
+
elsif options[:preformatted_code] && sibling.element? && sibling.name.casecmp("code").zero?
|
|
237
|
+
false
|
|
238
|
+
elsif sibling.element? && !Utilities.is_block?(self.class.new(sibling, options))
|
|
239
|
+
sibling.text.match?(pattern)
|
|
240
|
+
else
|
|
241
|
+
false
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Ported from turndown-plugin-gfm by Dom Christie
|
|
4
|
+
# (https://github.com/mixmark-io/turndown-plugin-gfm), released under the
|
|
5
|
+
# MIT License. See LICENSE for the full notice.
|
|
6
|
+
|
|
7
|
+
module Turndown
|
|
8
|
+
module Plugins
|
|
9
|
+
module GFM
|
|
10
|
+
HIGHLIGHT_REGEXP = /highlight-(?:text|source)-([a-z0-9]+)/
|
|
11
|
+
ALIGN_MAP = {
|
|
12
|
+
"left" => ":--",
|
|
13
|
+
"right" => "--:",
|
|
14
|
+
"center" => ":-:"
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
def call(converter)
|
|
20
|
+
converter.use([
|
|
21
|
+
HighlightedCodeBlock,
|
|
22
|
+
Strikethrough,
|
|
23
|
+
Tables,
|
|
24
|
+
TaskListItems
|
|
25
|
+
])
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
module Strikethrough
|
|
29
|
+
module_function
|
|
30
|
+
|
|
31
|
+
def call(converter)
|
|
32
|
+
converter.add_rule(
|
|
33
|
+
:strikethrough,
|
|
34
|
+
filter: %w[del s strike],
|
|
35
|
+
replacement: ->(content, _node, _options) { "~#{content}~" }
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
module TaskListItems
|
|
41
|
+
module_function
|
|
42
|
+
|
|
43
|
+
def call(converter)
|
|
44
|
+
converter.add_rule(
|
|
45
|
+
:task_list_items,
|
|
46
|
+
filter: lambda do |node, _options|
|
|
47
|
+
node.type == "checkbox" && node.parent_node&.node_name == "LI"
|
|
48
|
+
end,
|
|
49
|
+
replacement: ->(_content, node, _options) { "#{node.checked? ? '[x]' : '[ ]'} " }
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
module HighlightedCodeBlock
|
|
55
|
+
module_function
|
|
56
|
+
|
|
57
|
+
def call(converter)
|
|
58
|
+
converter.add_rule(
|
|
59
|
+
:highlighted_code_block,
|
|
60
|
+
filter: lambda do |node, _options|
|
|
61
|
+
first_child = node.first_child
|
|
62
|
+
node.node_name == "DIV" &&
|
|
63
|
+
node.class_name.match?(HIGHLIGHT_REGEXP) &&
|
|
64
|
+
first_child &&
|
|
65
|
+
first_child.node_name == "PRE"
|
|
66
|
+
end,
|
|
67
|
+
replacement: lambda do |_content, node, options|
|
|
68
|
+
language = node.class_name[HIGHLIGHT_REGEXP, 1].to_s
|
|
69
|
+
"\n\n#{options[:fence]}#{language}\n#{node.first_child.text_content}\n#{options[:fence]}\n\n"
|
|
70
|
+
end
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
module Tables
|
|
76
|
+
module_function
|
|
77
|
+
|
|
78
|
+
def call(converter)
|
|
79
|
+
converter.keep(lambda { |node, _options| node.node_name == "TABLE" && node.rows.none? { |row| heading_row?(row) } })
|
|
80
|
+
|
|
81
|
+
converter.add_rule(
|
|
82
|
+
:table_cell,
|
|
83
|
+
filter: %w[th td],
|
|
84
|
+
replacement: ->(content, node, _options) { cell(content, node) }
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
converter.add_rule(
|
|
88
|
+
:table_row,
|
|
89
|
+
filter: "tr",
|
|
90
|
+
replacement: lambda do |content, node, _options|
|
|
91
|
+
next "" if empty_row?(node)
|
|
92
|
+
|
|
93
|
+
border_cells = String.new
|
|
94
|
+
|
|
95
|
+
if heading_row?(node)
|
|
96
|
+
node.child_nodes.each do |child|
|
|
97
|
+
next unless %w[TH TD].include?(child.node_name)
|
|
98
|
+
|
|
99
|
+
border = ALIGN_MAP.fetch(child.get_attribute("align").to_s.downcase, "---")
|
|
100
|
+
border_cells << cell(border, child)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
"\n#{content}#{border_cells.empty? ? '' : "\n#{border_cells}"}"
|
|
105
|
+
end
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
converter.add_rule(
|
|
109
|
+
:table,
|
|
110
|
+
filter: lambda do |node, _options|
|
|
111
|
+
node.node_name == "TABLE" && node.rows.any? { |row| heading_row?(row) }
|
|
112
|
+
end,
|
|
113
|
+
replacement: lambda do |content, _node, _options|
|
|
114
|
+
"\n\n#{content.sub("\n\n", "\n")}\n\n"
|
|
115
|
+
end
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
converter.add_rule(
|
|
119
|
+
:table_section,
|
|
120
|
+
filter: %w[thead tbody tfoot],
|
|
121
|
+
replacement: ->(content, _node, _options) { content }
|
|
122
|
+
)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def heading_row?(row)
|
|
126
|
+
return false unless row
|
|
127
|
+
|
|
128
|
+
parent = row.parent_node
|
|
129
|
+
cells = row.child_nodes.select(&:element?)
|
|
130
|
+
return false if cells.empty?
|
|
131
|
+
|
|
132
|
+
if parent&.node_name == "THEAD"
|
|
133
|
+
cells.any? { |child| !child.text_content.empty? }
|
|
134
|
+
else
|
|
135
|
+
return false unless cells.all? { |child| child.node_name == "TH" }
|
|
136
|
+
|
|
137
|
+
parent &&
|
|
138
|
+
row.same_node?(parent.first_child || row) &&
|
|
139
|
+
(parent.node_name == "TABLE" || first_tbody?(parent))
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def first_tbody?(element)
|
|
144
|
+
previous_sibling = element.previous_sibling
|
|
145
|
+
element.node_name == "TBODY" &&
|
|
146
|
+
(
|
|
147
|
+
previous_sibling.nil? ||
|
|
148
|
+
(
|
|
149
|
+
previous_sibling.node_name == "THEAD" &&
|
|
150
|
+
previous_sibling.text_content.match?(/\A\s*\z/)
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def cell(content, node)
|
|
156
|
+
index = node.parent_node.children.find_index { |child| child.same_node?(node) } || 0
|
|
157
|
+
prefix = index.zero? ? "| " : " "
|
|
158
|
+
"#{prefix}#{content} |"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def empty_row?(row)
|
|
162
|
+
return false if heading_row?(row)
|
|
163
|
+
|
|
164
|
+
cells = row.child_nodes.select(&:element?)
|
|
165
|
+
cells.any? && cells.all? { |cell| cell.text_content.empty? }
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Turndown
|
|
4
|
+
class RootNode
|
|
5
|
+
ROOT_ID = "turndown-root".freeze
|
|
6
|
+
|
|
7
|
+
class << self
|
|
8
|
+
def build(input, options)
|
|
9
|
+
root =
|
|
10
|
+
if input.is_a?(String)
|
|
11
|
+
document = Nokogiri::HTML5::Document.parse(%(<x-turndown id="#{ROOT_ID}">#{input}</x-turndown>))
|
|
12
|
+
document.at_css("##{ROOT_ID}")
|
|
13
|
+
else
|
|
14
|
+
input.dup
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
normalize_pre(root)
|
|
18
|
+
CollapseWhitespace.call(
|
|
19
|
+
element: root,
|
|
20
|
+
is_block: ->(node) { Utilities.is_block?(node) },
|
|
21
|
+
is_void: ->(node) { Utilities.is_void?(node) },
|
|
22
|
+
is_pre: options[:preformatted_code] ? ->(node) { pre_or_code?(node) } : nil
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
root
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def pre_or_code?(node)
|
|
31
|
+
return false unless node.element?
|
|
32
|
+
|
|
33
|
+
%w[pre code].include?(node.name.downcase)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def normalize_pre(root)
|
|
37
|
+
root.css("pre br").each do |node|
|
|
38
|
+
node.replace(Nokogiri::XML::Text.new("\n", node.document))
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Turndown
|
|
4
|
+
class Rule
|
|
5
|
+
attr_reader :filter
|
|
6
|
+
|
|
7
|
+
def initialize(filter: nil, replacement:, append: nil)
|
|
8
|
+
@filter = filter
|
|
9
|
+
@replacement = replacement
|
|
10
|
+
@append = append
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def matches?(node, options)
|
|
14
|
+
case filter
|
|
15
|
+
when String, Symbol
|
|
16
|
+
filter.to_s == node.node_name_downcase
|
|
17
|
+
when Array
|
|
18
|
+
filter.map(&:to_s).include?(node.node_name_downcase)
|
|
19
|
+
when Proc
|
|
20
|
+
filter.call(node, options)
|
|
21
|
+
when nil
|
|
22
|
+
false
|
|
23
|
+
else
|
|
24
|
+
raise TypeError, "`filter` needs to be a string, array, or function"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def replacement(content, node, options)
|
|
29
|
+
@replacement.call(content, node, options)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def append(options)
|
|
33
|
+
return "" unless @append
|
|
34
|
+
|
|
35
|
+
@append.call(options)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Turndown
|
|
4
|
+
class Rules
|
|
5
|
+
def initialize(options)
|
|
6
|
+
@options = options
|
|
7
|
+
@keep = []
|
|
8
|
+
@remove = []
|
|
9
|
+
@blank_rule = Rule.new(replacement: options[:blank_replacement])
|
|
10
|
+
@keep_replacement = options[:keep_replacement]
|
|
11
|
+
@default_rule = Rule.new(replacement: options[:default_replacement])
|
|
12
|
+
@array = options.fetch(:rules).values.dup
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def add(_key, rule)
|
|
16
|
+
@array.unshift(rule)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def keep(filter)
|
|
20
|
+
@keep.unshift(
|
|
21
|
+
Rule.new(
|
|
22
|
+
filter: filter,
|
|
23
|
+
replacement: @keep_replacement
|
|
24
|
+
)
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def remove(filter)
|
|
29
|
+
@remove.unshift(
|
|
30
|
+
Rule.new(
|
|
31
|
+
filter: filter,
|
|
32
|
+
replacement: ->(_content, _node, _options) { "" }
|
|
33
|
+
)
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def for_node(node)
|
|
38
|
+
return @blank_rule if node.blank?
|
|
39
|
+
|
|
40
|
+
find_rule(@array, node) || find_rule(@keep, node) || find_rule(@remove, node) || @default_rule
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def each(&block)
|
|
44
|
+
@array.each(&block)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def find_rule(rules, node)
|
|
50
|
+
rules.find { |rule| rule.matches?(node, @options) }
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|