theme-check 1.4.0 → 1.6.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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/theme-check.yml +14 -6
  3. data/.gitignore +1 -0
  4. data/CHANGELOG.md +42 -0
  5. data/CONTRIBUTING.md +58 -0
  6. data/Gemfile +3 -0
  7. data/config/default.yml +3 -0
  8. data/docs/checks/deprecated_global_app_block_type.md +65 -0
  9. data/docs/flamegraph.svg +18488 -0
  10. data/lib/theme_check/analyzer.rb +5 -0
  11. data/lib/theme_check/asset_file.rb +13 -2
  12. data/lib/theme_check/check.rb +1 -1
  13. data/lib/theme_check/checks/asset_size_css.rb +15 -0
  14. data/lib/theme_check/checks/asset_size_css_stylesheet_tag.rb +18 -1
  15. data/lib/theme_check/checks/convert_include_to_render.rb +2 -1
  16. data/lib/theme_check/checks/deprecated_global_app_block_type.rb +57 -0
  17. data/lib/theme_check/checks/liquid_tag.rb +1 -1
  18. data/lib/theme_check/checks/missing_required_template_files.rb +21 -7
  19. data/lib/theme_check/checks/pagination_size.rb +33 -14
  20. data/lib/theme_check/checks/required_directories.rb +3 -1
  21. data/lib/theme_check/checks/space_inside_braces.rb +47 -24
  22. data/lib/theme_check/checks/translation_key_exists.rb +3 -1
  23. data/lib/theme_check/checks/unused_snippet.rb +3 -1
  24. data/lib/theme_check/cli.rb +32 -6
  25. data/lib/theme_check/corrector.rb +23 -10
  26. data/lib/theme_check/file_system_storage.rb +13 -2
  27. data/lib/theme_check/html_node.rb +4 -4
  28. data/lib/theme_check/html_visitor.rb +20 -8
  29. data/lib/theme_check/in_memory_storage.rb +8 -0
  30. data/lib/theme_check/json_file.rb +9 -4
  31. data/lib/theme_check/json_printer.rb +6 -1
  32. data/lib/theme_check/language_server/document_link_provider.rb +2 -1
  33. data/lib/theme_check/language_server/handler.rb +16 -11
  34. data/lib/theme_check/language_server/server.rb +11 -13
  35. data/lib/theme_check/language_server/uri_helper.rb +37 -0
  36. data/lib/theme_check/language_server.rb +1 -0
  37. data/lib/theme_check/node.rb +118 -11
  38. data/lib/theme_check/offense.rb +26 -0
  39. data/lib/theme_check/position.rb +27 -16
  40. data/lib/theme_check/position_helper.rb +13 -15
  41. data/lib/theme_check/printer.rb +9 -5
  42. data/lib/theme_check/regex_helpers.rb +1 -15
  43. data/lib/theme_check/remote_asset_file.rb +4 -0
  44. data/lib/theme_check/template.rb +5 -19
  45. data/lib/theme_check/template_rewriter.rb +57 -0
  46. data/lib/theme_check/theme_file.rb +18 -1
  47. data/lib/theme_check/version.rb +1 -1
  48. data/lib/theme_check.rb +1 -0
  49. data/theme-check.gemspec +1 -0
  50. metadata +21 -2
@@ -7,25 +7,20 @@ module ThemeCheck
7
7
  end
8
8
 
9
9
  def insert_after(node, content)
10
- line = @template.full_line(node.line_number)
11
- line.insert(node.range[1] + 1, content)
10
+ @template.rewriter.insert_after(node, content)
12
11
  end
13
12
 
14
13
  def insert_before(node, content)
15
- line = @template.full_line(node.line_number)
16
- line.insert(node.range[0], content)
14
+ @template.rewriter.insert_before(node, content)
17
15
  end
18
16
 
19
17
  def replace(node, content)
20
- line = @template.full_line(node.line_number)
21
- line[node.range[0]..node.range[1]] = content
18
+ @template.rewriter.replace(node, content)
22
19
  node.markup = content
23
20
  end
24
21
 
25
22
  def wrap(node, insert_before, insert_after)
