herbst 0.0.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b1a11bff9f5b5f00be6b23b297bc01faf785e1f661124b90129e9a509ecb10b4
4
- data.tar.gz: 1d36830e13ca7faf498a8e30dd9997d58ffbc8b4027d832780969c3c9ab400c4
3
+ metadata.gz: 97c0f8a85d40558521510f33882058615c8530334598498b8db1407d799655d2
4
+ data.tar.gz: 917a913efabe6050570792a30fb49a12e6e9086b8e7ccf3fac90c2c35563f533
5
5
  SHA512:
6
- metadata.gz: f1334e96f1c98dfa0a1a40d79955c45fcba7ef9ebf7ee0d943545c21c2288bcadb3114783226f3f63b2fc0199908b716de2f35ca95bc2949839b870bf320f37e
7
- data.tar.gz: f93966c0949452056e56629a501640141d51ff2f2a72cedd3209379a9e650ad9fada8e6d91ece8e55cc87f82197d0b85adbcdf2b92b94f805d38fd67d91e1aec
6
+ metadata.gz: aa0044094d6f1fac7a519a105bcf848bc40ed2568039eef3c6288e4d23bb66b39c12c0d8f6006c6b284c7afb6a062a69aa0d612323c17d87eceef08c43fbe6ec
7
+ data.tar.gz: 56799703b87cfdef92316360560be4939c0f0d6cbcd9f25ff08c7f5d6d3dbfefe6a467fe6b18457eecc25f5fec02b94fbcda9ac9e5fe17ccfa691da6a94ae4fb
data/README.md CHANGED
@@ -1,4 +1,4 @@
1
- <h2 align="center">Herbst 🍂</h2>
1
+ <h2 align="center">🍂 Herbst</h2>
2
2
 
3
3
  <h4 align="center">Herb Syntax Tree</h4>
4
4
 
@@ -10,13 +10,13 @@ Herbst (German for "autumn") provides tooling to translate various HTML templati
10
10
 
11
11
  ## Why Herbst?
12
12
 
13
- Herbst bridges the gap between different HTML templating languages and the Herb ecosystem by:
13
+ Herb's tooling (the linter, formatter, language server and `Herb::Engine`) works on Herb's syntax tree, which Herb builds from HTML+ERB. Herbst builds the same tree from other templating languages, so you can:
14
14
 
15
- - **Translating** Haml, Slim, and other HTML templating languages to Herb's unified syntax tree
16
- - **Enabling** Herb tooling (linter, formatter, language server) to work with any supported templating language
17
- - **Unifying** the developer experience across different HTML templating languages
15
+ - **Convert** Haml and Slim templates to ERB, one file or a whole `app/views` at a time
16
+ - **Compile** them with `Herb::Engine`, by converting them to ERB first
17
+ - **Use** Herb's HTML-aware tooling on templates that aren't written in ERB
18
18
 
19
- All these HTML templating languages eventually produce HTML, and Herbst ensures they can all benefit from Herb's HTML-aware tooling.
19
+ All of these languages render HTML in the end, so they can all share the same syntax tree.
20
20
 
21
21
  ## Installation
22
22
 
@@ -34,7 +34,41 @@ gem install herbst
34
34
 
35
35
  ## Usage
36
36
 
