mjml-rb 0.4.2 → 0.4.4

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: 0062fce070623c7a358e1af4811bb928a46d7f0cd4e1320a71e16276ccca1d74
4
- data.tar.gz: a4e81e450419963570486009b46f44962d93c25b41cdbe4df721346db6e79fc8
3
+ metadata.gz: 750fc042cf9ff6273cebd5907a096e6fc304d28a49b0872cf6eec93f9818e148
4
+ data.tar.gz: 138a44a38b430267f646cd771b4a9fa01e3c0e9609083291de5270280db36650
5
5
  SHA512:
6
- metadata.gz: 880bfd1a06dfe3fb9e8fe2169fe660554cb4e9ca96ee5d6eff1d2b41f6df748af14b753895d7b58a3106e33335516f6f4caadd2fd01717c597846b6104330fbf
7
- data.tar.gz: 94f84fa6a66210d9177db704c16efa6fce09d983eb138bc5d13fdbf9bf967ac553b867907cfce9190a2bd7e14a6e6108c4ff74eb776d16221cbf77689f59ea85
6
+ metadata.gz: 5d83da073643a75183303c6a14a6b0cbd2e06d864875c80a4bbfc65ef5c857b5a4ffb2971a0465cc58e46b400efe8a1d760994f643a420da0d549837799f8bf5
7
+ data.tar.gz: 1bd7d0c899cb6de244ab534d0b598b12f437c53ef556cb7fd5c56880ec4d6fc164dedb493ad49e45e60b52e5bd9180dd725b7f91790eb2952440df5d5219bb3a
data/README.md CHANGED
@@ -11,198 +11,98 @@
11
11
  > This is a **fully open source project** — feedback, bug reports, test cases,
12
12
  > and pull requests are welcome!
13
13
 
14
- This gem provides a Ruby-first implementation of the main MJML tooling:
14
+ A pure-Ruby MJML v4 compiler no Node.js required.
15
15
 
16
- - library API compatible with `mjml2html`
17
- - command-line interface (`mjml`)
18
- - validation commands
19
- - pure Ruby parser, AST, validator, and renderer
20
- - no Node.js runtime and no shelling out to the official npm renderer
16
+ - Library API compatible with `mjml2html`
17
+ - Command-line interface (`mjml`)
18
+ - Rails integration (ActionView template handler for `.mjml` views)
19
+ - Validation (soft, strict, skip)
20
+ - Custom component support
21
+ - Pure Ruby parser, AST, validator, and renderer
21
22
 
22
- Remaining parity work is tracked in [npm Ruby Parity Audit](docs/PARITY_AUDIT.md).
23
-
24
- ## Compatibility
25
-
26
- This project targets **MJML v4 only**.
23
+ **[Full Usage Guide](docs/USAGE.md)** API reference, all compiler options, component attribute tables, CLI flags, Rails setup, custom components, and more.
27
24
 
28
- - parsing, validation, and rendering are implemented against the MJML v4 document structure
29
- - component rules and attribute validation follow the MJML v4 model
25
+ ## Installation
30
26
 
31
- ## Quick start
32
-
33
- ```bash
34
- bundle install
35
- bundle exec ruby -Ilib -e 'require "mjml-rb"; puts MjmlRb.mjml2html("<mjml><mj-body><mj-section><mj-column><mj-text>Hello</mj-text></mj-column></mj-section></mj-body></mjml>")[:html]'
27
+ ```ruby
28
+ # Gemfile
29
+ gem "mjml-rb"
36
30
  ```
37
31
 
38
- ## CLI usage
32
+ ## Quick Start
39
33
 
40
- ```bash
41
- bundle exec bin/mjml example.mjml -o output.html
42
- bundle exec bin/mjml --validate example.mjml
34
+ ```ruby
35
+ require "mjml-rb"
36
+
37
+ result = MjmlRb.mjml2html(<<~MJML)
38
+ <mjml>
39
+ <mj-body>
40
+ <mj-section>
41
+ <mj-column>
42
+ <mj-text>Hello World!</mj-text>
43
+ </mj-column>
44
+ </mj-section>
45
+ </mj-body>
46
+ </mjml>
47
+ MJML
48
+
49
+ puts result[:html] # compiled HTML
50
+ puts result[:errors] # validation errors (if any)
43
51
  ```
44
52
 
45
- ## Rails integration
46
-
47
- In a Rails app, requiring the gem registers an `ActionView` template handler for
48
- `.mjml` templates through a `Railtie`.
49
-
50
- By default, `.mjml` files are treated as raw MJML/XML source.
53
+ ## CLI
51
54
 