26
- line = @template.full_line(node.line_number)
27
- line.insert(node.range[0], insert_before)
28
- line.insert(node.range[1] + 1 + insert_before.length, insert_after)
23
+ @template.rewriter.wrap(node, insert_before, insert_after)
29
24
  end
30
25
 
31
26
  def create(theme, relative_path, content)
@@ -34,7 +29,25 @@ module ThemeCheck
34
29
 
35
30
  def create_default_locale_json(theme)
36
31
  theme.default_locale_json = JsonFile.new("locales/#{theme.default_locale}.default.json", theme.storage)
37
- theme.default_locale_json.update_contents('{}')
32
+ theme.default_locale_json.update_contents({})
33
+ end
34
+
35
+ def remove(theme, relative_path)
36
+ theme.storage.remove(relative_path)
37
+ end
38
+
39
+ def mkdir(theme, relative_path)
40
+ theme.storage.mkdir(relative_path)
41
+ end
42
+
43
+ def add_default_translation_key(file, key, value)
44
+ hash = file.content
45
+ key.reduce(hash) do |pointer, token|
46
+ return pointer[token] = value if token == key.last
47
+ pointer[token] = {} unless pointer.key?(token)
48
+ pointer[token]
49
+ end
50
+ file.update_contents(hash)
38
51
  end
39
52
  end
40
53
  end
@@ -16,14 +16,25 @@ module ThemeCheck
16
16
  end
17
17
 
18
18
  def read(relative_path)
19
- file(relative_path).read
19
+ file(relative_path).read(mode: 'rb', encoding: 'UTF-8')
20
20
  end
21
21
 
22
22
  def write(relative_path, content)
23
23
  reset_memoizers unless file_exists?(relative_path)
24
24
 
25
25
  file(relative_path).dirname.mkpath unless file(relative_path).dirname.directory?
26
- file(relative_path).write(content)
26
+ file(relative_path).write(content, mode: 'w+b', encoding: 'UTF-8')
27
+ end
28
+
29
+ def remove(relative_path)
30
+ file(relative_path).delete
31
+ reset_memoizers
32
+ end
33
+
34
+ def mkdir(relative_path)
35
+ reset_memoizers unless file_exists?(relative_path)
36
+
37
+ file(relative_path).mkpath unless file(relative_path).directory?
27
38
  end
28
39
 
29
40
  def files
@@ -67,10 +67,10 @@ module ThemeCheck
67
67
  private
68
68
 
69
69
  def replace_placeholders(string)
70
- # Replace all {%#{i}####%} with the actual content.
71
- string.gsub(LIQUID_TAG) do |match|
72
- key = /\d+/.match(match)[0]
73
- @placeholder_values[key.to_i]
70
+ # Replace all {i}####≬ with the actual content.
71
+ string.gsub(HTML_LIQUID_PLACEHOLDER) do |match|
72
+ key = /[0-9a-z]+/.match(match)[0]
73
+ @placeholder_values[key.to_i(36)]
74
74
  end
75
75
  end
76
76
  end
@@ -9,12 +9,11 @@ module ThemeCheck
9
9
 
10
10
  def initialize(checks)
11
11
  @checks = checks
12
- @placeholder_values = []
13
12
  end
14
13
 
15
14
  def visit_template(template)
16
- doc = parse(template)
17
- visit(HtmlNode.new(doc, template, @placeholder_values))
15
+ doc, placeholder_values = parse(template)
16
+ visit(HtmlNode.new(doc, template, placeholder_values))
18
17
  rescue ArgumentError => e
19
18
  call_checks(:on_parse_error, e, template)
20
19
  end
@@ -22,19 +21,32 @@ module ThemeCheck
22
21
  private
23
22
 
24
23
  def parse(template)
24
+ placeholder_values = []
25
25
  parseable_source = +template.source.clone
26
26
 
27
- # Replace all liquid tags with {%#{i}######%} to prevent the HTML
27
+ # Replace all non-empty liquid tags with {i}######≬ to prevent the HTML
28
28
  # parser from freaking out. We transparently replace those placeholders in
29
29
  # HtmlNode.