37
- Herbst is currently in early development. Usage instructions will be added as the gem matures.
37
+ Herbst is currently in early development and supports converting Haml and Slim to ERB. Add the `haml` or `slim` gem for the languages you convert, since Herbst doesn't depend on either.
38
+
39
+ Converting and printing ERB is pure Ruby. Formatting the output (`format: true`, and the default for the CLI) runs the Herb formatter through `npx`, so it requires Node.js.
40
+
41
+ ```ruby
42
+ require "herbst"
43
+
44
+ Herbst::Haml.parse("%p= greeting") # => Herb::AST::DocumentNode
45
+ Herbst::Haml.to_erb("%p= greeting") # => "<p><%= greeting %></p>\n"
46
+ Herbst::Haml.to_erb(source, format: true)
47
+
48
+ Herbst::Slim.parse("p = greeting") # => Herb::AST::DocumentNode
49
+ Herbst::Slim.to_erb("p = greeting") # => "<p><%= greeting %></p>"
50
+
51
+ Herbst::IdentityPrinter.print(Herb.parse(erb, track_whitespace: true).value)
52
+ ```
53
+
54
+ Herbst doesn't compile templates. To compile Haml or Slim, compile its ERB with `Herb::Engine`:
55
+
56
+ ```ruby
57
+ Herb::Engine.new(Herbst::Haml.to_erb(source), escape: true).src
58
+ ```
59
+
60
+ From the command line:
61
+
62
+ ```bash
63
+ herbst convert app/views/posts/show.html.haml # prints the ERB
64
+ herbst convert app/views # writes show.html.erb next to show.html.haml or .slim
65
+ ```
66
+
67
+ A single file is printed unless you pass `--write`. Directories, and several files at once, are written next to their sources with the `.haml` or `.slim` extension replaced by `.erb`, so `show.html.haml` becomes `show.html.erb`. A file without a format gets `html`, so `show.haml` becomes `show.html.erb`. Pass `--default-format FORMAT` for another format, or `--no-default-format` to write `show.erb`. The Haml and Slim files are kept, and existing ERB files are skipped unless you pass `--force`. Pass `--no-format` to skip the Herb formatter.
68
+
69
+ Constructs that cannot be converted exactly (for example splatted attribute hashes, which become `tag.attributes(...)`) are reported as warnings. Files that can't be converted, for example because they use an unsupported filter such as `:markdown`, are reported and the remaining files are still converted.
70
+
71
+ The CLI also checks that the ERB parses back into exactly the tree Herbst built, and reports any difference. Pass `--no-verify` to skip the check. The same check is available as `Herbst::RoundTrip.difference(document, erb)`. The CLI exits with status 1 when a file fails to convert or the check finds a difference.
38
72
 
39
73
  ## Development
40
74
 
data/Rakefile CHANGED
@@ -7,4 +7,14 @@ require "rubocop/rake_task"
7
7
  Minitest::TestTask.create
8
8
  RuboCop::RakeTask.new
9
9
 
10
- task default: %i[test rubocop]
10
+ desc "Generate RBS signatures from the inline annotations"
11
+ task :rbs do
12
+ sh "bundle exec rbs-inline --opt-out --output=sig/ lib/"
13
+ end
14
+
15
+ desc "Type check with Steep"
16
+ task :steep do
17
+ sh "bundle exec steep check"
18
+ end
19
+
20
+ task default: [:test, :rubocop, :steep]
data/exe/herbst ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/herbst"
5
+
6
+ exit Herbst::CLI.new(ARGV).run
data/herbst.gemspec CHANGED
@@ -11,11 +11,12 @@ Gem::Specification.new do |spec|
11
11
  spec.summary = "Herb Syntax Tree - Unified syntax tree for HTML templating languages"
12
12
  spec.description = "Herbst provides tooling to translate Haml, Slim, and other HTML templating languages into the unified syntax tree used by Herb, enabling HTML-aware tooling across different HTML templating languages."
13
13
  spec.homepage = "https://github.com/marcoroth/herbst"
14
+ spec.license = "MIT"
14
15
  spec.required_ruby_version = ">= 3.2.0"
15
16
 
16
17
  spec.metadata["homepage_uri"] = spec.homepage
17
18
  spec.metadata["source_code_uri"] = "https://github.com/marcoroth/herbst"
18
- spec.metadata["changelog_uri"] = "https://github.com/marcoroth/herbst/blob/main/CHANGELOG.md"
19
+ spec.metadata["changelog_uri"] = "https://github.com/marcoroth/herbst/releases"
19
20
  spec.metadata["rubygems_mfa_required"] = "true"
20
21
 
21
22
  spec.files = Dir[
@@ -24,7 +25,8 @@ Gem::Specification.new do |spec|
24
25
  "Rakefile",
25
26
  "README.md",
26
27
  "lib/**/*.rb",
27
- "sig/**/*.rbs",
28
+ "sig/herbst.rbs",
29
+ "sig/herbst/**/*.rbs",
28
30
  "exe/*"
29
31
  ]
30
32
 
@@ -32,5 +34,5 @@ Gem::Specification.new do |spec|
32
34
  spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
33
35
  spec.require_paths = ["lib"]
34
36
 
35
- spec.add_dependency "herb"
37
+ spec.add_dependency "herb", ">= 0.11.1"
36
38
  end