52
- If you want Slim-backed MJML templates, configure it explicitly:
53
-
54
- ```ruby
55
- config.mjml_rb.rails_template_language = :slim
55
+ ```bash
56
+ mjml email.mjml -o email.html # compile to file
57
+ mjml -r "templates/*.mjml" -o output/ # batch compile
58
+ mjml -v email.mjml # validate only
59
+ mjml -i -s < email.mjml # stdin → stdout
56
60
  ```
57
61
 
58
- Supported values are `:slim` and `:haml`.
62
+ See the [Usage Guide — CLI section](docs/USAGE.md#cli) for all flags and config options.
59
63
 
60
- With a configured `rails_template_language`, `.mjml` templates are rendered
61
- through that template engine first, so partials and embedded Ruby can assemble
62
- MJML before the outer template is compiled to HTML. Without that setting,
63
- non-XML MJML source is rejected instead of being guessed.
64
+ ## Rails
64
65
 
65
- For `:slim` or `:haml`, the matching Rails template handler must already be
66
- registered in `ActionView` by the corresponding gem or integration layer.
66
+ Add the gem to your Gemfile that's it. The `.mjml` template handler is registered automatically.
67
67
 
68
- Create a view such as `app/views/user_mailer/welcome.html.mjml`:
69
-
70
- ```mjml
68
+ ```erb
69
+ <!-- app/views/user_mailer/welcome.html.mjml -->
71
70
  <mjml>
72
71
  <mj-body>
73
72
  <mj-section>
74
73
  <mj-column>
75
- <mj-text>Hello from Rails</mj-text>
74
+ <mj-text>Welcome, <%= @user.name %>!</mj-text>
76
75
  </mj-column>
77
76
  </mj-section>
78
77
  </mj-body>
79
78
  </mjml>
80
79
  ```
81
80
 
82
- Then render it like any other Rails template:
83
-
84
81
  ```ruby
85
82
  class UserMailer < ApplicationMailer
86
- def welcome
87
- mail(to: "user@example.com", subject: "Welcome")
83
+ def welcome(user)
84
+ @user = user
85
+ mail(to: user.email, subject: "Welcome")
88
86
  end
89
87
  end
90
88
  ```
91
89
 
