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.
@@ -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