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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +12 -0
- data/LICENSE +202 -0
- data/NOTICE +10 -0
- data/README.md +246 -0
- data/lib/nokodown/boilerplate.rb +236 -0
- data/lib/nokodown/charset.rb +27 -0
- data/lib/nokodown/container_check.rb +174 -0
- data/lib/nokodown/content_locator.rb +118 -0
- data/lib/nokodown/dom.rb +140 -0
- data/lib/nokodown/extractor.rb +137 -0
- data/lib/nokodown/markdown/cleanup.rb +111 -0
- data/lib/nokodown/markdown/code_block.rb +71 -0
- data/lib/nokodown/markdown/image.rb +63 -0
- data/lib/nokodown/markdown/image_cleanup.rb +166 -0
- data/lib/nokodown/markdown/inline.rb +340 -0
- data/lib/nokodown/markdown/redundant_images.rb +142 -0
- data/lib/nokodown/markdown/renderer.rb +339 -0
- data/lib/nokodown/markdown/table.rb +93 -0
- data/lib/nokodown/markdown/text.rb +79 -0
- data/lib/nokodown/metadata.rb +316 -0
- data/lib/nokodown/patterns.rb +152 -0
- data/lib/nokodown/preprocessor.rb +226 -0
- data/lib/nokodown/product_reviews.rb +90 -0
- data/lib/nokodown/pruner.rb +337 -0
- data/lib/nokodown/result.rb +8 -0
- data/lib/nokodown/scorer.rb +230 -0
- data/lib/nokodown/stats.rb +79 -0
- data/lib/nokodown/tables.rb +62 -0
- data/lib/nokodown/url_resolver.rb +249 -0
- data/lib/nokodown/version.rb +5 -0
- data/lib/nokodown.rb +54 -0
- metadata +93 -0
|
@@ -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?("
|
|
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
|