html-proofer 3.19.4 → 4.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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/bin/htmlproofer +31 -57
  3. data/lib/html-proofer.rb +1 -54
  4. data/lib/html_proofer/attribute/url.rb +237 -0
  5. data/lib/html_proofer/attribute.rb +15 -0
  6. data/lib/html_proofer/cache.rb +279 -0
  7. data/lib/html_proofer/check/favicon.rb +40 -0
  8. data/lib/html_proofer/check/images.rb +93 -0
  9. data/lib/html_proofer/check/links.rb +129 -0
  10. data/lib/html_proofer/check/open_graph.rb +39 -0
  11. data/lib/html_proofer/check/scripts.rb +46 -0
  12. data/lib/html_proofer/check.rb +92 -0
  13. data/lib/html_proofer/configuration.rb +88 -0
  14. data/lib/html_proofer/element.rb +122 -0
  15. data/lib/html_proofer/failure.rb +17 -0
  16. data/lib/{html-proofer → html_proofer}/log.rb +19 -19
  17. data/lib/html_proofer/reporter/cli.rb +33 -0
  18. data/lib/html_proofer/reporter.rb +23 -0
  19. data/lib/html_proofer/runner.rb +246 -0
  20. data/lib/html_proofer/url_validator/external.rb +194 -0
  21. data/lib/html_proofer/url_validator/internal.rb +96 -0
  22. data/lib/html_proofer/url_validator.rb +16 -0
  23. data/lib/{html-proofer → html_proofer}/utils.rb +9 -12
  24. data/lib/{html-proofer → html_proofer}/version.rb +1 -1
  25. data/lib/html_proofer/xpath_functions.rb +10 -0
  26. data/lib/html_proofer.rb +57 -0
  27. metadata +42 -22
  28. data/lib/html-proofer/cache.rb +0 -194
  29. data/lib/html-proofer/check/favicon.rb +0 -29
  30. data/lib/html-proofer/check/html.rb +0 -37
  31. data/lib/html-proofer/check/images.rb +0 -48
  32. data/lib/html-proofer/check/links.rb +0 -182
  33. data/lib/html-proofer/check/opengraph.rb +0 -46
  34. data/lib/html-proofer/check/scripts.rb +0 -42
  35. data/lib/html-proofer/check.rb +0 -75
  36. data/lib/html-proofer/configuration.rb +0 -88
  37. data/lib/html-proofer/element.rb +0 -265
  38. data/lib/html-proofer/issue.rb +0 -65
  39. data/lib/html-proofer/middleware.rb +0 -82
  40. data/lib/html-proofer/runner.rb +0 -249
  41. data/lib/html-proofer/url_validator.rb +0 -237
