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,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nokodown
|
|
4
|
+
class Extractor
|
|
5
|
+
TITLE_SIMILARITY = 0.75
|
|
6
|
+
MIN_ARTICLE_LENGTH = 500
|
|
7
|
+
CONDITIONAL_SHARE = 0.5
|
|
8
|
+
REMOVED_LINK_SHARE = 0.5
|
|
9
|
+
|
|
10
|
+
def initialize(html, url: nil, main_content: true, title: true, images: true, links: true)
|
|
11
|
+
@html = html
|
|
12
|
+
@url = url
|
|
13
|
+
@main_content = main_content
|
|
14
|
+
@title = title
|
|
15
|
+
@images = images
|
|
16
|
+
@links = links
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call
|
|
20
|
+
doc = Nokolexbor::HTML(Charset.utf8(@html.to_s))
|
|
21
|
+
metadata = Metadata.new(doc).to_h
|
|
22
|
+
urls = UrlResolver.new(@url, doc.at_css("base[href]")&.[]("href"))
|
|
23
|
+
body = doc.at_css("body")
|
|
24
|
+
return result([], metadata, :none) if body.nil?
|
|
25
|
+
|
|
26
|
+
product_page = @main_content && ProductReviews.product_page?(doc, metadata[:page_type])
|
|
27
|
+
Preprocessor.new(body).call
|
|
28
|
+
nodes, mode = select_content(body, metadata[:page_type], product_page)
|
|
29
|
+
blocks = render(nodes, mode, urls)
|
|
30
|
+
blocks = with_title(blocks, metadata[:title]) if @title
|
|
31
|
+
result(blocks, metadata, mode)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def render(nodes, mode, urls)
|
|
37
|
+
redundant = redundant_images(nodes, mode, urls)
|
|
38
|
+
renderer = Markdown::Renderer.new(urls, images: @images, links: @links, redundant_images: redundant)
|
|
39
|
+
Markdown::Cleanup.call(renderer.render(nodes), renderer.rendered_images)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Converting the whole page keeps every visible image.
|
|
43
|
+
def redundant_images(nodes, mode, urls)
|
|
44
|
+
return {} if mode == :full || !@images
|
|
45
|
+
|
|
46
|
+
Markdown::RedundantImages.new(nodes, mode, urls).call
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def select_content(body, page_type, product_page)
|
|
50
|
+
return [[body], :full] unless @main_content
|
|
51
|
+
|
|
52
|
+
Boilerplate.new(body).call
|
|
53
|
+
ProductReviews.new(body).call if product_page
|
|
54
|
+
selection = ContentLocator.new(body, page_type: page_type).call
|
|
55
|
+
return [cleaned_article(selection.nodes, page_type), :article] if selection.mode == :article
|
|
56
|
+
|
|
57
|
+
Pruner.new(selection.nodes, :page).call
|
|
58
|
+
[selection.nodes, :page]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Like Readability, fall back to lighter cleaning when conditional cleaning
|
|
62
|
+
# throws away most of what it sees; markup made of many small linky blocks
|
|
63
|
+
# (React apps, card grids) trips it, and a short intro would otherwise pass
|
|
64
|
+
# for the article. When almost nothing is left at all, the content is
|
|
65
|
+
# pruned like a page. Cleaning runs on a copy so it can be undone.
|
|
66
|
+
def cleaned_article(nodes, page_type)
|
|
67
|
+
copies = nodes.map(&:dup)
|
|
68
|
+
pruner = Pruner.new(copies, :article).call
|
|
69
|
+
if almost_nothing_kept?(copies, nodes)
|
|
70
|
+
Pruner.new(nodes, :page).call
|
|
71
|
+
return nodes
|
|
72
|
+
end
|
|
73
|
+
return copies if keep_conditional_cleaning?(pruner, page_type)
|
|
74
|
+
|
|
75
|
+
Pruner.new(nodes, :article, conditional: false).call
|
|
76
|
+
nodes
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# When what conditional cleaning removed from a declared article is mostly
|
|
80
|
+
# links, it was a grid of teasers for other stories, not the article. On
|
|
81
|
+
# listings and hubs the same teasers are the content.
|
|
82
|
+
def keep_conditional_cleaning?(pruner, page_type)
|
|
83
|
+
return true if pruner.conditional_share >= CONDITIONAL_SHARE
|
|
84
|
+
|
|
85
|
+
page_type == :article && pruner.removed_link_share > REMOVED_LINK_SHARE
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def almost_nothing_kept?(copies, nodes)
|
|
89
|
+
kept = copies.sum { |node| Dom.text(node).length }
|
|
90
|
+
kept < MIN_ARTICLE_LENGTH && kept < nodes.sum { |node| Dom.text(node).length } * 0.25
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# One H1 at the top: the page's own if the content has one, otherwise the
|
|
94
|
+
# metadata title, replacing a near-duplicate heading right below it.
|
|
95
|
+
# Nothing found stays empty, so a script-rendered shell or a bot wall does
|
|
96
|
+
# not pass for a page; Result#title still has the title.
|
|
97
|
+
def with_title(blocks, title)
|
|
98
|
+
return blocks if blocks.empty?
|
|
99
|
+
|
|
100
|
+
first_h1 = blocks.find { |block| block.kind == :heading && block.level == 1 }
|
|
101
|
+
return demote_h1s(blocks, first_h1) if first_h1
|
|
102
|
+
return blocks if title.nil?
|
|
103
|
+
|
|
104
|
+
duplicate = blocks.first(3).find do |block|
|
|
105
|
+
block.kind == :heading && Dom.similarity(title, Markdown::Cleanup.heading_text(block)) > TITLE_SIMILARITY
|
|
106
|
+
end
|
|
107
|
+
[Markdown::Block.new(:heading, "# #{Markdown::Text.escape(title)}", 1)] + blocks.reject { |block| block.equal?(duplicate) }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def demote_h1s(blocks, keep)
|
|
111
|
+
blocks.each do |block|
|
|
112
|
+
next unless block.kind == :heading && block.level == 1 && !block.equal?(keep)
|
|
113
|
+
|
|
114
|
+
block.text = "##{block.text}"
|
|
115
|
+
block.level = 2
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def result(blocks, metadata, mode)
|
|
120
|
+
Result.new(
|
|
121
|
+
markdown: blocks.map(&:text).join("\n\n"),
|
|
122
|
+
title: metadata[:title],
|
|
123
|
+
byline: metadata[:byline],
|
|
124
|
+
excerpt: metadata[:excerpt] || first_paragraph(blocks),
|
|
125
|
+
site_name: metadata[:site_name],
|
|
126
|
+
published_time: metadata[:published_time],
|
|
127
|
+
lang: metadata[:lang],
|
|
128
|
+
mode: mode
|
|
129
|
+
)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def first_paragraph(blocks)
|
|
133
|
+
paragraphs = blocks.select { |block| block.kind == :paragraph }.map { |block| Markdown::Text.plain(block.text) }
|
|
134
|
+
paragraphs.find { |text| text.length >= 80 } || paragraphs.find { |text| !text.empty? }
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nokodown
|
|
4
|
+
module Markdown
|
|
5
|
+
# Block-level tidying after rendering: repeated blocks, stray rules,
|
|
6
|
+
# punctuation-only lines, section headings left without a section, and
|
|
7
|
+
# redundant or repeated images.
|
|
8
|
+
module Cleanup
|
|
9
|
+
DEDUPE_WINDOW = 4
|
|
10
|
+
IMAGE_ONLY = /\A!\[([^\]]*)\]\([^)]*\)\z/.freeze
|
|
11
|
+
LINKED_HEADING = /\A#+ \[.*\]\(.*\)\z/.freeze
|
|
12
|
+
TITLE_LIST_MIN = 3
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# images: the Renderer's rendered_images.
|
|
17
|
+
def call(blocks, images = {})
|
|
18
|
+
blocks = blocks.reject { |block| noise?(block) }
|
|
19
|
+
blocks = dedupe(blocks)
|
|
20
|
+
blocks = trim_rules(blocks)
|
|
21
|
+
blocks = drop_empty_sections(blocks)
|
|
22
|
+
images.empty? ? blocks : ImageCleanup.new(images).call(blocks)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def noise?(block)
|
|
26
|
+
return false unless block.kind == :paragraph
|
|
27
|
+
|
|
28
|
+
text = block.text
|
|
29
|
+
return true unless text.match?(/[\p{L}\p{N}]/) || text.include?("](")
|
|
30
|
+
|
|
31
|
+
plain = text.gsub(/[*_`~]/, "").strip
|
|
32
|
+
plain.length <= 80 && plain.match?(Patterns::BOILERPLATE_TEXT)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Responsive and "enlarge" variants repeat an image (and its caption) a
|
|
36
|
+
# block or two later, within the same section. Dropping the image makes
|
|
37
|
+
# the captions adjacent.
|
|
38
|
+
def dedupe(blocks)
|
|
39
|
+
blocks.each_with_object([]) do |block, kept|
|
|
40
|
+
window = block.text.match?(IMAGE_ONLY) ? same_section(kept.last(DEDUPE_WINDOW)) : kept.last(1)
|
|
41
|
+
kept << block unless window.any? { |previous| identity(previous) == identity(block) }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def same_section(blocks)
|
|
46
|
+
heading = blocks.rindex { |block| block.kind == :heading }
|
|
47
|
+
heading ? blocks.drop(heading + 1) : blocks
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Variants of one image differ in URL (size, crop) but share alt text.
|
|
51
|
+
def identity(block)
|
|
52
|
+
alt = block.text[IMAGE_ONLY, 1]
|
|
53
|
+
alt && alt.length >= 10 ? "image:#{alt}" : block.text
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def trim_rules(blocks)
|
|
57
|
+
blocks = blocks.each_with_object([]) do |block, kept|
|
|
58
|
+
next if block.kind == :rule && (kept.empty? || kept.last.kind == :rule || kept.last.kind == :heading)
|
|
59
|
+
|
|
60
|
+
kept << block
|
|
61
|
+
end
|
|
62
|
+
blocks.pop while blocks.last&.kind == :rule
|
|
63
|
+
blocks
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# A heading followed by nothing, or by a heading at the same or a higher
|
|
67
|
+
# level, lost its section during extraction. Title lists are content in
|
|
68
|
+
# their own right, and a page that is only its headline keeps it.
|
|
69
|
+
def drop_empty_sections(blocks)
|
|
70
|
+
titles = title_list_indexes(blocks)
|
|
71
|
+
kept = blocks.each_index.reject { |index| !titles.include?(index) && empty_section?(blocks, index) }
|
|
72
|
+
kept.pop while kept.any? && blocks[kept.last].kind == :heading && !titles.include?(kept.last)
|
|
73
|
+
return blocks.values_at(*kept) if kept.any?
|
|
74
|
+
|
|
75
|
+
[blocks.find { |block| block.kind == :heading && block.level == 1 }].compact
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def empty_section?(blocks, index)
|
|
79
|
+
block = blocks[index]
|
|
80
|
+
return false unless block.kind == :heading
|
|
81
|
+
|
|
82
|
+
following = blocks[(index + 1)..].find { |b| b.kind != :rule }
|
|
83
|
+
return true if following.nil?
|
|
84
|
+
return false unless following.kind == :heading && following.level <= block.level
|
|
85
|
+
|
|
86
|
+
heading_text(block).match?(Patterns::NOISE_HEADING)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Same-level headings in a row, mostly links, are the titles of cards
|
|
90
|
+
# whose details were removed (products, related guides), not sections.
|
|
91
|
+
# A "Trending" or "Related" heading among them is still a section label.
|
|
92
|
+
def title_list_indexes(blocks)
|
|
93
|
+
runs = blocks.each_index.reject { |index| blocks[index].kind == :rule }.slice_when do |a, b|
|
|
94
|
+
blocks[a].kind != :heading || blocks[b].kind != :heading || blocks[a].level != blocks[b].level
|
|
95
|
+
end
|
|
96
|
+
indexes = runs.select { |run| title_list?(blocks.values_at(*run)) }.flatten
|
|
97
|
+
indexes.reject { |index| heading_text(blocks[index]).match?(Patterns::NOISE_HEADING) }.to_set
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def title_list?(headings)
|
|
101
|
+
return false if headings.size < TITLE_LIST_MIN
|
|
102
|
+
|
|
103
|
+
headings.count { |heading| heading.text.match?(LINKED_HEADING) } * 2 > headings.size
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def heading_text(block)
|
|
107
|
+
block.text.sub(/\A#+\s*/, "").gsub(/\[([^\]]*)\]\([^)]*\)/, '\1').gsub(/[*_`]/, "")
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nokodown
|
|
4
|
+
module Markdown
|
|
5
|
+
module CodeBlock
|
|
6
|
+
LANGUAGE_CLASS = /\A(?:language|lang|highlight-source|highlight)-([\w+#.-]+)\z/i.freeze
|
|
7
|
+
NO_LANGUAGE = %w[none plain plaintext text nohighlight].freeze
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def render(node, raw)
|
|
12
|
+
text = raw.tr(Dom::NBSP, " ").sub(/\A\n/, "").split("\n").map(&:rstrip).join("\n")
|
|
13
|
+
text = text.sub(/\A(?:[ \t]*\n)+/, "").rstrip
|
|
14
|
+
return [] if text.strip.empty?
|
|
15
|
+
|
|
16
|
+
fence = Text.code_fence(text)
|
|
17
|
+
[Block.new(:code, "#{fence}#{language(node)}\n#{text}\n#{fence}")]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# <code> used as a block: <div class="code"><code>multi-line</code></div>.
|
|
21
|
+
def standalone?(node)
|
|
22
|
+
parent = node.parent
|
|
23
|
+
return false if parent.nil? || parent.name == "pre"
|
|
24
|
+
return false unless node.content.strip.include?("\n")
|
|
25
|
+
|
|
26
|
+
parent.children.all? { |child| child == node || (child.text? && child.content.strip.empty?) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Highlighters often put each line in its own block element instead of
|
|
30
|
+
# separating lines with "\n".
|
|
31
|
+
def preformatted(node)
|
|
32
|
+
node.children.each_with_object(+"") do |child, out|
|
|
33
|
+
if child.text? || child.cdata?
|
|
34
|
+
out << child.content
|
|
35
|
+
elsif child.name == "br"
|
|
36
|
+
out << "\n"
|
|
37
|
+
elsif Dom.block?(child)
|
|
38
|
+
out << "\n" unless out.empty? || out.end_with?("\n")
|
|
39
|
+
out << preformatted(child)
|
|
40
|
+
out << "\n" unless out.end_with?("\n")
|
|
41
|
+
elsif child.element?
|
|
42
|
+
out << preformatted(child)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The language sits on the <pre>, its <code>, or a wrapper up to two
|
|
48
|
+
# levels out (GitHub, Sphinx).
|
|
49
|
+
def language(node)
|
|
50
|
+
[node, node.at_css("code"), node.parent, node.parent&.parent].each do |element|
|
|
51
|
+
next unless element&.element?
|
|
52
|
+
|
|
53
|
+
name = explicit_language(element)
|
|
54
|
+
return clean(name) if name
|
|
55
|
+
end
|
|
56
|
+
""
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def explicit_language(element)
|
|
60
|
+
classes = element["class"].to_s
|
|
61
|
+
element["data-lang"] || element["data-language"] || classes[/brush:\s*([\w+#.-]+)/, 1] ||
|
|
62
|
+
classes.split.map { |name| name[LANGUAGE_CLASS, 1] }.compact.first
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def clean(name)
|
|
66
|
+
name = name.downcase.sub(/\Asource-/, "")
|
|
67
|
+
name.match?(/\A[\w+#.-]{1,24}\z/) && !NO_LANGUAGE.include?(name) ? name : ""
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nokodown
|
|
4
|
+
module Markdown
|
|
5
|
+
module Image
|
|
6
|
+
TRACKING = %r{(?:\A|/)(?:pixel|spacer|blank|transparent|clear|tracking|beacon|1x1|[\w-]*placeholder)\.(?:gif|png|svg|jpe?g|webp)(?:\?|\z)|/(?:pixel|beacon)(?:/|\?)}i.freeze
|
|
7
|
+
# README status badges: build, version and coverage shields.
|
|
8
|
+
BADGE = %r{//(?:img\.shields\.io|badge\.fury\.io|badgen\.net)/|badge\.svg(?:[?#]|\z)|/badges?/[^?#]*\.svg}i.freeze
|
|
9
|
+
# Emoji, including skin-tone modifiers, variation selectors and joiners.
|
|
10
|
+
EMOJI = /\A[\p{So}\p{Sk}\p{Mn}\p{Cf}]+\z/.freeze
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def source(node, urls)
|
|
15
|
+
src = node["src"].to_s.strip
|
|
16
|
+
src = largest_srcset(node["srcset"].to_s) if src.empty? || src.start_with?("data:")
|
|
17
|
+
return if src.nil? || src.empty? || src.start_with?("data:")
|
|
18
|
+
|
|
19
|
+
urls.resolve_image(src)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def largest_srcset(srcset)
|
|
23
|
+
candidates = srcset.split(/,\s+/).map do |candidate|
|
|
24
|
+
url, descriptor = candidate.strip.split(/\s+/, 2)
|
|
25
|
+
[url, descriptor.to_f]
|
|
26
|
+
end
|
|
27
|
+
candidates.reject { |url, _| url.nil? || url.empty? }.max_by { |_, size| size }&.first
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Icons, spacers, tracking pixels, placeholders and badges.
|
|
31
|
+
def decorative?(node, src, alt)
|
|
32
|
+
return true if alt.empty? && src.match?(/\.svg(?:[?#]|\z)/i)
|
|
33
|
+
|
|
34
|
+
width = pixels(node["width"])
|
|
35
|
+
height = pixels(node["height"])
|
|
36
|
+
return true if (width && width <= 2) || (height && height <= 2)
|
|
37
|
+
return true if width && height && width <= 32 && height <= 32
|
|
38
|
+
|
|
39
|
+
src.match?(TRACKING) || src.match?(BADGE)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def pixels(value)
|
|
43
|
+
value.to_s.strip.match?(/\A\d+(?:px)?\z/) ? value.to_i : nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# WordPress and Twitter render emoji as images; the alt is the emoji.
|
|
47
|
+
def emoji?(node, alt)
|
|
48
|
+
node["class"].to_s.match?(/emoji|wp-smiley/) || (alt.length.between?(1, 8) && alt.match?(EMOJI))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Wikipedia's math fallback images carry the TeX source as alt text.
|
|
52
|
+
def tex(alt)
|
|
53
|
+
tex = alt.sub(/\A\{\\displaystyle\s*(.*)\}\z/m, '\1').strip
|
|
54
|
+
tex.empty? ? "" : "$#{tex}$"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def video_page(src)
|
|
58
|
+
id = src[%r{youtube(?:-nocookie)?\.com/embed/([\w-]{6,})}, 1]
|
|
59
|
+
id ? "https://www.youtube.com/watch?v=#{id}" : src
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nokodown
|
|
4
|
+
module Markdown
|
|
5
|
+
# The last step of Cleanup: removes redundant images and repeats of an
|
|
6
|
+
# image anywhere on the page. Run earlier, it would leave a heading whose
|
|
7
|
+
# section held only an image for drop_empty_sections to take.
|
|
8
|
+
class ImageCleanup
|
|
9
|
+
# Markup as Inline writes it: "](" in the alt is escaped, and the
|
|
10
|
+
# destination's parentheses are balanced or percent-encoded. One pattern
|
|
11
|
+
# for every image keeps the cost linear on pages with thousands; the
|
|
12
|
+
# rare URL with nested parentheses is not matched, so its image stays.
|
|
13
|
+
IMAGE = /!\[(?:\\.|[^\\\[\]\n]|\[(?:\\.|[^\\\[\]\n])*\])*\]\((?:[^()\s]|\([^()\s]*\))*\)/.freeze
|
|
14
|
+
DESTINATION = /\]\(((?:[^()\s]|\([^()\s]*\))*)\)/.freeze
|
|
15
|
+
# A line with nothing but list or quote markers, nested lists included.
|
|
16
|
+
BARE_LINE = /\A(?:\s|>|[-*+]|\d+\.)*\z/.freeze
|
|
17
|
+
LIST_MARKER = /\A *(?:[-*+]|\d+\.) /.freeze
|
|
18
|
+
LIST_MARKERS = /\A *(?:(?:[-*+]|\d+\.) )+/.freeze
|
|
19
|
+
# The indentation and markers before a line's text.
|
|
20
|
+
LINE_PREFIX = /\A(?: |>|(?:[-*+]|\d+\.) )*/.freeze
|
|
21
|
+
# Text never starts a line with ``` (Text.escape_line_starts), so this
|
|
22
|
+
# is the fence of a code block nested in a list or a quote.
|
|
23
|
+
FENCE = /#{LINE_PREFIX.source}(`{3,})/.freeze
|
|
24
|
+
|
|
25
|
+
def initialize(images)
|
|
26
|
+
@images = images
|
|
27
|
+
# Image URL => @text_lines when its last copy was written.
|
|
28
|
+
@seen = {}
|
|
29
|
+
@text_lines = 0
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def call(blocks)
|
|
33
|
+
@linked = linked_pages(blocks)
|
|
34
|
+
blocks.select do |block|
|
|
35
|
+
if block.kind == :code || !block.text.include?("![")
|
|
36
|
+
@text_lines += 1
|
|
37
|
+
next true
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
text = without_images(block.text)
|
|
41
|
+
next true if text == block.text
|
|
42
|
+
|
|
43
|
+
block.text = text
|
|
44
|
+
text.match?(/[\p{L}\p{N}]/) && !Cleanup.noise?(block)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
# Pages the final Markdown still links to.
|
|
51
|
+
def linked_pages(blocks)
|
|
52
|
+
destinations = blocks.reject { |block| block.kind == :code }.flat_map { |block| block.text.scan(DESTINATION).flatten }
|
|
53
|
+
Set.new(destinations.map { |destination| RedundantImages.page(destination) })
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def without_images(text)
|
|
57
|
+
lines = with_code_flags(text.split("\n")).map do |line, code|
|
|
58
|
+
next [line, line_without_images(line), false] unless code
|
|
59
|
+
|
|
60
|
+
@text_lines += 1
|
|
61
|
+
[line, line, true]
|
|
62
|
+
end
|
|
63
|
+
squeeze(drop_bare_lines(lines)).strip
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Each line, and whether it belongs to a nested code block, whose lines
|
|
67
|
+
# stay as they are.
|
|
68
|
+
def with_code_flags(lines)
|
|
69
|
+
fence = nil
|
|
70
|
+
lines.map do |line|
|
|
71
|
+
run = line[FENCE, 1]
|
|
72
|
+
if fence
|
|
73
|
+
fence = nil if run && run.length >= fence.length
|
|
74
|
+
next [line, true]
|
|
75
|
+
end
|
|
76
|
+
fence = run
|
|
77
|
+
[line, !run.nil?]
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Text that followed a removed image can now start the line, where "#"
|
|
82
|
+
# or ">" would turn it into a heading or a quote.
|
|
83
|
+
def line_without_images(line)
|
|
84
|
+
removed = removed_images(line)
|
|
85
|
+
return line if removed.empty?
|
|
86
|
+
|
|
87
|
+
kept = line.gsub(IMAGE) { |markup| removed.include?(markup) ? " " : markup }
|
|
88
|
+
prefix = line[LINE_PREFIX]
|
|
89
|
+
prefix + Text.escape_line_starts(kept[prefix.length..].strip.squeeze(" "))
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Redundant images, and later copies of an image standing alone on its
|
|
93
|
+
# line. Beside text, as in a table of tick marks, each copy counts.
|
|
94
|
+
def removed_images(line)
|
|
95
|
+
markups = line.scan(IMAGE).select { |markup| @images.key?(markup) }
|
|
96
|
+
content = markups.reject { |markup| redundant?(@images[markup]) }
|
|
97
|
+
alone = line.gsub(IMAGE) { |markup| @images.key?(markup) ? "" : markup }.match?(BARE_LINE)
|
|
98
|
+
removed = Set.new(markups - content)
|
|
99
|
+
removed.merge(repeats(content)) if alone
|
|
100
|
+
content.each { |markup| @seen[@images[markup].url] = @text_lines }
|
|
101
|
+
@text_lines += 1 unless alone
|
|
102
|
+
removed
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# A line with several copies of one image is a count, such as a row of
|
|
106
|
+
# rating stars, and keeps them all. Otherwise a copy without alt text
|
|
107
|
+
# adds nothing. One with alt text can mark its own item (an award, a
|
|
108
|
+
# verified tick), so it goes only when nothing but images came since
|
|
109
|
+
# the last copy, as in a logo strip shown twice.
|
|
110
|
+
def repeats(content)
|
|
111
|
+
urls = content.map { |markup| @images[markup].url }
|
|
112
|
+
return [] if urls.uniq.size < urls.size
|
|
113
|
+
|
|
114
|
+
content.select do |markup|
|
|
115
|
+
seen = @seen[@images[markup].url]
|
|
116
|
+
seen && (markup.start_with?("![]") || seen == @text_lines)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# A thumbnail goes only while the Markdown still links to its page; a
|
|
121
|
+
# card whose title link was dropped keeps its image.
|
|
122
|
+
def redundant?(image)
|
|
123
|
+
image.redundant && image.targets.all? { |target| @linked.include?(target) }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Lines left with only markers go. A list item whose first line held
|
|
127
|
+
# only images starts at its next line instead.
|
|
128
|
+
def drop_bare_lines(lines)
|
|
129
|
+
marker = nil
|
|
130
|
+
lines.filter_map do |line, kept, code|
|
|
131
|
+
if kept != line && kept.match?(BARE_LINE)
|
|
132
|
+
marker = pending_marker(line, marker)
|
|
133
|
+
next
|
|
134
|
+
end
|
|
135
|
+
next [kept, code] if kept.empty?
|
|
136
|
+
|
|
137
|
+
kept = with_marker(kept, marker) if marker
|
|
138
|
+
marker = nil
|
|
139
|
+
[kept, code]
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# One blank line where dropped lines left several, except in code.
|
|
144
|
+
def squeeze(lines)
|
|
145
|
+
lines.each_with_object([]) do |(line, code), kept|
|
|
146
|
+
kept << line unless !code && line.empty? && kept.last == ""
|
|
147
|
+
end.join("\n")
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# The outermost item still waiting for its text: a bare line nested in
|
|
151
|
+
# it, such as a list of thumbnails, does not take its place.
|
|
152
|
+
def pending_marker(line, marker)
|
|
153
|
+
return marker if marker && line.start_with?(" " * marker[LIST_MARKER].length)
|
|
154
|
+
|
|
155
|
+
line[LIST_MARKERS] || marker
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# The markers of the items the line is indented into, not deeper.
|
|
159
|
+
def with_marker(line, marker)
|
|
160
|
+
prefixes = marker.scan(/ *(?:[-*+]|\d+\.) /).each_with_object([]) { |piece, all| all << "#{all.last}#{piece}" }
|
|
161
|
+
prefix = prefixes.reverse.find { |candidate| line.start_with?(" " * candidate.length) && !line[candidate.length..].match?(/\A\s/) }
|
|
162
|
+
prefix ? prefix + line[prefix.length..] : line
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|