nokodown 0.1.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,339 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nokodown
4
+ module Markdown
5
+ Block = Struct.new(:kind, :text, :level)
6
+
7
+ # Turns DOM nodes into Markdown blocks. Runs of inline content become
8
+ # paragraphs; block elements are rendered by tag.
9
+ class Renderer
10
+ MAX_DEPTH = 400
11
+ SEPARATOR_LINE = /\A\s*([-=_*~])\1{2,}\s*\z/.freeze
12
+ GLYPHS = "•·▪◦‣►▶▸➤→■●○"
13
+ # A run of glyphs (●●●○○) is a rating, not a bullet.
14
+ BULLET_GLYPH = /\A(?:[#{GLYPHS}](?![#{GLYPHS}])|[-–—](?=\s))\s*/.freeze
15
+ # Any symbols before a space can be an item's marker (+ and - in pros
16
+ # and cons, ✓ and ✗), not only the glyphs that are stripped.
17
+ LEADING_SYMBOLS = /\A[^\p{L}\p{N}\s]+(?=\s)/.freeze
18
+
19
+ def initialize(urls, images: true, links: true, redundant_images: {})
20
+ @inline = Inline.new(urls, images: images, links: links, redundant_images: redundant_images)
21
+ @links = links
22
+ @depth = 0
23
+ @contains_block = {}
24
+ end
25
+
26
+ def render(nodes)
27
+ nodes.flat_map { |node| block_level?(node) ? block(node) : children_blocks(node) }
28
+ end
29
+
30
+ def children_blocks(node)
31
+ blocks = []
32
+ run = []
33
+ node.children.each do |child|
34
+ if block_level?(child)
35
+ blocks.concat(paragraphs(run))
36
+ run = []
37
+ blocks.concat(block(child))
38
+ else
39
+ run << child
40
+ end
41
+ end
42
+ blocks.concat(paragraphs(run))
43
+ end
44
+
45
+ def paragraphs(nodes)
46
+ nodes.empty? ? [] : text_paragraphs(@inline.render(nodes))
47
+ end
48
+
49
+ # Two or more <br> in a row separate paragraphs, as Readability treats them.
50
+ def text_paragraphs(text)
51
+ text.split(/[ \t]*\n[ \t]*\n\s*/).filter_map do |chunk|
52
+ chunk = Text.tidy(chunk).lines.reject { |line| line.match?(SEPARATOR_LINE) }.join.strip
53
+ Block.new(:paragraph, Text.escape_line_starts(chunk)) unless chunk.empty?
54
+ end
55
+ end
56
+
57
+ def inline(nodes)
58
+ @inline.render(nodes)
59
+ end
60
+
61
+ def rendered_images
62
+ @inline.rendered_images
63
+ end
64
+
65
+ def block_level?(node)
66
+ return false unless node.element?
67
+ return true if Dom::BLOCK.include?(node.name)
68
+ return CodeBlock.standalone?(node) if node.name == "code"
69
+
70
+ contains_block?(node)
71
+ end
72
+
73
+ def contains_block?(node)
74
+ key = Dom.key(node)
75
+ return @contains_block[key] if key && @contains_block.key?(key)
76
+
77
+ found = node.element_children.any? { |child| Dom::BLOCK.include?(child.name) || contains_block?(child) }
78
+ @contains_block[key] = found if key
79
+ found
80
+ end
81
+
82
+ private
83
+
84
+ def block(node)
85
+ return paragraphs([node]) if @depth > MAX_DEPTH
86
+
87
+ @depth += 1
88
+ begin
89
+ render_block(node)
90
+ ensure
91
+ @depth -= 1
92
+ end
93
+ end
94
+
95
+ def render_block(node)
96
+ case node.name
97
+ when "h1", "h2", "h3", "h4", "h5", "h6" then heading(node)
98
+ when "ul", "ol", "menu", "dir" then list(node)
99
+ when "li" then format_list([children_blocks(node)].reject(&:empty?), nil)
100
+ when "blockquote" then quote(node)
101
+ when "pre" then CodeBlock.render(node, CodeBlock.preformatted(node))
102
+ when "code" then CodeBlock.render(node, node.content)
103
+ when "table" then Table.new(self, node).blocks
104
+ when "dl" then definition_list(node)
105
+ when "hr" then [Block.new(:rule, "---")]
106
+ when "iframe" then embed(node)
107
+ when "summary" then summary(node)
108
+ when "a" then linked_blocks(node)
109
+ else children_blocks(node)
110
+ end
111
+ end
112
+
113
+ # Text that long is a paragraph some site wrapped in a heading tag.
114
+ def heading(node)
115
+ text = Text.single_line(@inline.render(node.children, heading: true))
116
+ return [] if text.empty?
117
+ return [Block.new(:paragraph, text)] if text.length > 300
118
+
119
+ level = node.name[1].to_i
120
+ [Block.new(:heading, "#{'#' * level} #{text}", level)]
121
+ end
122
+
123
+ def list(node)
124
+ items = []
125
+ list_children(node).each do |child|
126
+ blocks = list_item(child)
127
+ owner = list_owner(items, wrapped: child.name == "li") if sublist?(child, blocks)
128
+ owner ? owner.concat(blocks) : items << blocks
129
+ end
130
+ items.reject!(&:empty?)
131
+ start = node.name == "ol" ? Integer(node["start"].to_s, exception: false) || 1 : nil
132
+ format_list(items, start)
133
+ end
134
+
135
+ # A nested list belongs to the item before it, also when a site wraps
136
+ # it in an item of its own (<li>Parent</li><li><ul>…</ul></li>).
137
+ def sublist?(node, blocks)
138
+ return false unless node.element? && blocks.size == 1 && blocks.first.kind == :list
139
+ return true if %w[ul ol].include?(node.name)
140
+
141
+ node.name == "li" && node.element_children.all? { |child| %w[ul ol].include?(child.name) }
142
+ end
143
+
144
+ # The last item with text of its own; empty items (<br>, whitespace) and
145
+ # items that open with a list cannot hold one. A wrapped list does not
146
+ # join an item that ends with a list already, as cons after pros.
147
+ def list_owner(items, wrapped:)
148
+ owner = items.reverse_each.find(&:any?)
149
+ owner if owner && owner.first.kind != :list && !(wrapped && owner.last.kind == :list)
150
+ end
151
+
152
+ def list_item(node)
153
+ return paragraphs([node]) unless node.element?
154
+ return children_blocks(node) if node.name == "li"
155
+
156
+ block_level?(node) ? block(node) : paragraphs([node])
157
+ end
158
+
159
+ # Wrappers between a list and its items (<ul><div><li>) are transparent.
160
+ def list_children(node)
161
+ node.children.flat_map do |child|
162
+ wrapper = child.element? && !%w[li ul ol].include?(child.name) && child.element_children.any? { |c| c.name == "li" }
163
+ wrapper ? list_children(child) : [child]
164
+ end
165
+ end
166
+
167
+ def format_list(items, start)
168
+ typed = typed_bullet?(items)
169
+ items = items.each_with_index.map { |blocks, index| without_typed_marker(blocks, typed, start && start + index) }
170
+ items.reject!(&:empty?)
171
+ return [] if items.empty?
172
+
173
+ nested_only = items.map { |blocks| blocks.size == 1 && bulleted_list?(blocks.first) }
174
+ rendered = items.each_with_index.map do |blocks, index|
175
+ previous = index.positive? && nested_only[index - 1]
176
+ list_entry(start ? "#{start + index}. " : "- ", blocks, nested_only[index] && !previous && !nested_only[index + 1])
177
+ end
178
+ loose = items.any? { |blocks| blocks.drop(1).any? { |block| block.kind != :list } }
179
+ [Block.new(:list, rendered.join(loose ? "\n\n" : "\n"))]
180
+ end
181
+
182
+ # Markers that differ between items carry meaning, as in legends where
183
+ # ● and ○ mean yes and no, or pros and cons with + and -; one glyph
184
+ # throughout is the site's own bullet.
185
+ def typed_bullet?(items)
186
+ markers = items.filter_map { |blocks| leading_marker(blocks.first) }
187
+ markers.size > 1 && markers.uniq.size == 1
188
+ end
189
+
190
+ def leading_marker(block)
191
+ return unless block.kind == :paragraph
192
+
193
+ (block.text[BULLET_GLYPH] || block.text[LEADING_SYMBOLS])&.strip
194
+ end
195
+
196
+ # Sites type their own bullet or number into items, which would repeat
197
+ # the marker ("- • a", "2. 2. b").
198
+ def without_typed_marker(blocks, typed_bullet, number)
199
+ first = blocks.first
200
+ return blocks unless first.kind == :paragraph
201
+
202
+ text = typed_bullet ? first.text.sub(BULLET_GLYPH, "") : first.text
203
+ text = text.sub(/\A#{number}[.)](?!\d)\s*/, "") if number
204
+ return blocks if text == first.text
205
+
206
+ rest = blocks.drop(1)
207
+ text.empty? ? rest : [Block.new(:paragraph, Text.escape_line_starts(text))] + rest
208
+ end
209
+
210
+ # An item holding nothing but a bulleted list would print an empty
211
+ # marker ("- - a"), so the nested items stand in for it. An item with
212
+ # more content stays one item, so product cards keep their boundaries,
213
+ # and numbered lists keep their numbering.
214
+ def list_entry(marker, blocks, hoist = false)
215
+ return blocks.first.text if hoist && marker == "- "
216
+
217
+ indent_item(marker, join_item(blocks))
218
+ end
219
+
220
+ def bulleted_list?(block)
221
+ block.kind == :list && block.text.start_with?("- ")
222
+ end
223
+
224
+ def indent_item(marker, body)
225
+ first, *rest = body.split("\n", -1)
226
+ indent = " " * marker.length
227
+ ([marker + first.to_s] + rest.map { |line| line.empty? ? "" : indent + line }).join("\n")
228
+ end
229
+
230
+ # A nested list hugs the line above it; other blocks are separated.
231
+ def join_item(blocks)
232
+ blocks.each_with_index.map { |block, index| (index.zero? ? "" : separator(block)) + block.text }.join
233
+ end
234
+
235
+ def separator(block)
236
+ block.kind == :list ? "\n" : "\n\n"
237
+ end
238
+
239
+ def quote(node)
240
+ inner = children_blocks(node).map(&:text).join("\n\n")
241
+ return [] if inner.strip.empty?
242
+
243
+ [Block.new(:quote, inner.split("\n", -1).map { |line| line.empty? ? ">" : "> #{line}" }.join("\n"))]
244
+ end
245
+
246
+ # Definitions of one paragraph each go on their term's line, so spec
247
+ # sheets stay one line per row; longer ones keep their paragraphs, lists
248
+ # and code under the term.
249
+ def definition_list(node)
250
+ entries = definition_groups(node).map do |term, definitions|
251
+ compact?(definitions) ? definition_line(term, definitions) : list_entry("- ", definition_item(term, definitions))
252
+ end
253
+ return [] if entries.empty?
254
+
255
+ runs = entries.slice_when { |a, b| a.include?("\n") || b.include?("\n") }
256
+ [Block.new(:list, runs.map { |run| run.join("\n") }.join("\n\n"))]
257
+ end
258
+
259
+ # [term, [blocks of each <dd>]] pairs; a term may be nil or have no <dd>.
260
+ def definition_groups(node)
261
+ groups = []
262
+ definition_items(node).each do |item|
263
+ if item.name == "dt"
264
+ term = Text.escape(Dom.text(item))
265
+ groups << [term, []] unless term.empty?
266
+ else
267
+ blocks = children_blocks(item)
268
+ next if blocks.empty?
269
+
270
+ groups << [nil, []] if groups.empty?
271
+ groups.last[1] << blocks
272
+ end
273
+ end
274
+ groups
275
+ end
276
+
277
+ def compact?(definitions)
278
+ definitions.all? { |blocks| blocks.size == 1 && blocks.first.kind == :paragraph }
279
+ end
280
+
281
+ def definition_line(term, definitions)
282
+ text = definitions.map { |blocks| Text.single_line(blocks.first.text) }.join("; ")
283
+ return "- #{text}" if term.nil?
284
+ return "- **#{term}**" if text.empty?
285
+
286
+ term.match?(/[::]\z/) ? "- **#{term}** #{text}" : "- **#{term}**: #{text}"
287
+ end
288
+
289
+ def definition_item(term, definitions)
290
+ blocks = definitions.flatten(1)
291
+ term ? [Block.new(:paragraph, "**#{term}**")] + blocks : blocks
292
+ end
293
+
294
+ def definition_items(node)
295
+ node.element_children.flat_map do |child|
296
+ case child.name
297
+ when "dt", "dd" then [child]
298
+ when "div" then definition_items(child)
299
+ else []
300
+ end
301
+ end
302
+ end
303
+
304
+ def embed(node)
305
+ url = @inline.link_url(Image.video_page(node["src"].to_s))
306
+ return [] unless url && @links
307
+
308
+ title = Dom.squish(node["title"].to_s)
309
+ [Block.new(:paragraph, title.empty? ? url : "[#{Text.link_text(Text.escape(title))}](#{Text.destination(url)})")]
310
+ end
311
+
312
+ def summary(node)
313
+ text = Text.single_line(@inline.render(node.children))
314
+ text.empty? ? [] : [Block.new(:paragraph, "**#{text}**")]
315
+ end
316
+
317
+ # Cards that wrap a whole block in one link keep the URL on the headline.
318
+ def linked_blocks(node)
319
+ blocks = children_blocks(node)
320
+ url = @inline.link_url(node["href"].to_s)
321
+ target = url && @links && link_target(blocks)
322
+ return blocks unless target
323
+
324
+ if target.kind == :heading
325
+ hashes, text = target.text.split(" ", 2)
326
+ target.text = "#{hashes} [#{Text.link_text(text)}](#{Text.destination(url)})"
327
+ else
328
+ target.text = "[#{Text.link_text(target.text.tr("\n", ' '))}](#{Text.destination(url)})"
329
+ end
330
+ blocks
331
+ end
332
+
333
+ def link_target(blocks)
334
+ target = blocks.find { |b| b.kind == :heading } || blocks.find { |b| b.kind == :paragraph && !b.text.start_with?("![") }
335
+ target if target && target.text.length <= 300 && !target.text.include?("](")
336
+ end
337
+ end
338
+ end
339
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nokodown
4
+ module Markdown
5
+ # Data tables become GFM tables; layout tables are unwrapped into the
6
+ # blocks they hold.
7
+ class Table
8
+ MAX_COLSPAN = 50
9
+ MAX_ROWSPAN = 500
10
+
11
+ def initialize(renderer, node)
12
+ @renderer = renderer
13
+ @node = node
14
+ end
15
+
16
+ def blocks
17
+ return layout_blocks unless Tables.data_table?(@node)
18
+
19
+ grid = trimmed_grid
20
+ return layout_blocks if grid.size < 2 || grid.first.size < 2
21
+
22
+ caption + [Block.new(:table, gfm(grid))]
23
+ end
24
+
25
+ private
26
+
27
+ def gfm(grid)
28
+ lines = grid.map { |row| "| #{row.join(' | ')} |" }
29
+ lines.insert(1, "| #{Array.new(grid.first.size, '---').join(' | ')} |")
30
+ lines.join("\n")
31
+ end
32
+
33
+ def caption
34
+ node = @node.at_css("caption")
35
+ text = node ? Text.single_line(@renderer.inline(node.children)) : ""
36
+ text.empty? ? [] : [Block.new(:paragraph, text)]
37
+ end
38
+
39
+ # Drops empty rows and columns and pads short rows.
40
+ def trimmed_grid
41
+ grid = cell_grid.reject { |row| row.all?(&:empty?) }
42
+ width = grid.map(&:size).max.to_i
43
+ grid.each { |row| row.fill("", row.size...width) }
44
+ columns = (0...width).select { |column| grid.any? { |row| !row[column].empty? } }
45
+ grid.map { |row| row.values_at(*columns) }
46
+ end
47
+
48
+ # One value per row and column: a rowspan repeats its value in the rows
49
+ # below, a colspan leaves the extra columns blank.
50
+ def cell_grid
51
+ spans = {}
52
+ Tables.rows(@node).map do |row|
53
+ line = spans.transform_values(&:first)
54
+ spans = spans.transform_values { |text, rows| [text, rows - 1] }.reject { |_, (_, rows)| rows.zero? }
55
+ place_cells(row, line, spans)
56
+ (0..(line.keys.max || -1)).map { |column| line.fetch(column, "") }
57
+ end
58
+ end
59
+
60
+ def place_cells(row, line, spans)
61
+ column = 0
62
+ Tables.cells(row).each do |cell|
63
+ column += 1 while line.key?(column)
64
+ text = Text.table_cell(@renderer.children_blocks(cell).map(&:text).join(" "))
65
+ rows = span(cell, "rowspan", MAX_ROWSPAN)
66
+ span(cell, "colspan", MAX_COLSPAN).times do |offset|
67
+ value = offset.zero? ? text : ""
68
+ line[column + offset] = value
69
+ spans[column + offset] = [value, rows - 1] if rows > 1
70
+ end
71
+ column += 1 while line.key?(column)
72
+ end
73
+ end
74
+
75
+ def span(cell, attribute, limit)
76
+ cell[attribute].to_i.clamp(1, limit)
77
+ end
78
+
79
+ # A row of inline cells reads as one line; rows holding blocks are
80
+ # rendered cell by cell.
81
+ def layout_blocks
82
+ Tables.rows(@node).flat_map do |row|
83
+ cells = Tables.cells(row).reject { |cell| cell.content.strip.empty? && cell.at_css("img").nil? }
84
+ if cells.none? { |cell| @renderer.contains_block?(cell) }
85
+ @renderer.text_paragraphs(cells.map { |cell| @renderer.inline(cell.children) }.join(" "))
86
+ else
87
+ cells.flat_map { |cell| @renderer.children_blocks(cell) }
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nokodown
4
+ module Markdown
5
+ # Escaping and whitespace rules. Escapes only what would otherwise change
6
+ # meaning, so prose stays free of backslashes.
7
+ module Text
8
+ INVISIBLE = /[\u00AD\u200B\u2060\uFEFF]/.freeze
9
+
10
+ module_function
11
+
12
+ def collapse(string)
13
+ string.gsub(INVISIBLE, "").tr(Dom::UNICODE_SPACES, " ").squeeze(" ")
14
+ end
15
+
16
+ def escape(string)
17
+ string = string.gsub(/\\(?=[!-\/:-@\[-`{-~])/, "\\\\\\\\")
18
+ string = string.gsub("`", "\\\\`")
19
+ string = string.gsub(/(?<!\s)\*|\*(?!\s)/, "\\\\*")
20
+ string = string.gsub(/(?<![\p{L}\p{N}\\])_|_(?![\p{L}\p{N}])/, "\\\\_")
21
+ string = string.gsub(/\](?=[(\[])/, "\\\\]")
22
+ string.gsub(/<(?=[A-Za-z\/!?])/, "\\\\<")
23
+ end
24
+
25
+ # Block syntax that plain text could trigger at the start of a line.
26
+ def escape_line_starts(string)
27
+ string.gsub(/^(#{'#'}{1,6})(?=\s|$)/) { "\\#{Regexp.last_match(1)}" }
28
+ .gsub(/^(>|```|~~~)/) { "\\#{Regexp.last_match(1)}" }
29
+ end
30
+
31
+ # Tidy an inline run: no spaces around line breaks, no doubled spaces.
32
+ def tidy(string)
33
+ string.gsub(/[ \t]*\n[ \t]*/, "\n").gsub(/ {2,}/, " ").strip
34
+ end
35
+
36
+ def single_line(string)
37
+ tidy(string).gsub(/\s*\n\s*/, " ")
38
+ end
39
+
40
+ def code_span(text)
41
+ longest = text.scan(/`+/).map(&:length).max || 0
42
+ fence = "`" * (longest + 1)
43
+ pad = text.start_with?("`") || text.end_with?("`") ? " " : ""
44
+ "#{fence}#{pad}#{text}#{pad}#{fence}"
45
+ end
46
+
47
+ def code_fence(text)
48
+ longest = text.scan(/^`{3,}/).map(&:length).max || 0
49
+ "`" * [3, longest + 1].max
50
+ end
51
+
52
+ def link_text(text)
53
+ return text if text.count("[") == text.count("]")
54
+
55
+ text.gsub(/(?<!\\)([\[\]])/, "\\\\\\1")
56
+ end
57
+
58
+ def destination(url)
59
+ url = url.gsub(" ", "%20").gsub("<", "%3C").gsub(">", "%3E")
60
+ url = url.gsub("(", "%28").gsub(")", "%29") unless url.count("(") == url.count(")")
61
+ url
62
+ end
63
+
64
+ # Markdown back to readable text: no images, link targets, emphasis or
65
+ # escapes.
66
+ def plain(markdown)
67
+ markdown.gsub(/!\[[^\]]*\]\([^)]*\)/, "")
68
+ .gsub(/\[([^\]]*)\]\([^)]*\)/, '\1')
69
+ .gsub(/\\([\\`*_\[\]<>#|])/, '\1')
70
+ .gsub(/\*\*|\*|~~|`/, "")
71
+ .strip
72
+ end
73
+
74
+ def table_cell(text)
75
+ single_line(text).gsub(/(?<!\\)\|/, "\\|")
76
+ end
77
+ end
78
+ end
79
+ end