30
+ #
31
+ # We're using base36 to prevent index bleeding on 36^3 tags.
32
+ # `{{x}}` -> `≬#{i}≬` would properly be transformed for 46656 tags in a single file.
33
+ # Should be enough.
34
+ #
35
+ # The base10 alternative would have overflowed at 1000 (`{{x}}` -> `≬1000≬`) which seemed more likely.
36
+ #
37
+ # Didn't go with base64 because of the `=` character that would have messed with HTML parsing.
30
38
  matches(parseable_source, LIQUID_TAG_OR_VARIABLE).each do |m|
31
39
  value = m[0]
32
- @placeholder_values.push(value)
33
- key = (@placeholder_values.size - 1).to_s
34
- parseable_source[m.begin(0)...m.end(0)] = "{%#{key.ljust(m.end(0) - m.begin(0) - 4, '#')}%}"
40
+ next unless value.size > 4 # skip empty tags/variables {%%} and {{}}
41
+ placeholder_values.push(value)
42
+ key = (placeholder_values.size - 1).to_s(36)
43
+ parseable_source[m.begin(0)...m.end(0)] = "≬#{key.ljust(m.end(0) - m.begin(0) - 2, '#')}≬"
35
44
  end
36
45
 
37
- Nokogiri::HTML5.fragment(parseable_source, max_tree_depth: 400, max_attributes: 400)
46
+ [
47
+ Nokogiri::HTML5.fragment(parseable_source, max_tree_depth: 400, max_attributes: 400),
48
+ placeholder_values,
49
+ ]
38
50
  end
39
51
 
40
52
  def visit(node)
@@ -23,6 +23,14 @@ module ThemeCheck
23
23
  @files[relative_path] = content
24
24
  end
25
25
 
26
+ def remove(relative_path)
27
+ @files.delete(relative_path)
28
+ end
29
+
30
+ def mkdir(relative_path)
31
+ @files[relative_path] = nil
32
+ end
33
+
26
34
  def files
27
35
  @files.keys
28
36
  end
@@ -20,14 +20,19 @@ module ThemeCheck
20
20
  @parser_error
21
21
  end
22
22
 
23
- def update_contents(new_content = '{}')
23
+ def update_contents(new_content = {})
24
+ raise ArgumentError if new_content.is_a?(String)
24
25
  @content = new_content
25
26
  end
26
27
 
27
28
  def write
28
- if source != @content
29
- @storage.write(@relative_path, content)
30
- @source = content
29
+ pretty = JSON.pretty_generate(@content)
30
+ if source.rstrip != pretty.rstrip
31
+ # Most editors add a trailing \n at the end of files. Here we
32
+ # try to maintain the convention.
33
+ eof = source.end_with?("\n") ? "\n" : ""
34
+ @storage.write(@relative_path, pretty.gsub("\n", @eol) + eof)
35
+ @source = pretty
31
36
  end
32
37
  end
33
38
 
@@ -3,9 +3,13 @@ require 'json'
3
3
 
4
4
  module ThemeCheck
5
5
  class JsonPrinter
6
+ def initialize(out_stream = STDOUT)
7
+ @out = out_stream
8
+ end
9
+
6
10
  def print(offenses)
7
11
  json = offenses_by_path(offenses)
8
- puts JSON.dump(json)
12
+ @out.puts JSON.dump(json)
9
13
  end
10
14
 
11
15
  def offenses_by_path(offenses)
@@ -21,6 +25,7 @@ module ThemeCheck
21
25
  styleCount: path_offenses.count { |offense| offense[:severity] == Check::SEVERITY_VALUES[:style] },
22
26
  }
23
27
  end
28
+ .sort_by { |o| o[:path] }
24
29
  end
25
30
  end
26
31
  end
@@ -5,6 +5,7 @@ module ThemeCheck
5
5
  class DocumentLinkProvider
6
6
  include RegexHelpers
7
7
  include PositionHelper
8
+ include URIHelper
8
9
 
9
10
  class << self
10
11
  attr_accessor :partial_regexp, :destination_directory, :destination_postfix
@@ -63,7 +64,7 @@ module ThemeCheck
63
64
  end
64
65
 
65
66
  def file_link(partial)
66
- "file://#{@storage.path(destination_directory + '/' + partial + destination_postfix)}"
67
+ file_uri(@storage.path(destination_directory + '/' + partial + destination_postfix))
67
68
  end
68
69
  end
69
70
  end