@@ -1,194 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative 'utils'
4
- require 'date'
5
- require 'json'
6
- require 'uri'
7
-
8
- module HTMLProofer
9
- class Cache
10
- include HTMLProofer::Utils
11
-
12
- DEFAULT_STORAGE_DIR = File.join('tmp', '.htmlproofer')
13
- DEFAULT_CACHE_FILE_NAME = 'cache.log'
14
-
15
- URI_REGEXP = URI::DEFAULT_PARSER.make_regexp
16
-
17
- attr_reader :exists, :cache_log, :storage_dir, :cache_file
18
-
19
- def initialize(logger, options)
20
- @logger = logger
21
- @cache_log = {}
22
-
23
- @cache_datetime = DateTime.now
24
- @cache_time = @cache_datetime.to_time
25
-
26
- if options.nil? || options.empty?
27
- define_singleton_method('use_cache?') { false }
28
- else
29
- define_singleton_method('use_cache?') { true }
30
- setup_cache!(options)
31
- @parsed_timeframe = parsed_timeframe(options[:timeframe])
32
- end
33
- end
34
-
35
- def within_timeframe?(time)
36
- return false if time.nil?
37
-
38
- (@parsed_timeframe..@cache_time).cover?(Time.parse(time))
39
- end
40
-
41
- def urls
42
- @cache_log['urls'] || []
43
- end
44
-
45
- def size
46
- @cache_log.length
47
- end
48
-
49
- def parsed_timeframe(timeframe)
50
- time, date = timeframe.match(/(\d+)(\D)/).captures
51
- time = time.to_i
52
- case date
53
- when 'M'
54
- time_ago(time, :months)
55
- when 'w'
56
- time_ago(time, :weeks)
57
- when 'd'
58
- time_ago(time, :days)
59
- when 'h'
60
- time_ago(time, :hours)
61
- else
62
- raise ArgumentError, "#{date} is not a valid timeframe!"
63
- end
64
- end
65
-
66
- def add(url, filenames, status, msg = '')
67
- return unless use_cache?
68
-
69
- data = {
70
- time: @cache_time,
71
- filenames: filenames,
72
- status: status,
73
- message: msg
74
- }
75
-
76
- @cache_log[clean_url(url)] = data
77
- end
78
-
79
- def detect_url_changes(found, type)
80
- found_urls = found.keys.map { |url| clean_url(url) }
81
-
82
- # if there were no urls, bail
83
- return {} if found_urls.empty?
84
-
85
- existing_urls = @cache_log.keys.map { |url| clean_url(url) }
86
-
87
- # prepare to add new URLs detected
88
- additions = found.reject do |url, _|
89
- url = clean_url(url)
90
- if existing_urls.include?(url)
91
- true
92
- else
93
- @logger.log :debug, "Adding #{url} to cache check"
94
- false
95
- end
96
- end
97
-
98
- new_link_count = additions.length
99
- new_link_text = pluralize(new_link_count, 'link', 'links')
100
- @logger.log :info, "Adding #{new_link_text} to the cache..."
101
-
102
- # remove from cache URLs that no longer exist
103
- deletions = 0
104
- @cache_log.delete_if do |url, _|
105
- url = clean_url(url)
106
-
107
- if found_urls.include?(url)
108
- false
109
- elsif url_matches_type?(url, type)
110
- @logger.log :debug, "Removing #{url} from cache check"
111
- deletions += 1
112
- true
113
- end
114
- end
115
-
116
- del_link_text = pluralize(deletions, 'link', 'links')
117
- @logger.log :info, "Removing #{del_link_text} from the cache..."
118
-
119
- additions
120
- end
121
-
122
- # TODO: Garbage performance--both the external and internal
123
- # caches need access to this file. Write a proper versioned
124
- # schema in the future
125
- def write
126
- File.write(cache_file, @cache_log.to_json)
127
- end
128
-
129
- def load?
130
- @load.nil?
131
- end
132
-
133
- def retrieve_urls(urls, type)
134
- urls_to_check = detect_url_changes(urls, type)
135
-
136
- @cache_log.each_pair do |url, cache|
137
- next if within_timeframe?(cache['time']) && cache['message'].empty? # these were successes to skip
138
-
139
- if url_matches_type?(url, type)
140
- urls_to_check[url] = cache['filenames'] # recheck expired links
141
- end
142
- end
143
- urls_to_check
144
- end
145
-
146
- # FIXME: it seems that Typhoeus actually acts on escaped URLs,
147
- # but there's no way to get at that information, and the cache
148
- # stores unescaped URLs. Because of this, some links, such as
149
- # github.com/search/issues?q=is:open+is:issue+fig are not matched
150
- # as github.com/search/issues?q=is%3Aopen+is%3Aissue+fig
151
- def unescape_url(url)
152
- Addressable::URI.unescape(url)
153
- end
154
-
155
- def clean_url(url)
156
- unescape_url(url)
157
- end
158
-
159
- def setup_cache!(options)
160
- @storage_dir = options[:storage_dir] || DEFAULT_STORAGE_DIR
161
-
162
- FileUtils.mkdir_p(storage_dir) unless Dir.exist?(storage_dir)
163
-
164
- cache_file_name = options[:cache_file] || DEFAULT_CACHE_FILE_NAME
165
-
166
- @cache_file = File.join(storage_dir, cache_file_name)
167
-
168
- return unless File.exist?(@cache_file)
169
-
170
- contents = File.read(@cache_file)
171
- @cache_log = contents.empty? ? {} : JSON.parse(contents)
172
- end
173
-
174
- private
175
-
176
- def time_ago(measurement, unit)
177
- case unit
178
- when :months
179
- @cache_datetime >> -measurement
180
- when :weeks
181
- @cache_datetime - (measurement * 7)
182
- when :days
183
- @cache_datetime - measurement
184
- when :hours
185
- @cache_datetime - Rational(measurement / 24.0)
186
- end.to_time
187
- end
188
-
189
- def url_matches_type?(url, type)
190
- return true if type == :internal && url !~ URI_REGEXP
191
- return true if type == :external && url =~ URI_REGEXP
192
- end
193
- end
194
- end
@@ -1,29 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- class FaviconCheck < ::HTMLProofer::Check
4
- def run
5
- found = false
6
- @html.xpath('//link[not(ancestor::pre or ancestor::code)]').each do |node|
7
- favicon = create_element(node)
8
- next if favicon.ignore?
9
-
10
- found = true if favicon.rel.split.last.eql? 'icon'
11
- break if found
12
- end
13
-
14
- return if found
15
-
16
- return if immediate_redirect?
17
-
18
- add_issue('no favicon specified')
19
- end
20
-
21
- private
22
-
23
- # allow any instant-redirect meta tag
24
- def immediate_redirect?
25
- @html.xpath("//meta[@http-equiv='refresh']").attribute('content').value.start_with? '0;'
26
- rescue StandardError
27
- false
28
- end
29
- end
@@ -1,37 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- class HtmlCheck < ::HTMLProofer::Check
4
- # tags embedded in scripts are used in templating languages: http://git.io/vOovv
5
- SCRIPT_EMBEDS_MSG = /Element script embeds close tag/.freeze
6
- INVALID_TAG_MSG = /Tag ([\w\-:]+) invalid/.freeze
7
- INVALID_PREFIX = /Namespace prefix/.freeze
8
- PARSE_ENTITY_REF = /htmlParseEntityRef: no name/.freeze
9
- DOCTYPE_MSG = /Expected a doctype token/.freeze
10
- EOF_IN_TAG = /End of input in tag/.freeze
11
- MISMATCHED_TAGS = /That tag isn't allowed here/.freeze
12
-
13
- def run
14
- @html.errors.each do |error|
15
- add_issue(error.message, line: error.line) if report?(error.message)
16
- end
17
- end
18
-
19
- def report?(message)
20
- case message
21
- when SCRIPT_EMBEDS_MSG
22
- options[:validation][:report_script_embeds]
23
- when INVALID_TAG_MSG, INVALID_PREFIX
24
- options[:validation][:report_invalid_tags]
25
- when PARSE_ENTITY_REF
26
- options[:validation][:report_missing_names]
27
- when DOCTYPE_MSG
28
- options[:validation][:report_missing_doctype]
29
- when EOF_IN_TAG
30
- options[:validation][:report_eof_tags]
31
- when MISMATCHED_TAGS
32
- options[:validation][:report_mismatched_tags]
33
- else
34
- true
35
- end
36
- end
37
- end
@@ -1,48 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- class ImageCheck < ::HTMLProofer::Check
4
- SCREEN_SHOT_REGEX = /Screen(?: |%20)Shot(?: |%20)\d+-\d+-\d+(?: |%20)at(?: |%20)\d+.\d+.\d+/.freeze
5
-
6
- def empty_alt_tag?
7
- @img.alt.nil? || @img.alt.strip.empty?
8
- end
9
-
10
- def terrible_filename?
11
- @img.url =~ SCREEN_SHOT_REGEX
12
- end
13
-
14
- def missing_src?
15
- blank?(@img.url)
16
- end
17
-
18
- def run
19
- @html.css('img').each do |node|
20
- @img = create_element(node)
21
- line = node.line
22
- content = node.content
23
-
24
- next if @img.ignore?
25
-
26
- # screenshot filenames should return because of terrible names
27
- if terrible_filename?
28
- add_issue("image has a terrible filename (#{@img.url})", line: line, content: content)
29
- next
30
- end
31
-
32
- # does the image exist?
33
- if missing_src?
34
- add_issue('image has no src or srcset attribute', line: line, content: content)
35
- elsif @img.remote?
36
- add_to_external_urls(@img.url)
37
- elsif !@img.exists?
38
- add_issue("internal image #{@img.url} does not exist", line: line, content: content)
39
- end
40
-
41
- add_issue("image #{@img.url} does not have an alt attribute", line: line, content: content) if empty_alt_tag? && !@img.ignore_empty_alt? && !@img.ignore_alt?
42
-
43
- add_issue("image #{@img.url} uses the http scheme", line: line, content: content) if @img.check_img_http? && @img.scheme == 'http'
44
- end
45
-
46
- external_urls
47
- end
48
- end
@@ -1,182 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- class LinkCheck < ::HTMLProofer::Check
4
- include HTMLProofer::Utils
5
-
6
- def missing_href?
7
- return blank?(@link.src) if @node.name == 'source'
8
-
9
- blank?(@link.href) && blank?(@link.name) && blank?(@link.id)
10
- end
11
-
12
- def placeholder?
13
- (!blank?(@link.id) || !blank?(@link.name)) && @link.href.nil?
14
- end
15
-
16
- def run
17
- @html.css('a, link, source').each do |node|
18
- @link = create_element(node)
19
- line = node.line
20
- content = node.to_s
21
-
22
- next if @link.ignore?
23
-
24
- next if placeholder?
25
- next if @link.allow_hash_href? && @link.href == '#'
26
-
27
- # is it even a valid URL?
28
- unless @link.valid?
29
- add_issue("#{@link.href} is an invalid URL", line: line, content: content)
30
- next
31
- end
32
-
33
- check_schemes(@link, line, content)
34
-
35
- # is there even an href?
36
- if missing_href?
37
- next if @link.allow_missing_href?
38
- # HTML5 allows dropping the href: http://git.io/vBX0z
39
- next if @html.internal_subset.nil? || (@html.internal_subset.name == 'html' && @html.internal_subset.external_id.nil?)
40
-
41
- add_issue('anchor has no href attribute', line: line, content: content)
42
- next
43
- end
44
-
45
- # intentionally here because we still want valid? & missing_href? to execute
46
- next if @link.non_http_remote?
47
-
48
- if !@link.href&.start_with?('#') && !@link.internal? && @link.remote?
49
- check_sri(line, content) if @link.check_sri? && node.name == 'link'
50
- # we need to skip these for now; although the domain main be valid,
51
- # curl/Typheous inaccurately return 404s for some links. cc https://git.io/vyCFx
52
- next if @link.respond_to?(:rel) && @link.rel == 'dns-prefetch'
53
-
54
- unless @link.path?
55
- add_issue("#{@link.href} is an invalid URL", line: line, content: content)
56
- next
57
- end
58
-
59
- add_to_external_urls(@link.href || @link.src)
60
- next
61
- elsif @link.internal?
62
- add_to_internal_urls(@link.href, InternalLink.new(@link, @path, line, content))
63
- add_issue("internally linking to #{@link.href}, which does not exist", line: line, content: content) if !@link.exists? && !@link.hash
64
- end
65
- end
66
-
67
- external_urls
68
- end
69
-
70
- def check_internal_link(link, path, line, content)
71
- # does the local directory have a trailing slash?
72
- if link.unslashed_directory?(link.absolute_path)
73
- add_issue("internally linking to a directory #{link.absolute_path} without trailing slash", path: path, line: line, content: content)
74
- return false
75
- end
76
-
77
- return true unless link.hash
78
-
79
- # verify the target hash
80
- handle_hash(link, path, line, content)
81
- end
82
-
83
- def check_schemes(link, line, content)
84
- case link.scheme
85
- when 'mailto'
86
- handle_mailto(link, line, content)
87
- when 'tel'
88
- handle_tel(link, line, content)
89
- when 'http'
90
- return unless @options[:enforce_https]
91
-
92
- add_issue("#{link.href} is not an HTTPS link", line: line, content: content)
93
- end
94
- end
95
-
96
- def handle_mailto(link, line, content)
97
- if link.path.empty?
98
- add_issue("#{link.href} contains no email address", line: line, content: content) unless link.ignore_empty_mailto?
99
- elsif !link.path.include?('@')
100
- add_issue("#{link.href} contains an invalid email address", line: line, content: content)
101
- end
102
- end
103
-
104
- def handle_tel(link, line, content)
105
- add_issue("#{link.href} contains no phone number", line: line, content: content) if link.path.empty?
106
- end
107
-
108
- def handle_hash(link, path, line, content)
109
- if link.internal? && !hash_exists?(link.html, link.hash) # rubocop:disable Style/GuardClause
110
- return add_issue("linking to internal hash ##{link.hash} that does not exist", path: path, line: line, content: content)
111
- elsif link.external?
112
- return external_link_check(link, line, content)
113
- end
114
-
115
- true
116
- end
117
-
118
- def external_link_check(link, line, content)
119
- if link.exists? # rubocop:disable Style/GuardClause
120
- target_html = create_nokogiri(link.absolute_path)
121
- return add_issue("linking to #{link.href}, but #{link.hash} does not exist", line: line, content: content) unless hash_exists?(target_html, link.hash)
122
- else
123
- return add_issue("trying to find hash of #{link.href}, but #{link.absolute_path} does not exist", line: line, content: content)
124
- end
125
-
126
- true
127
- end
128
-
129
- def hash_exists?(html, href_hash)
130
- decoded_href_hash = Addressable::URI.unescape(href_hash)
131
- fragment_ids = [href_hash, decoded_href_hash]
132
- # https://www.w3.org/TR/html5/single-page.html#scroll-to-fragid
133
- fragment_ids.include?('top') || !find_fragments(html, fragment_ids).empty?
134
- end
135
-
136
- def find_fragments(html, fragment_ids)
137
- xpaths = fragment_ids.flat_map do |frag_id|
138
- escaped_frag_id = "'#{frag_id.split("'").join("', \"'\", '")}', ''"
139
- [
140
- "//*[case_sensitive_equals(@id, concat(#{escaped_frag_id}))]",
141
- "//*[case_sensitive_equals(@name, concat(#{escaped_frag_id}))]"
142
- ]
143
- end
144
- xpaths << XpathFunctions.new
145
-
146
- html.xpath(*xpaths)
147
- end
148
-
149
- # Whitelist for affected elements from Subresource Integrity specification
150
- # https://w3c.github.io/webappsec-subresource-integrity/#link-element-for-stylesheets
151
- SRI_REL_TYPES = %(stylesheet)
152
-
153
- def check_sri(line, content)
154
- return unless SRI_REL_TYPES.include?(@link.rel)
155
-
156
- if !defined?(@link.integrity) && !defined?(@link.crossorigin)
157
- add_issue("SRI and CORS not provided in: #{@link.src}", line: line, content: content)
158
- elsif !defined?(@link.integrity)
159
- add_issue("Integrity is missing in: #{@link.src}", line: line, content: content)
160
- elsif !defined?(@link.crossorigin)
161
- add_issue("CORS not provided for external resource in: #{@link.src}", line: line, content: content)
162
- end
163
- end
164
-
165
- class XpathFunctions
166
- def case_sensitive_equals(node_set, str_to_match)
167
- node_set.find_all { |node| node.to_s.== str_to_match.to_s }
168
- end
169
- end
170
-
171
- class InternalLink
172
- attr_reader :link, :href, :path, :line, :content
173
-
174
- def initialize(link, path, line, content)
175
- @link = link
176
- @href = @link.href
177
- @path = path
178
- @line = line
179
- @content = content
180
- end
181
- end
182
- end
@@ -1,46 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- class OpenGraphElement < ::HTMLProofer::Element
4
- attr_reader :src
5
-
6
- def initialize(obj, check, logger)
7
- super(obj, check, logger)
8
- # Fake up src from the content attribute
9
- instance_variable_set('@src', @content)
10
-
11
- @src.insert 0, 'http:' if %r{^//}.match?(@src)
12
- end
13
- end
14
-
15
- class OpenGraphCheck < ::HTMLProofer::Check
16
- def missing_src?
17
- !@opengraph.src
18
- end
19
-
20
- def empty_src?
21
- blank?(@opengraph.src)
22
- end
23
-
24
- def run
25
- @html.css('meta[property="og:url"], meta[property="og:image"]').each do |m|
26
- @opengraph = OpenGraphElement.new(m, self, @logger)
27
-
28
- next if @opengraph.ignore?
29
-
30
- # does the opengraph exist?
31
- if missing_src?
32
- add_issue('open graph has no content attribute', line: m.line, content: m.content)
33
- elsif empty_src?
34
- add_issue('open graph content attribute is empty', line: m.line, content: m.content)
35
- elsif !@opengraph.valid?
36
- add_issue("#{@opengraph.src} is an invalid URL", line: m.line)
37
- elsif @opengraph.remote?
38
- add_to_external_urls(@opengraph.url)
39
- else
40
- add_issue("internal open graph #{@opengraph.url} does not exist", line: m.line, content: m.content) unless @opengraph.exists?
41
- end
42
- end
43
-
44
- external_urls
45
- end
46
- end
@@ -1,42 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- class ScriptCheck < ::HTMLProofer::Check
4
- attr_reader :src
5
-
6
- def missing_src?
7
- !@script.src
8
- end
9
-
10
- def run
11
- @html.css('script').each do |node|
12
- @script = create_element(node)
13
- line = node.line
14
- content = node.content
15
-
16
- next if @script.ignore?
17
- next unless node.text.strip.empty?
18
-
19
- # does the script exist?
20
- if missing_src?
21
- add_issue('script is empty and has no src attribute', line: line, content: content)
22
- elsif @script.remote?
23
- add_to_external_urls(@script.src)
24
- check_sri(line, content) if @script.check_sri?
25
- elsif !@script.exists?
26
- add_issue("internal script #{@script.src} does not exist", line: line, content: content)
27
- end
28
- end
29
-
30
- external_urls
31
- end
32
-
33
- def check_sri(line, content)
34
- if !defined?(@script.integrity) && !defined?(@script.crossorigin)
35
- add_issue("SRI and CORS not provided in: #{@script.src}", line: line, content: content)
36
- elsif !defined?(@script.integrity)
37
- add_issue("Integrity is missing in: #{@script.src}", line: line, content: content)
38
- elsif !defined?(@script.crossorigin)
39
- add_issue("CORS not provided for external resource in: #{@script.src}", line: line, content: content)
40
- end
41
- end
42
- end
@@ -1,75 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module HTMLProofer
4
- # Mostly handles issue management and collecting of external URLs.
5
- class Check
6
- attr_reader :node, :html, :element, :src, :path, :options, :issues, :internal_urls, :external_urls
7
-
8
- def initialize(src, path, html, logger, cache, options)
9
- @src = src
10
- @path = path
11
- @html = remove_ignored(html)
12
- @logger = logger
13
- @cache = cache
14
- @options = options
15
- @issues = []
16
- @internal_urls = {}
17
- @external_urls = {}
18
- end
19
-
20
- def create_element(node)
21
- @node = node
22
- Element.new(node, self, @logger)
23
- end
24
-
25
- def run
26
- raise NotImplementedError, 'HTMLProofer::Check subclasses must implement #run'
27
- end
28
-
29
- def add_issue(desc, line: nil, path: nil, status: -1, content: nil)
30
- @issues << Issue.new(path || @path, desc, line: line, status: status, content: content)
31
- false
32
- end
33
-
34
- def add_to_internal_urls(url, internal_url)
35
- if @internal_urls[url]
36
- @internal_urls[url] << internal_url
37
- else
38
- @internal_urls[url] = [internal_url]
39
- end
40
- end
41
-
42
- def add_to_external_urls(url)
43
- return if @external_urls[url]
44
-
45
- if @external_urls[url]
46
- @external_urls[url] << @path
47
- else
48
- @external_urls[url] = [@path]
49
- end
50
- end
51
-
52
- def self.subchecks
53
- classes = []
54
-
55
- ObjectSpace.each_object(Class) do |c|
56
- next unless c.superclass == self
57
-
58
- classes << c
59
- end
60
-
61
- classes
62
- end
63
-
64
- def blank?(attr)
65
- attr.nil? || attr.empty?
66
- end
67
-
68
- private
69
-
70
- def remove_ignored(html)
71
- html.css('code, pre, tt').each(&:unlink)
72
- html
73
- end
74
- end
75
- end