@@ -0,0 +1,358 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "herb"
4
+ require "herb/html/util"
5
+ require "prism"
6
+
7
+ module Herbst
8
+ module Builder
9
+ #: (String, String) -> Herb::Token
10
+ def token(type, value)
11
+ Herb::Token.from(type, value)
12
+ end
13
+
14
+ #: (Array[Herb::AST::Node]) -> Herb::AST::DocumentNode
15
+ def document(children)
16
+ Herb::AST::DocumentNode.build(children: children)
17
+ end
18
+
19
+ #: (String) -> Herb::AST::HTMLTextNode
20
+ def text(content)
21
+ Herb::AST::HTMLTextNode.build(content: +content)
22
+ end
23
+
24
+ #: (String) -> Herb::AST::LiteralNode
25
+ def literal(content)
26
+ Herb::AST::LiteralNode.build(content: +content)
27
+ end
28
+
29
+ #: (?String) -> Herb::AST::WhitespaceNode
30
+ def whitespace(value = " ")
31
+ Herb::AST::WhitespaceNode.build(value: token("TOKEN_WHITESPACE", value))
32
+ end
33
+
34
+ #: (String, ?opening: String) -> (Herb::AST::ERBContentNode | Herb::AST::ERBYieldNode)
35
+ def erb(code, opening: "<%=")
36
+ return Herb::AST::ERBYieldNode.build(**erb_tag(opening, code)) if yield?(code)
37
+
38
+ Herb::AST::ERBContentNode.build(**erb_tag(opening, code), parsed: true, valid: Prism.parse(code).errors.empty?)
39
+ end
40
+
41
+ #: (String) -> bool
42
+ def yield?(code)
43
+ code.include?("yield") && !Prism.parse(code).value.breadth_first_search { |node| node.is_a?(Prism::YieldNode) }.nil?
44
+ end
45
+
46
+ #: (String) -> Herb::AST::ERBCommentNode
47
+ def erb_comment(content)
48
+ Herb::AST::ERBCommentNode.build(**erb_tag("<%#", content))
49
+ end
50
+
51
+ #: () -> Herb::AST::ERBEndNode
52
+ def erb_end
53
+ Herb::AST::ERBEndNode.build(**erb_tag("<%", "end"))
54
+ end
55
+
56
+ #: (String, Array[Herb::AST::Node], ?subsequent: (Herb::AST::ERBIfNode | Herb::AST::ERBElseNode)?, ?end_node: Herb::AST::ERBEndNode?) -> Herb::AST::ERBIfNode
57
+ def erb_if(code, statements, subsequent: nil, end_node: erb_end)
58
+ Herb::AST::ERBIfNode.build(**erb_tag("<%", code), statements: statements, subsequent: subsequent, end_node: end_node)
59
+ end
60
+
61
+ #: (String, Array[Herb::AST::Node], ?else_clause: Herb::AST::ERBElseNode?) -> Herb::AST::ERBUnlessNode
62
+ def erb_unless(code, statements, else_clause: nil)
63
+ Herb::AST::ERBUnlessNode.build(**erb_tag("<%", code), statements: statements, else_clause: else_clause, end_node: erb_end)
64
+ end
65
+
66
+ #: (Array[Herb::AST::Node]) -> Herb::AST::ERBElseNode
67
+ def erb_else(statements)
68
+ Herb::AST::ERBElseNode.build(**erb_tag("<%", "else"), statements: statements)
69
+ end
70
+
71
+ #: (String, Array[Herb::AST::ERBWhenNode], ?else_clause: Herb::AST::ERBElseNode?) -> Herb::AST::ERBCaseNode
72
+ def erb_case(code, conditions, else_clause: nil)
73
+ Herb::AST::ERBCaseNode.build(**erb_tag("<%", code), conditions: conditions, else_clause: else_clause, end_node: erb_end)
74
+ end
75
+
76
+ #: (String, Array[Herb::AST::Node]) -> Herb::AST::ERBWhenNode
77
+ def erb_when(code, statements)
78
+ Herb::AST::ERBWhenNode.build(**erb_tag("<%", code), statements: statements)
79
+ end
80
+
81
+ #: (String, Array[Herb::AST::Node], ?rescue_clause: Herb::AST::ERBRescueNode?, ?else_clause: Herb::AST::ERBElseNode?, ?ensure_clause: Herb::AST::ERBEnsureNode?) -> Herb::AST::ERBBeginNode
82
+ def erb_begin(code, statements, rescue_clause: nil, else_clause: nil, ensure_clause: nil)
83
+ Herb::AST::ERBBeginNode.build(
84
+ **erb_tag("<%", code),
85
+ statements: statements,
86
+ rescue_clause: rescue_clause,
87
+ else_clause: else_clause,
88
+ ensure_clause: ensure_clause,
89
+ end_node: erb_end
90
+ )
91
+ end
92
+
93
+ #: (String, Array[Herb::AST::Node], ?subsequent: Herb::AST::ERBRescueNode?) -> Herb::AST::ERBRescueNode
94
+ def erb_rescue(code, statements, subsequent: nil)
95
+ Herb::AST::ERBRescueNode.build(**erb_tag("<%", code), statements: statements, subsequent: subsequent)
96
+ end
97
+
98
+ #: (Array[Herb::AST::Node]) -> Herb::AST::ERBEnsureNode
99
+ def erb_ensure(statements)
100
+ Herb::AST::ERBEnsureNode.build(**erb_tag("<%", "ensure"), statements: statements)
101
+ end
102
+
103
+ #: (String, Array[Herb::AST::Node]) -> Herb::AST::ERBWhileNode
104
+ def erb_while(code, statements)
105
+ Herb::AST::ERBWhileNode.build(**erb_tag("<%", code), statements: statements, end_node: erb_end)
106
+ end
107
+
108
+ #: (String, Array[Herb::AST::Node]) -> Herb::AST::ERBUntilNode
109
+ def erb_until(code, statements)
110
+ Herb::AST::ERBUntilNode.build(**erb_tag("<%", code), statements: statements, end_node: erb_end)
111
+ end
112
+
113
+ #: (String, Array[Herb::AST::Node]) -> Herb::AST::ERBForNode
114
+ def erb_for(code, statements)
115
+ Herb::AST::ERBForNode.build(**erb_tag("<%", code), statements: statements, end_node: erb_end)
116
+ end
117
+
118
+ #: (String, Array[Herb::AST::Node], ?opening: String) -> Herb::AST::ERBBlockNode
119
+ def erb_block(code, body, opening: "<%")
120
+ Herb::AST::ERBBlockNode.build(**erb_tag(opening, code), body: body, block_arguments: block_arguments(code), end_node: erb_end)
121
+ end
122
+
123
+ #: (String) -> Array[Herb::AST::RubyParameterNode]
124
+ def block_arguments(code)
125
+ # Like Herb, only a block passed to the first statement's method call has parameters, so
126
+ # `items.each do |item|` has them and `list = items.map do |item|` doesn't.
127
+ call = Prism.parse("#{code}\nend").value.statements.body.first
128
+ return [] unless call.is_a?(Prism::CallNode)
129
+
130
+ block = call.block
131
+ return [] unless block.is_a?(Prism::BlockNode)
132
+
133
+ block_parameters = block.parameters
134
+ return [] unless block_parameters.is_a?(Prism::BlockParametersNode)
135
+
136
+ parameters = block_parameters.parameters
137
+ return [] unless parameters
138
+
139
+ all = [*parameters.requireds, *parameters.optionals, parameters.rest, *parameters.posts, *parameters.keywords, parameters.keyword_rest, parameters.block].compact
140
+ all = all.flat_map { |parameter| destructured(parameter) }
141
+
142
+ all.filter_map { |parameter| ruby_parameter(parameter) }
143
+ end
144
+
145
+ #: (Prism::node) -> Herb::AST::RubyParameterNode?
146
+ def ruby_parameter(parameter)
147
+ case parameter
148
+ when Prism::RequiredParameterNode
149
+ ruby_parameter_node(parameter.name, "positional", true)
150
+ when Prism::OptionalParameterNode
151
+ ruby_parameter_node(parameter.name, "positional", false, parameter.value)
152
+ when Prism::RestParameterNode
153
+ ruby_parameter_node(parameter.name, "rest", false)
154
+ when Prism::RequiredKeywordParameterNode
155
+ ruby_parameter_node(parameter.name, "keyword", true)
156
+ when Prism::OptionalKeywordParameterNode
157
+ ruby_parameter_node(parameter.name, "keyword", false, parameter.value)
158
+ when Prism::KeywordRestParameterNode
159
+ ruby_parameter_node(parameter.name, "keyword_rest", false)
160
+ when Prism::BlockParameterNode
161
+ ruby_parameter_node(parameter.name, "block", false)
162
+ end
163
+ end
164
+
165
+ #: (Symbol?, String, bool, ?Prism::node?) -> Herb::AST::RubyParameterNode?
166
+ def ruby_parameter_node(name, kind, required, value = nil)
167
+ return unless name
168
+
169
+ default = Herb::AST::RubyLiteralNode.build(content: +value.slice) if value
170
+
171
+ Herb::AST::RubyParameterNode.build(
172
+ name: token("TOKEN_IDENTIFIER", name.to_s), default_value: default, kind: +kind, required: required
173
+ )
174
+ end
175
+
176
+ #: (String, ?attributes: Array[Herb::AST::Node], ?body: Array[Herb::AST::Node], ?self_closing: bool) -> Herb::AST::HTMLElementNode
177
+ def element(name, attributes: [], body: [], self_closing: false)
178
+ void = Herb::HTML::Util.void_element?(name)
179
+
180
+ self_closing &&= !void
181
+
182
+ children = open_tag_children(attributes)
183
+ children << whitespace if self_closing
184
+
185
+ open_tag = Herb::AST::HTMLOpenTagNode.build(
186
+ tag_opening: token("TOKEN_HTML_TAG_START", "<"),
187
+ tag_name: token("TOKEN_IDENTIFIER", name),
188
+ tag_closing: self_closing ? token("TOKEN_HTML_TAG_SELF_CLOSE", "/>") : token("TOKEN_HTML_TAG_END", ">"),
189
+ children: children,
190
+ is_void: self_closing
191
+ )
192
+
193
+ if Herb::HTML::Util.raw_text_element?(name)
194
+ body = merge_literals(body.map do |node|
195
+ node.is_a?(Herb::AST::HTMLTextNode) ? literal(node.content.to_s) : node
196
+ end)
197
+ end
198
+
199
+ # Herb reads the content of elements like `<script>` and `<textarea>` as text and ERB, so Herb
200
+ # builds one that contains anything else, like the templates in `<script type="text/ng-template">`.
201
+ if Herb::HTML::Util.raw_text_element?(name) || Herb::HTML::Util.rcdata_element?(name)
202
+ text_only = body.all? do |node|
203
+ node.is_a?(Herb::AST::LiteralNode) || node.is_a?(Herb::AST::HTMLTextNode) || node.is_a?(Herb::AST::ERBContentNode)
204
+ end
205
+
206
+ unless text_only
207
+ source = IdentityPrinter.print(element(name, attributes: attributes, body: []).tap { |node| node.body.replace(body) })
208
+
209
+ return Herb.parse(source, track_whitespace: true).value.children.fetch(0) #: Herb::AST::HTMLElementNode
210
+ end
211
+ end
212
+
213
+ close_tag = unless void || self_closing
214
+ Herb::AST::HTMLCloseTagNode.build(
215
+ tag_opening: token("TOKEN_HTML_TAG_START_CLOSE", "</"),
216
+ tag_name: token("TOKEN_IDENTIFIER", name),
217
+ tag_closing: token("TOKEN_HTML_TAG_END", ">")
218
+ )
219
+ end
220
+
221
+ Herb::AST::HTMLElementNode.build(
222
+ open_tag: open_tag,
223
+ tag_name: token("TOKEN_IDENTIFIER", name),
224
+ body: body,
225
+ close_tag: close_tag,
226
+ is_void: void || self_closing,
227
+ element_source: "HTML"
228
+ )
229
+ end
230
+
231
+ #: (String | Herb::AST::ERBContentNode, ?Array[Herb::AST::Node]?) -> Herb::AST::HTMLAttributeNode
232
+ def attribute(name, value = nil)
233
+ name_node = Herb::AST::HTMLAttributeNameNode.build(children: [name.is_a?(String) ? literal(name) : name])
234
+ return Herb::AST::HTMLAttributeNode.build(name: name_node) if value.nil?
235
+
236
+ Herb::AST::HTMLAttributeNode.build(
237
+ name: name_node,
238
+ equals: token("TOKEN_EQUALS", "="),
239
+ value: Herb::AST::HTMLAttributeValueNode.build(
240
+ open_quote: token("TOKEN_QUOTE", '"'),
241
+ children: merge_literals(value),
242
+ close_quote: token("TOKEN_QUOTE", '"'),
243
+ quoted: true
244
+ )
245
+ )
246
+ end
247
+
248
+ #: (Array[Herb::AST::Node]) -> Herb::AST::HTMLCommentNode
249
+ def comment(children)
250
+ children = children.map { |node| node.is_a?(Herb::AST::HTMLTextNode) ? literal(node.content.to_s) : node }
251
+
252
+ # Herb doesn't parse elements inside a comment, so Herb builds a comment that contains any.
253
+ unless children.all? { |node| node.is_a?(Herb::AST::LiteralNode) || node.is_a?(Herb::AST::ERBContentNode) }
254
+ source = "<!--#{children.map { |node| IdentityPrinter.print(node) }.join}-->"
255
+
256
+ return Herb.parse(source, track_whitespace: true).value.children.fetch(0) #: Herb::AST::HTMLCommentNode
257
+ end
258
+
259
+ Herb::AST::HTMLCommentNode.build(
260
+ comment_start: token("TOKEN_HTML_COMMENT_START", "<!--"),
261
+ children: merge_literals(children),
262
+ comment_end: token("TOKEN_HTML_COMMENT_END", "-->")
263
+ )
264
+ end
265
+
266
+ #: (String) -> Herb::AST::HTMLDoctypeNode
267
+ def doctype(content)
268
+ Herb::AST::HTMLDoctypeNode.build(
269
+ tag_opening: token("TOKEN_HTML_DOCTYPE", "<!DOCTYPE"),
270
+ children: [literal(content)],
271
+ tag_closing: token("TOKEN_HTML_TAG_END", ">")
272
+ )
273
+ end
274
+
275
+ #: (String) -> Herb::AST::XMLDeclarationNode
276
+ def xml_declaration(content)
277
+ Herb::AST::XMLDeclarationNode.build(
278
+ tag_opening: token("TOKEN_XML_DECLARATION", "<?xml"),
279
+ children: [literal(content)],
280
+ tag_closing: token("TOKEN_XML_DECLARATION_END", "?>")
281
+ )
282
+ end
283
+
284
+ #: (Prism::node) -> Array[Prism::node]
285
+ def destructured(parameter)
286
+ return [parameter] unless parameter.is_a?(Prism::MultiTargetNode)
287
+
288
+ [*parameter.lefts, *parameter.rights].flat_map { |target| destructured(target) }
289
+ end
290
+
291
+ #: (Array[Herb::AST::Node]) -> Array[Herb::AST::Node]
292
+ def open_tag_children(attributes)
293
+ attributes.each_with_index.flat_map do |node, index|
294
+ # Herb reads ERB in an open tag as an attribute name when an attribute with a value follows it.
295
+ valued = attributes.drop(index + 1).any? { |following| following.is_a?(Herb::AST::HTMLAttributeNode) && following.equals }
296
+
297
+ if node.is_a?(Herb::AST::ERBContentNode) && valued
298
+ [whitespace, attribute(node)]
299
+ else
300
+ [whitespace, node]
301
+ end
302
+ end
303
+ end
304
+
305
+ #: [T < Herb::AST::Node] (T) -> T
306
+ def merge_texts!(node)
307
+ node.instance_variables.each do |name|
308
+ value = node.instance_variable_get(name)
309
+
310
+ if value.is_a?(Array)
311
+ value.each { |child| merge_texts!(child) if child.is_a?(Herb::AST::Node) }
312
+ value.replace(merge_adjacent_texts(value))
313
+ elsif value.is_a?(Herb::AST::Node)
314
+ merge_texts!(value)
315
+ end
316
+ end
317
+
318
+ node
319
+ end
320
+
321
+ #: (Array[Herb::AST::Node]) -> Array[Herb::AST::Node]
322
+ def merge_adjacent_texts(nodes)
323
+ nodes.each_with_object([]) do |node, merged|
324
+ last = merged.last
325
+
326
+ if node.is_a?(Herb::AST::HTMLTextNode) && last.is_a?(Herb::AST::HTMLTextNode)
327
+ merged[-1] = text(last.content.to_s + node.content.to_s)
328
+ else
329
+ merged << node
330
+ end
331
+ end
332
+ end
333
+
334
+ #: (Array[Herb::AST::Node]) -> Array[Herb::AST::Node]
335
+ def merge_literals(nodes)
336
+ nodes.each_with_object([]) do |node, merged|
337
+ next if node.is_a?(Herb::AST::LiteralNode) && node.content.to_s.empty?
338
+
339
+ last = merged.last
340
+
341
+ if node.is_a?(Herb::AST::LiteralNode) && last.is_a?(Herb::AST::LiteralNode)
342
+ merged[-1] = literal(last.content.to_s + node.content.to_s)
343
+ else
344
+ merged << node
345
+ end
346
+ end
347
+ end
348
+
349
+ #: (String, String) -> { tag_opening: Herb::Token, content: Herb::Token, tag_closing: Herb::Token }
350
+ def erb_tag(opening, code)
351
+ {
352
+ tag_opening: token("TOKEN_ERB_START", opening),
353
+ content: token("TOKEN_ERB_CONTENT", " #{code.strip} "),
354
+ tag_closing: token("TOKEN_ERB_END", "%>"),
355
+ }
356
+ end
357
+ end
358
+ end