@@ -1,11 +1,12 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  require "benchmark"
3
- require "uri"
4
- require "cgi"
5
4
 
6
5
  module ThemeCheck
7
6
  module LanguageServer
8
7
  class Handler
8
+ include URIHelper
9
+
9
10
  CAPABILITIES = {
10
11
  completionProvider: {
11
12
  triggerCharacters: ['.', '{{ ', '{% '],
@@ -26,7 +27,7 @@ module ThemeCheck
26
27
  end
27
28
 
28
29
  def on_initialize(id, params)
29
- @root_path = path_from_uri(params["rootUri"]) || params["rootPath"]
30
+ @root_path = root_path_from_params(params)
30
31
 
31
32
  # Tell the client we don't support anything if there's no rootPath
32
33
  return send_response(id, { capabilities: {} }) if @root_path.nil?
@@ -96,19 +97,23 @@ module ThemeCheck
96
97
  end
97
98
 
98
99
  def text_document_uri(params)
99
- path_from_uri(params.dig('textDocument', 'uri'))
100
- end
101
-
102
- def path_from_uri(uri_string)
103
- return if uri_string.nil?
104
- uri = URI(uri_string)
105
- CGI.unescape(uri.path)
100
+ file_path(params.dig('textDocument', 'uri'))
106
101
  end
107
102
 
108
103
  def relative_path_from_text_document_uri(params)
109
104
  @storage.relative_path(text_document_uri(params))
110
105
  end
111
106
 
107
+ def root_path_from_params(params)
108
+ root_uri = params["rootUri"]
109
+ root_path = params["rootPath"]
110
+ if root_uri
111
+ file_path(root_uri)
112
+ elsif root_path
113
+ root_path
114
+ end
115
+ end
116
+
112
117
  def text_document_text(params)
113
118
  params.dig('textDocument', 'text')
114
119
  end
@@ -174,7 +179,7 @@ module ThemeCheck
174
179
  def send_diagnostic(path, offenses)
175
180
  # https://microsoft.github.io/language-server-protocol/specifications/specification-current/#notificationMessage
176
181
  send_notification('textDocument/publishDiagnostics', {
177
- uri: "file://#{path}",
182
+ uri: file_uri(path),
178
183
  diagnostics: offenses.map { |offense| offense_to_diagnostic(offense) },
179
184
  })
180
185
  end
@@ -25,6 +25,15 @@ module ThemeCheck
25
25
  @out = out_stream
26
26
  @err = err_stream
27
27
 
28
+ # Because programming is fun,
29
+ #
30
+ # Ruby on Windows turns \n into \r\n. Which means that \r\n
31
+ # gets turned into \r\r\n. Which means that the protocol
32
+ # breaks on windows unless we turn STDOUT into binary mode.
33
+ #
34
+ # Hours wasted: 9.
35
+ @out.binmode
36
+
28
37
  @out.sync = true # do not buffer
29
38
  @err.sync = true # do not buffer
30
39
 
@@ -52,19 +61,8 @@ module ThemeCheck
52
61
  response_body = JSON.dump(response)
53
62
  log(JSON.pretty_generate(response)) if $DEBUG
54
63
 
55
- # Because programming is fun,
56
- #
57
- # Ruby on Windows turns \n into \r\n. Which means that \r\n
58
- # gets turned into \r\r\n. Which means that the protocol
59
- # breaks on windows unless we turn STDOUT into binary mode and
60
- # set the encoding manually (yuk!) or we do this little hack
61
- # here and put \n which gets transformed into \r\n on windows
62
- # only...
63
- #
64
- # Hours wasted: 8.
65
- eol = Gem.win_platform? ? "\n" : "\r\n"
66
- @out.write("Content-Length: #{response_body.bytesize}#{eol}")
67
- @out.write(eol)
64
+ @out.write("Content-Length: #{response_body.bytesize}\r\n")
65
+ @out.write("\r\n")
68
66
  @out.write(response_body)
69
67
  @out.flush
70
68
  end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "benchmark"
4
+ require "uri"
5
+ require "cgi"
6
+
7
+ module ThemeCheck
8
+ module LanguageServer
9
+ module URIHelper
10
+ # Will URI.encode a string the same way VS Code would. There are two things
11
+ # to watch out for:
12
+ #
13
+ # 1. VS Code still uses the outdated '%20' for spaces
14
+ # 2. VS Code prefixes Windows paths with / (so /C:/Users/... is expected)
15
+ #
16
+ # Exists because of https://github.com/Shopify/theme-check/issues/360
17
+ def file_uri(absolute_path)
18
+ "file://" + absolute_path
19
+ .to_s
20
+ .split('/')
21
+ .map { |x| CGI.escape(x).gsub('+', '%20') }
22
+ .join('/')
23
+ .sub(%r{^/?}, '/') # Windows paths should be prefixed by /c:
24
+ end
25
+
26
+ def file_path(uri_string)
27
+ return if uri_string.nil?
28
+ uri = URI(uri_string)
29
+ path = CGI.unescape(uri.path)
30
+ # On Windows, VS Code sends the URLs as file:///C:/...
31
+ # /C:/1234 is not a valid path in ruby. So we strip the slash.
32
+ path = path.sub(%r{^/([a-z]:/)}i, '\1')
33
+ path
34
+ end
35
+ end
36
+ end
37
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
  require_relative "language_server/protocol"
3
3
  require_relative "language_server/constants"
4
+ require_relative "language_server/uri_helper"
4
5
  require_relative "language_server/handler"
5
6
  require_relative "language_server/server"
6
7
  require_relative "language_server/tokens"
@@ -10,12 +10,14 @@ module ThemeCheck
10
10
  @value = value
11
11
  @parent = parent
12
12
  @template = template
13
+ @tag_markup = nil
14
+ @line_number_offset = 0
13
15
  end
14
16
 
15
17
  # The original source code of the node. Doesn't contain wrapping braces.
16
18
  def markup
17
19
  if tag?
18
- @value.raw
20
+ tag_markup
19
21
  elsif @value.instance_variable_defined?(:@markup)
20
22
  @value.instance_variable_get(:@markup)
21
23
  end
@@ -64,6 +66,10 @@ module ThemeCheck
64
66
  @value.is_a?(Liquid::Tag)
65
67
  end
66
68
 
69
+ def variable?
70
+ @value.is_a?(Liquid::Variable)
71
+ end
72
+
67
73
  # A {% comment %} block node?
68
74
  def comment?
69
75
  @value.is_a?(Liquid::Comment)
@@ -92,7 +98,12 @@ module ThemeCheck
92
98
 
93
99
  # Most nodes have a line number, but it's not guaranteed.
94
100
  def line_number
95
- @value.line_number if @value.respond_to?(:line_number)
101
+ if tag? && @value.respond_to?(:line_number)
102
+ markup # initialize the line_number_offset
103
+ @value.line_number - @line_number_offset
104
+ elsif @value.respond_to?(:line_number)
105
+ @value.line_number
106
+ end
96
107
  end
97
108
 
98
109
  # The `:under_score_name` of this type of node. Used to dispatch to the `on_<type_name>`
@@ -101,27 +112,48 @@ module ThemeCheck
101
112
  @type_name ||= StringHelpers.underscore(StringHelpers.demodulize(@value.class.name)).to_sym
102
113
  end
103
114
 
115
+ def source
116
+ template&.source
117
+ end
118
+
119
+ WHITESPACE = /\s/
120
+
104
121
  # Is this node inside a `{% liquid ... %}` block?
105
122
  def inside_liquid_tag?
106
- if line_number
107
- template.excerpt(line_number).start_with?("{%")
123
+ # What we're doing here is starting at the start of the tag and
124
+ # backtrack on all the whitespace until we land on something. If
125
+ # that something is {% or %-, then we can safely assume that
126
+ # we're inside a full tag and not a liquid tag.
127
+ @inside_liquid_tag ||= if tag? && line_number && source
128
+ i = 1
129
+ i += 1 while source[start_index - i] =~ WHITESPACE && i < start_index
130
+ first_two_backtracked_characters = source[(start_index - i - 1)..(start_index - i)]
131
+ first_two_backtracked_characters != "{%" && first_two_backtracked_characters != "%-"
108
132
  else
109
133
  false
110
134
  end
111
135
  end
112
136
 
113
- # Is this node inside a `{%- ... -%}`
114
- def whitespace_trimmed?
115
- if line_number
116
- template.excerpt(line_number).start_with?("{%-")
137
+ # Is this node inside a tag or variable that starts by removing whitespace. i.e. {%- or {{-
138
+ def whitespace_trimmed_start?
139
+ @whitespace_trimmed_start ||= if line_number && source && !inside_liquid_tag?
140
+ i = 1
141
+ i += 1 while source[start_index - i] =~ WHITESPACE && i < start_index
142
+ source[start_index - i] == "-"
117
143
  else
118
144
  false
119
145
  end
120
146
  end
121
147
 
122
- def range
123
- start = template.full_line(line_number).index(markup)
124
- [start, start + markup.length - 1]
148
+ # Is this node inside a tag or variable ends starts by removing whitespace. i.e. -%} or -}}
149
+ def whitespace_trimmed_end?
150
+ @whitespace_trimmed_end ||= if line_number && source && !inside_liquid_tag?
151
+ i = 0
152
+ i += 1 while source[end_index + i] =~ WHITESPACE && i < source.size
153
+ source[end_index + i] == "-"
154
+ else
155
+ false
156
+ end
125
157
  end
126
158
 
127
159
  def position
@@ -139,5 +171,80 @@ module ThemeCheck
139
171
  def end_index
140
172
  position.end_index
141
173
  end
174
+
175
+ def start_token
176
+ return "" if inside_liquid_tag?
177
+ output = ""
178
+ output += "{{" if variable?
179
+ output += "{%" if tag?
180
+ output += "-" if whitespace_trimmed_start?
181
+ output
182
+ end
183
+
184
+ def end_token
185
+ return "" if inside_liquid_tag?
186
+ output = ""
187
+ output += "-" if whitespace_trimmed_end?
188
+ output += "}}" if variable?
189
+ output += "%}" if tag?
190
+ output
191
+ end
192
+
193
+ private
194
+
195
+ # Here we're hacking around a glorious bug in Liquid that makes it so the
196
+ # line_number and markup of a tag is wrong if there's whitespace
197
+ # between the tag_name and the markup of the tag.
198
+ #
199
+ # {%
200
+ # render
201
+ # 'foo'
202
+ # %}
203
+ #
204
+ # Returns a raw value of "render 'foo'\n".
205
+ # The "\n " between render and 'foo' got replaced by a single space.
206
+ #
207
+ # And the line number is the one of 'foo'\n%}. Yay!
208
+ #
209
+ # This breaks any kind of position logic we have since that string
210
+ # does not exist in the template.
211
+ def tag_markup
212
+ return @value.raw if @value.instance_variable_get('@markup').empty?
213
+ return @tag_markup if @tag_markup
214
+
215
+ l = 1
216
+ scanner = StringScanner.new(source)
217
+ scanner.scan_until(/\n/) while l < @value.line_number && (l += 1)
218
+ start = scanner.charpos
219
+
220
+ tag_markup = @value.instance_variable_get('@markup')
221
+
222
+ # See https://github.com/Shopify/theme-check/pull/423/files#r701936559 for a detailed explanation
223
+ # of why we're doing the check below.
224
+ #
225
+ # TL;DR it's because line_numbers are not enough to accurately
226
+ # determine the position of the raw markup and because that
227
+ # markup could be present on the same line outside of a Tag. e.g.
228
+ #
229
+ # uhoh {% if uhoh %}
230
+ if (match = /#{@value.tag_name} +#{Regexp.escape(tag_markup)}/.match(source, start))
231
+ return @tag_markup = match[0]
232
+ end
233
+
234
+ # find the markup
235
+ markup_start = source.index(tag_markup, start)
236
+ markup_end = markup_start + tag_markup.size
237
+
238
+ # go back until you find the tag_name
239
+ tag_start = markup_start
240
+ tag_start -= 1 while source[tag_start - 1] =~ WHITESPACE
241
+ tag_start -= @value.tag_name.size
242
+
243
+ # keep track of the error in line_number
244
+ @line_number_offset = source[tag_start...markup_start].count("\n")
245
+
246
+ # return the real raw content
247
+ @tag_markup = source[tag_start...markup_end]
248
+ end
142
249
  end
143
250
  end