92
- Rails rendering uses strict MJML validation by default. You can override the
93
- compiler options in your application config:
94
-
95
- ```ruby
96
- config.mjml_rb.compiler_options = { validation_level: "soft" }
97
- ```
98
-
99
- ## Custom components
90
+ Supports Slim and Haml via `config.mjml_rb.rails_template_language = :slim`. See the [Usage Guide — Rails section](docs/USAGE.md#rails-integration) for full configuration.
100
91
 
101
- You can register custom MJML components written in Ruby:
102
-
103
- ```ruby
104
- class MjRating < MjmlRb::Components::Base
105
- TAGS = ["mj-rating"].freeze
106
- ALLOWED_ATTRIBUTES = { "stars" => "integer", "color" => "color" }.freeze
107
- DEFAULT_ATTRIBUTES = { "stars" => "5", "color" => "#f4b400" }.freeze
108
-
109
- def render(tag_name:, node:, context:, attrs:, parent:)
110
- stars = (attrs["stars"] || "5").to_i
111
- color = attrs["color"] || "#f4b400"
112
- %(<div style="color:#{escape_attr(color)}">#{"\u2605" * stars}</div>)
113
- end
114
- end
115
-
116
- MjmlRb.register_component(MjRating,
117
- dependencies: { "mj-column" => ["mj-rating"] },
118
- ending_tags: ["mj-rating"]
119
- )
120
- ```
121
-
122
- The `dependencies` hash declares which parent tags accept the new component as a child. The `ending_tags` list tells the parser to treat content as raw HTML (like `mj-text`). Both are optional.
123
-
124
- Once registered, the component works in MJML markup and is validated like any built-in component.
125
-
126
- ## `.mjmlrc` config file
127
-
128
- Place a `.mjmlrc` file (JSON) in your project root to auto-register custom components and set default compiler options:
92
+ ## Architecture
129
93
 
130
- ```json
131
- {
132
- "packages": [
133
- "./lib/mjml_components/mj_rating.rb"
134
- ],
135
- "options": {
136
- "beautify": true,
137
- "validation-level": "soft"
138
- }
139
- }
140
94
  ```
141
-
142
- - **`packages`** — Ruby files to `require`. Each file should call `MjmlRb.register_component` to register its components.
143
- - **`options`** — Default compiler options. CLI flags and programmatic options override these.
144
-
145
- The CLI loads `.mjmlrc` automatically from the working directory. For the library API, load it explicitly:
146
-
147
- ```ruby
148
- MjmlRb::ConfigFile.load("/path/to/project")
149
- result = MjmlRb.mjml2html(mjml_string)
95
+ MJML string → Parser → AST → Validator → Renderer → HTML
150
96
  ```
151
97
 
152
- ## Architecture
98
+ 1. **Parser** — normalizes source, expands `mj-include`, builds `AstNode` tree
99
+ 2. **Validator** — checks structure, hierarchy, and attribute types
100
+ 3. **Renderer** — resolves head metadata, applies defaults, emits responsive HTML
153
101
 
154
- The compile pipeline is intentionally simple and fully Ruby-based:
155
-
156
- 1. `MjmlRb.mjml2html` calls `MjmlRb::Compiler`.
157
- 2. `MjmlRb::Parser` normalizes the source, expands `mj-include`, and builds an `AstNode` tree.
158
- 3. `MjmlRb::Validator` checks structural rules and supported attributes.
159
- 4. `MjmlRb::Renderer` resolves head metadata, applies component defaults, and renders HTML.
160
- 5. `MjmlRb::Compiler` post-processes the output and returns a `Result`.
161
-
162
- The key architectural idea is that the project uses a small shared AST plus a component registry:
163
-
164
- - the parser produces generic `AstNode` objects instead of component-specific node types
165
- - structure rules live in `lib/mjml-rb/dependencies.rb`
166
- - rendering logic lives in `lib/mjml-rb/components/*`
167
- - head components populate a shared rendering context
168
- - body components consume that context and emit the final HTML
169
-
170
- That split keeps the compiler pipeline predictable:
171
-
172
- - parsing is responsible for source normalization and include expansion
173
- - validation is responsible for MJML structure and attribute checks
174
- - rendering is responsible for HTML generation and responsive email output
175
-
176
- ## Project structure
177
-
178
- The main files are organized like this:
179
-
180
- ```text
181
- lib/mjml-rb.rb # public gem entry point
182
- lib/mjml-rb/compiler.rb # orchestration: parse -> validate -> render
183
- lib/mjml-rb/parser.rb # MJML/XML normalization, includes, AST building
184
- lib/mjml-rb/ast_node.rb # shared tree representation
185
- lib/mjml-rb/validator.rb # structural and attribute validation
186
- lib/mjml-rb/dependencies.rb # allowed parent/child relationships
187
- lib/mjml-rb/renderer.rb # HTML document assembly and render context
188
- lib/mjml-rb/components/* # per-component rendering and head handling
189
- lib/mjml-rb/result.rb # result object returned by the compiler
190
- lib/mjml-rb/cli.rb # CLI implementation used by bin/mjml
191
- docs/ARCHITECTURE.md # deeper architecture notes
192
- docs/PARITY_AUDIT.md # npm vs Ruby parity tracking
193
- ```
102
+ For the full internal walkthrough, see [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
194
103
 
195
- If you want the full internal walkthrough, see [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
104
+ Remaining parity work is tracked in [npm Ruby Parity Audit](docs/PARITY_AUDIT.md).
196
105
 
197
- ## Implementation goal
106
+ ## License
198
107
 
199
- > **Ruby MJML pipeline without the Node.js renderer.**
200
- >
201
- > The npm `mjml` package requires Node.js at build time (or runtime via a child
202
- > process / FFI bridge). This project replaces that entire pipeline with a single
203
- > Ruby library: XML parsing, AST construction, attribute resolution, validation,
204
- > and HTML rendering — all in Ruby, with no Node.js runtime and no need to
205
- > shell out to the official MJML renderer. Drop it into a Rails, Sinatra, or
206
- > plain Ruby project and render MJML templates the same way you render ERB — no
207
- > extra runtime, no
208
- > `package.json`, no `node_modules`.
108
+ MIT
@@ -173,7 +173,7 @@ module MjmlRb
173
173
  when "mj-accordion-element"
174
174
  render_accordion_element(child, context, accordion_attrs)
175
175
  when "mj-raw"
176
- raw_inner(child)
176
+ raw_inner_for_body(child)
177
177
  else
178
178
  render_node(child, context, parent: "mj-accordion")
179
179
  end
@@ -210,7 +210,7 @@ module MjmlRb
210
210
  child_attrs = attrs.merge(resolved_attributes(child, context))
211
211
  content << render_accordion_text(child, child_attrs)
212
212
  when "mj-raw"
213
- content << raw_inner(child)
213
+ content << raw_inner_for_body(child)
214
214
  end
215
215
  end
216
216
  end
@@ -249,7 +249,7 @@ module MjmlRb
249
249
  "width" => "100%",
250
250
  "border-bottom" => title_attrs["border"] || DEFAULTS["border"]
251
251
  )
252
- title_content = node ? raw_inner(node) : ""
252
+ title_content = node ? raw_inner_for_body(node) : ""
253
253
  title_cell = %(<td style="#{td_style}">#{title_content}</td>)
254
254
  icon_cell = %(<td class="mj-accordion-ico" style="#{td2_style}"><img src="#{escape_attr(title_attrs["icon-wrapped-url"] || DEFAULTS["icon-wrapped-url"])}" alt="#{escape_attr(title_attrs["icon-wrapped-alt"] || DEFAULTS["icon-wrapped-alt"])}" class="mj-accordion-more" style="#{icon_style}" /><img src="#{escape_attr(title_attrs["icon-unwrapped-url"] || DEFAULTS["icon-unwrapped-url"])}" alt="#{escape_attr(title_attrs["icon-unwrapped-alt"] || DEFAULTS["icon-unwrapped-alt"])}" class="mj-accordion-less" style="#{icon_style}" /></td>)
255
255
  cells = title_attrs["icon-position"] == "left" ? "#{icon_cell}#{title_cell}" : "#{title_cell}#{icon_cell}"
@@ -277,7 +277,7 @@ module MjmlRb
277
277
  "width" => "100%",
278
278
  "border-bottom" => text_attrs["border"] || DEFAULTS["border"]
279
279
  )
280
- content = node ? raw_inner(node) : ""
280
+ content = node ? raw_inner_for_body(node) : ""
281
281
  css_class = text_attrs["css-class"] ? %( class="#{escape_attr(text_attrs["css-class"])}") : ""
282
282
 
283
283
  %(<div class="mj-accordion-content"><table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="#{table_style}"><tbody><tr><td#{css_class} style="#{td_style}">#{content}</td></tr></tbody></table></div>)
@@ -50,6 +50,10 @@ module MjmlRb
50
50
  renderer.send(:raw_inner, node)
51
51
  end
52
52
 
53
+ def raw_inner_for_body(node)
54
+ renderer.send(:annotate_raw_html, raw_inner(node))
55
+ end
56
+
53
57
  # Like raw_inner but HTML-escapes text nodes. Use for components such as
54
58
  # mj-text where the inner content is treated as HTML but bare text must
55
59
  # be properly encoded (e.g. & -> &amp;).
@@ -143,7 +143,7 @@ module MjmlRb
143
143
  link_attrs["title"] = a["title"]
144
144
  end
145
145
 
146
- content = raw_inner(node)
146
+ content = raw_inner_for_body(node)
147
147
  inner_tag = %(<#{tag}#{html_attrs(link_attrs)}>#{content}</#{tag}>)
148
148
  table = %(<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="#{table_style}"><tbody><tr><td#{html_attrs(td_attrs)}>#{inner_tag}</td></tr></tbody></table>)
149
149
 
@@ -205,7 +205,7 @@ module MjmlRb
205
205
  "style" => anchor_style
206
206
  }
207
207
 
208
- content = raw_inner(node)
208
+ content = raw_inner_for_body(node)
209
209
  link = %(<a#{html_attrs(link_attrs)}>#{content}</a>)
210
210
  return link unless parent == "mj-navbar"
211
211
 
@@ -10,7 +10,7 @@ module MjmlRb
10
10
  }.freeze
11
11
 
12
12
  def render(tag_name:, node:, context:, attrs:, parent:)
13
- raw_inner(node)
13
+ raw_inner_for_body(node)
14
14
  end
15
15
 
16
16
  def handle_head(node, context)
@@ -603,7 +603,7 @@ module MjmlRb
603
603
  "overflow" => (has_border_radius ? "hidden" : nil),
604
604
  "margin" => "0px auto",
605
605
  "margin-top" => wrapper_gap,
606
- "max-width" => (full_width ? nil : "#{container_px}px")
606
+ "max-width" => "#{container_px}px"
607
607
  }.merge(full_width ? {} : background_styles)
608
608
  )
609
609
 
@@ -72,7 +72,7 @@ module MjmlRb
72
72
  "height" => height
73
73
  )
74
74
 
75
- content = raw_inner(node)
75
+ content = raw_inner_for_body(node)
76
76
  inner_div = %(<div style="#{div_style}">#{content}</div>)
77
77
 
78
78
  body = if height
@@ -18,13 +18,22 @@ module MjmlRb
18
18
  ].freeze
19
19
 
20
20
  # Pre-compiled regex patterns to avoid rebuilding on every call
21
- ENDING_TAGS_CDATA_RE = /<(#{ENDING_TAGS_FOR_CDATA.map { |t| Regexp.escape(t) }.join("|")})(\s[^<>]*?)?(?<!\/)>(.*?)<\/\1>/mi.freeze
21
+ ENDING_TAG_OPEN_RE = /<(#{ENDING_TAGS_FOR_CDATA.map { |t| Regexp.escape(t) }.join("|")})(\s[^<>]*?)?(?<!\/)>/mi.freeze
22
22
 
23
23
  VOID_TAG_CLOSING_BR_RE = %r{</br\s*>}i.freeze
24
24
  VOID_TAG_CLOSING_OTHER_RE = /<\/(#{(HTML_VOID_TAGS - ["br"]).join("|")})\s*>/i.freeze
25
25
  VOID_TAG_OPEN_RE = /<(#{HTML_VOID_TAGS.join("|")})(\s[^<>]*?)?>/i.freeze
26
26
  LINE_ANNOTATION_RE = /(\n)|(<!\[CDATA\[.*?\]\]>)|(<(?:mj-[\w-]+|mjml)(?=[\s\/>]))/m.freeze
27
27
  BARE_AMPERSAND_RE = /&(?!(?:#[0-9]+|#x[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);)/.freeze
28
+ ROOT_LEVEL_HEAD_TAGS = %w[
29
+ mj-attributes
30
+ mj-breakpoint
31
+ mj-html-attributes
32
+ mj-font
33
+ mj-preview
34
+ mj-style
35
+ mj-title
36
+ ].freeze
28
37
 
29
38
  class ParseError < StandardError
30
39
  attr_reader :line
@@ -49,6 +58,7 @@ module MjmlRb
49
58
 
50
59
  xml = annotate_line_numbers(sanitize_bare_ampersands(xml))
51
60
  doc = Document.new(xml)
61
+ normalize_root_head_elements(doc)
52
62
  element_to_ast(doc.root, keep_comments: opts[:keep_comments])
53
63
  rescue ParseException => e
54
64
  raise ParseError.new("XML parse error: #{e.message}")
@@ -173,6 +183,7 @@ module MjmlRb
173
183
 
174
184
  def extract_mjml_include_children(xml)
175
185
  include_doc = Document.new(sanitize_bare_ampersands(xml))
186
+ normalize_root_head_elements(include_doc)
176
187
  mjml_root = include_doc.root
177
188
  return [[], []] unless mjml_root&.name == "mjml"
178
189
 
@@ -219,6 +230,38 @@ module MjmlRb
219
230
  head
220
231
  end
221
232
 
233
+ def normalize_root_head_elements(doc)
234
+ mjml_root = doc.root
235
+ return unless mjml_root&.name == "mjml"
236
+
237
+ head_nodes = []
238
+ normalized_head_children = []
239
+ root_head_elements = []
240
+
241
+ mjml_root.children.each do |child|
242
+ next unless child.is_a?(Element)
243
+
244
+ if child.name == "mj-head"
245
+ head_nodes << child
246
+ child.children.each { |head_child| normalized_head_children << deep_clone(head_child) }
247
+ elsif ROOT_LEVEL_HEAD_TAGS.include?(child.name)
248
+ root_head_elements << child
249
+ normalized_head_children << deep_clone(child)
250
+ end
251
+ end
252
+
253
+ return if root_head_elements.empty? && head_nodes.length <= 1
254
+
255
+ head = head_nodes.first || ensure_head(doc)
256
+ return unless head
257
+
258
+ head.children.to_a.each { |child| head.delete(child) }
259
+ normalized_head_children.each { |child| head.add(child) }
260
+
261
+ root_head_elements.each { |child| mjml_root.delete(child) }
262
+ head_nodes.drop(1).each { |extra_head| mjml_root.delete(extra_head) }
263
+ end
264
+
222
265
  def strip_xml_declaration(content)
223
266
  content.sub(/\A<\?xml[^>]*\?>\s*/m, "")
224
267
  end
@@ -239,20 +282,74 @@ module MjmlRb
239
282
  end
240
283
 
241
284
  def wrap_ending_tags_in_cdata(content)
242
- # Negative lookbehind (?<!\/) ensures self-closing tags like <mj-text ... /> are skipped
243
- content.gsub(ENDING_TAGS_CDATA_RE) do
244
- tag = Regexp.last_match(1)
245
- attrs = Regexp.last_match(2).to_s
246
- inner = Regexp.last_match(3).to_s
285
+ wrapped = +""
286
+ cursor = 0
287
+
288
+ while (match = ENDING_TAG_OPEN_RE.match(content, cursor))
289
+ tag = match[1]
290
+ attrs = match[2].to_s
291
+ wrapped << content[cursor...match.begin(0)]
292
+
293
+ closing_range = find_matching_ending_tag(content, tag, match.end(0))
294
+ unless closing_range
295
+ wrapped << match[0]
296
+ cursor = match.end(0)
297
+ next
298
+ end
299
+
300
+ inner = content[match.end(0)...closing_range.begin(0)]
247
301
  if inner.include?("<![CDATA[")
248
- "<#{tag}#{attrs}>#{inner}</#{tag}>"
302
+ wrapped << "<#{tag}#{attrs}>#{inner}</#{tag}>"
249
303
  else
250
304
  # Pre-process content: normalize void tags and sanitize bare ampersands
251
305
  # before wrapping in CDATA, so the raw HTML is well-formed for output.
252
306
  prepared = sanitize_bare_ampersands(normalize_html_void_tags(inner))
253
- "<#{tag}#{attrs}><![CDATA[#{escape_cdata(prepared)}]]></#{tag}>"
307
+ wrapped << "<#{tag}#{attrs}><![CDATA[#{escape_cdata(prepared)}]]></#{tag}>"
254
308
  end
309
+
310
+ cursor = closing_range.end(0)
255
311
  end
312
+
313
+ wrapped << content[cursor..] if cursor < content.length
314
+ wrapped
315
+ end
316
+
317
+ def find_matching_ending_tag(content, tag_name, cursor)
318
+ open_tag_re = /<#{Regexp.escape(tag_name)}(\s[^<>]*?)?(?<!\/)>/mi
319
+ close_tag_re = %r{</#{Regexp.escape(tag_name)}\s*>}i
320
+ depth = 1
321
+
322
+ while cursor < content.length
323
+ cdata_index = content.index("<![CDATA[", cursor)
324
+ open_match = open_tag_re.match(content, cursor)
325
+ close_match = close_tag_re.match(content, cursor)
326
+
327
+ candidates = []
328
+ candidates << [:cdata, cdata_index, nil] if cdata_index
329
+ candidates << [:open, open_match.begin(0), open_match] if open_match
330
+ candidates << [:close, close_match.begin(0), close_match] if close_match
331
+ return nil if candidates.empty?
332
+
333
+ kind, _, match = candidates.min_by { |candidate| candidate[1] }
334
+
335
+ case kind
336
+ when :cdata
337
+ cdata_end = content.index("]]>", cdata_index + 9)
338
+ return nil unless cdata_end
339
+
340
+ cursor = cdata_end + 3
341
+ when :open
342
+ depth += 1
343
+ cursor = match.end(0)
344
+ when :close
345
+ depth -= 1
346
+ return match if depth.zero?
347
+
348
+ cursor = match.end(0)
349
+ end
350
+ end
351
+
352
+ nil
256
353
  end
257
354
 
258
355
  def escape_cdata(content)
@@ -337,6 +434,7 @@ module MjmlRb
337
434
  attrs = element.attributes.each_with_object({}) do |(name, val), h|
338
435
  h[name] = val unless name.start_with?("data-mjml-")
339
436
  end
437
+ attrs["data-mjml-raw"] = "true" unless element.name.start_with?("mj-") || element.name == "mjml"
340
438
 
341
439
  # For ending-tag elements whose content was wrapped in CDATA, store
342
440
  # the raw HTML directly as content instead of parsing structurally.
@@ -358,7 +456,10 @@ module MjmlRb
358
456
  memo << element_to_ast(child, keep_comments: keep_comments)
359
457
  when Text
360
458
  text = child.value
361
- memo << AstNode.new(tag_name: "#text", content: text) unless text.strip.empty?
459
+ next if text.empty?
460
+ next if text.strip.empty? && ignorable_whitespace_text?(text, parent_element_name: element.name)
461
+
462
+ memo << AstNode.new(tag_name: "#text", content: text)
362
463
  when Comment
363
464
  memo << AstNode.new(tag_name: "#comment", content: child.string) if keep_comments
364
465
  end
@@ -372,5 +473,11 @@ module MjmlRb
372
473
  file: meta_file
373
474
  )
374
475
  end
476
+
477
+ def ignorable_whitespace_text?(text, parent_element_name:)
478
+ return true if parent_element_name.start_with?("mj-") || parent_element_name == "mjml"
479
+
480
+ text.match?(/[\r\n]/)
481
+ end
375
482
  end
376
483
  end
@@ -168,6 +168,8 @@ module MjmlRb
168
168
  HTML
169
169
 
170
170
  html = apply_inline_styles(html, context)
171
+ html = preserve_raw_tag_spacing(html)
172
+ html = strip_internal_raw_markers(html)
171
173
  html = merge_outlook_conditionals(html)
172
174
  before_doctype.empty? ? html : "#{before_doctype}\n#{html}"
173
175
  end
@@ -342,20 +344,27 @@ module MjmlRb
342
344
  return html if css_blocks.empty?
343
345
 
344
346
  document = parse_html_document(html)
345
- rules, at_rules_css = parse_inline_css_rules(css_blocks.join("\n"))
347
+ rules, = parse_inline_css_rules(css_blocks.join("\n"))
348
+ merged_declarations_by_node = {}
349
+ touched_properties_by_node = Hash.new { |hash, node| hash[node] = Set.new }
346
350
 
347
351
  rules.each do |selector, declarations|
348
352
  next if selector.empty? || declarations.empty?
349
353
 
350
354
  select_nodes(document, selector).each do |node|
351
- merge_inline_style!(node, declarations)
355
+ existing = merged_declarations_by_node[node] ||= begin
356
+ source = node["data-mjml-raw"] == "true" ? :inline : :css
357
+ parsed = parse_css_declarations(node["style"].to_s, source: source)
358
+ touched_properties_by_node[node].merge(parsed.keys & HTML_ATTRIBUTE_SYNC_PROPERTIES.to_a) if source == :inline
359
+ parsed
360
+ end
361
+ merge_inline_declarations!(existing, declarations, touched_properties_by_node[node])
352
362
  end
353
363
  end
354
364
 
355
- # Inject preserved @-rules (@media, @font-face, etc.) as a <style> block.
356
- # These rules cannot be inlined into style attributes but should be kept
357
- # in the document for runtime application by email clients.
358
- inject_preserved_at_rules(document, at_rules_css)
365
+ merged_declarations_by_node.each do |node, declarations|
366
+ finalize_inline_style!(node, declarations, touched_properties_by_node[node])
367
+ end
359
368
 
360
369
  document.to_html
361
370
  end
@@ -368,18 +377,6 @@ module MjmlRb
368
377
  end
369
378
  end
370
379
 
371
- def inject_preserved_at_rules(document, at_rules_css)
372
- return if at_rules_css.nil? || at_rules_css.strip.empty?
373
-
374
- head = document.at_css("head")
375
- return unless head
376
-
377
- style = Nokogiri::XML::Node.new("style", document)
378
- style["type"] = "text/css"
379
- style.content = at_rules_css.strip
380
- head.add_child(style)
381
- end
382
-
383
380
  def select_nodes(document, selector)
384
381
  document.css(selector)
385
382
  rescue Nokogiri::CSS::SyntaxError, Nokogiri::XML::XPath::SyntaxError
@@ -517,7 +514,7 @@ module MjmlRb
517
514
  [a, b, c]
518
515
  end
519
516
 
520
- def parse_css_declarations(declarations)
517
+ def parse_css_declarations(declarations, source: :css)
521
518
  declarations.split(";").each_with_object({}) do |entry, memo|
522
519
  property, value = entry.split(":", 2).map { |part| part&.strip }
523
520
  next if property.nil? || property.empty? || value.nil? || value.empty?
@@ -525,35 +522,26 @@ module MjmlRb
525
522
  important = value.match?(/\s*!important\s*\z/)
526
523
  memo[property] = {
527
524
  value: value.sub(/\s*!important\s*\z/, "").strip,
528
- important: important
525
+ important: important,
526
+ source: source
529
527
  }
530
528
  end
531
529
  end
532
530
 
533
- def merge_inline_style!(node, declarations)
534
- existing = parse_css_declarations(node["style"].to_s)
531
+ def merge_inline_declarations!(existing, declarations, touched_properties)
535
532
  declarations.each do |property, value|
536
533
  merged = merge_css_declaration(existing[property], value)
537
534
  next if merged.equal?(existing[property])
538
535
 
539
536
  existing.delete(property)
540
537
  existing[property] = merged
538
+ touched_properties << property
541
539
  end
542
- normalize_background_fallbacks!(node, existing)
543
- sync_html_attributes!(node, existing)
544
- node["style"] = serialize_css_declarations(existing)
545
540
  end
546
541
 
547
- def normalize_background_fallbacks!(node, declarations)
548
- background_color = declaration_value(declarations["background-color"])
549
- return if background_color.nil? || background_color.empty?
550
-
551
- if syncable_background?(declaration_value(declarations["background"]))
552
- declarations["background"] = {
553
- value: background_color,
554
- important: declarations.fetch("background-color", {}).fetch(:important, false)
555
- }
556
- end
542
+ def finalize_inline_style!(node, declarations, touched_properties)
543
+ sync_html_attributes!(node, declarations, touched_properties)
544
+ node["style"] = serialize_css_declarations(declarations)
557
545
  end
558
546
 
559
547
  # Sync HTML attributes from inlined CSS declarations.
@@ -569,15 +557,19 @@ module MjmlRb
569
557
  "text-align" => "align",
570
558
  "vertical-align" => "valign"
571
559
  }.freeze
560
+ HTML_ATTRIBUTE_SYNC_PROPERTIES = Set.new((%w[width height] + STYLE_TO_ATTRIBUTE.keys)).freeze
572
561
 
573
- def sync_html_attributes!(node, declarations)
562
+ def sync_html_attributes!(node, declarations, touched_properties = nil)
574
563
  tag = node.name.downcase
575
564
 
576
565
  # Sync width/height on TABLE, TD, TH, IMG
577
566
  if WIDTH_HEIGHT_ELEMENTS.include?(tag)
578
567
  %w[width height].each do |prop|
568
+ next if touched_properties && !touched_properties.include?(prop)
569
+
579
570
  css_value = declaration_value(declarations[prop])
580
571
  next if css_value.nil? || css_value.empty?
572
+ next if tag == "img" && prop == "width" && css_value.include?("%")
581
573
 
582
574
  # Convert CSS px values to plain numbers for HTML attributes;
583
575
  # keep other values (auto, %) as-is.
@@ -589,6 +581,8 @@ module MjmlRb
589
581
  # Sync style-to-attribute mappings on table elements
590
582
  if TABLE_ELEMENTS.include?(tag)
591
583
  STYLE_TO_ATTRIBUTE.each do |css_prop, html_attr|
584
+ next if touched_properties && !touched_properties.include?(css_prop)
585
+
592
586
  css_value = declaration_value(declarations[css_prop])
593
587
  next if css_value.nil? || css_value.empty?
594
588
 
@@ -609,26 +603,10 @@ module MjmlRb
609
603
  end
610
604
  end
611
605
 
612
- def syncable_background?(value)
613
- return true if value.nil? || value.empty?
614
-
615
- normalized = value.downcase
616
- !normalized.include?("url(") &&
617
- !normalized.include?("gradient(") &&
618
- !normalized.include?("/") &&
619
- !normalized.include?(" no-repeat") &&
620
- !normalized.include?(" repeat") &&
621
- !normalized.include?(" fixed") &&
622
- !normalized.include?(" scroll") &&
623
- !normalized.include?(" center") &&
624
- !normalized.include?(" top") &&
625
- !normalized.include?(" bottom") &&
626
- !normalized.include?(" left") &&
627
- !normalized.include?(" right")
628
- end
629
-
630
606
  def merge_css_declaration(existing, incoming)
631
607
  return incoming if existing.nil?
608
+ return incoming if incoming[:important] && !existing[:important]
609
+ return existing if existing[:source] == :inline
632
610
  return existing if existing[:important] && !incoming[:important]
633
611
 
634
612
  incoming
@@ -639,13 +617,49 @@ module MjmlRb
639
617
  end
640
618
 
641
619
  def serialize_css_declarations(declarations)
642
- declarations.map do |property, declaration|
643
- value = declaration[:value]
644
- value = "#{value} !important" if declaration[:important]
645
- "#{property}: #{value}"
620
+ ordered_css_declarations(declarations).map do |property, declaration|
621
+ "#{property}: #{declaration[:value]}"
646
622
  end.join("; ")
647
623
  end
648
624
 
625
+ SHORTHAND_LONGHAND_FAMILIES = {
626
+ "background" => /\Abackground-/,
627
+ "border" => /\Aborder(?:-(?!collapse|spacing)[a-z-]+)?\z/,
628
+ "border-radius" => /\Aborder-(?:top|bottom)-(?:left|right)-radius\z/,
629
+ "font" => /\Afont-/,
630
+ "list-style" => /\Alist-style-/,
631
+ "margin" => /\Amargin-(?:top|right|bottom|left)\z/,
632
+ "padding" => /\Apadding-(?:top|right|bottom|left)\z/
633
+ }.freeze
634
+
635
+ def ordered_css_declarations(declarations)
636
+ ordered = declarations.to_a
637
+
638
+ SHORTHAND_LONGHAND_FAMILIES.each do |shorthand, longhand_pattern|
639
+ family_indexes = ordered.each_index.select do |index|
640
+ property = ordered[index][0]
641
+ property == shorthand || property.match?(longhand_pattern)
642
+ end
643
+ next if family_indexes.length < 2
644
+
645
+ family_entries = family_indexes.map.with_index do |declaration_index, original_family_index|
646
+ [ordered[declaration_index], original_family_index]
647
+ end
648
+ next unless family_entries.any? { |((_, declaration), _)| declaration[:important] }
649
+ next unless family_entries.any? { |((_, declaration), _)| !declaration[:important] }
650
+
651
+ reordered_entries = family_entries.sort_by do |((_, declaration), original_family_index)|
652
+ [declaration[:important] ? 1 : 0, original_family_index]
653
+ end.map(&:first)
654
+
655
+ family_indexes.each_with_index do |declaration_index, reordered_index|
656
+ ordered[declaration_index] = reordered_entries[reordered_index]
657
+ end
658
+ end
659
+
660
+ ordered
661
+ end
662
+
649
663
  def append_component_head_styles(document, context)
650
664
  all_tags = collect_tag_names(document)
651
665
 
@@ -740,7 +754,7 @@ module MjmlRb
740
754
  if node.respond_to?(:children)
741
755
  node.children.map do |child|
742
756
  if child.text?
743
- escape_html(child.content.to_s)
757
+ serialize_text_content(escape_html(child.content.to_s))
744
758
  elsif child.comment?
745
759
  "<!--#{child.content}-->"
746
760
  else
@@ -759,7 +773,7 @@ module MjmlRb
759
773
  if node.respond_to?(:children)
760
774
  node.children.map do |child|
761
775
  if child.text?
762
- child.content.to_s
776
+ serialize_text_content(child.content.to_s)
763
777
  elsif child.comment?
764
778
  "<!--#{child.content}-->"
765
779
  else
@@ -771,15 +785,52 @@ module MjmlRb
771
785
  end
772
786
  end
773
787
 
788
+ def annotate_raw_html(content)
789
+ return content if content.nil? || content.empty? || !content.include?("<")
790
+ content.gsub(/<(?!\/|!)([A-Za-z][\w:-]*)(\s[^<>]*?)?(\s*\/?)>/) do
791
+ tag_name = Regexp.last_match(1)
792
+ attrs = Regexp.last_match(2).to_s
793
+ closing = Regexp.last_match(3).to_s
794
+ "<#{tag_name}#{attrs} data-mjml-raw=\"true\"#{closing}>"
795
+ end
796
+ end
797
+
798
+ def strip_internal_raw_markers(html)
799
+ html.gsub(/\sdata-mjml-raw=(['"])true\1/, "")
800
+ end
801
+
802
+ def preserve_raw_tag_spacing(html)
803
+ html.gsub(
804
+ /(<[^>]+data-mjml-raw=(['"])true\2[^>]*>)([ \t]+)(<[^\/!][^>]+data-mjml-raw=(['"])true\5[^>]*>)/
805
+ ) do
806
+ "#{Regexp.last_match(1)}#{encode_whitespace_entities(Regexp.last_match(3))}#{Regexp.last_match(4)}"
807
+ end
808
+ end
809
+
810
+ def encode_whitespace_entities(text)
811
+ text.to_s.gsub(" ", "&#32;").gsub("\t", "&#9;")
812
+ end
813
+
774
814
  def serialize_node(node)
775
815
  attrs = node.attributes.map { |k, v| %( #{k}="#{escape_attr(v)}") }.join
776
816
  return "<#{node.tag_name}#{attrs} />" if node.children.empty? && html_void_tag?(node.tag_name)
777
817
  return "<#{node.tag_name}#{attrs}></#{node.tag_name}>" if node.children.empty?
778
818
 
779
- inner = node.children.map { |child| child.text? ? child.content.to_s : serialize_node(child) }.join
819
+ inner = node.children.map { |child| child.text? ? serialize_text_content(child.content.to_s) : serialize_node(child) }.join
780
820
  "<#{node.tag_name}#{attrs}>#{inner}</#{node.tag_name}>"
781
821
  end
782
822
 
823
+ def serialize_text_content(text)
824
+ value = text.to_s
825
+ return value unless significant_whitespace_text?(value)
826
+
827
+ value.gsub(" ", "&#32;").gsub("\t", "&#9;")
828
+ end
829
+
830
+ def significant_whitespace_text?(text)
831
+ !text.empty? && text.strip.empty? && !text.match?(/[\r\n]/)
832
+ end
833
+
783
834
  def html_void_tag?(tag_name)
784
835
  HTML_VOID_TAGS.include?(tag_name.to_s.downcase)
785
836
  end
@@ -1,3 +1,3 @@
1
1
  module MjmlRb
2
- VERSION = "0.4.2".freeze
2
+ VERSION = "0.4.4".freeze
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mjml-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.2
4
+ version: 0.4.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrei Andriichuk