mjml-rb 0.5.1 → 0.5.2

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: 46b2a8437d579f58fe7a39ef2355935c9d6cf728fb42a861b1fe84ae6c0d1a85
4
- data.tar.gz: 28897aaecd04098a2b4757d0fbf7c22a7f16ff9c3894c71f0346487aedbe87ef
3
+ metadata.gz: ffb73c167fce50a47b0513aa071f478625bd28de94a47904980746905f731e8e
4
+ data.tar.gz: 67977b5a7c57afb011f6e1f7f1b1de4d1872cca1847eb0f61c6413a60b93c919
5
5
  SHA512:
6
- metadata.gz: 5eb50165e738fe194a88ca08e6b51c97bee21eeaa8935527c32aa07c67353a5ce54557dd033577c6c43f5fbf8e11506e0de45b14059a7198e8851a170d590926
7
- data.tar.gz: 3232dc21d03d6ab082dffff4429578bab6c3e12bd49fb440be3272c5b3f54880089bd5dd87c80a2acc303f02b3673916c3d4767080593d8fb7cad69bb5319e9e
6
+ metadata.gz: 1eb9c9b43804484c8c0a5e89e04c83f85bcd0699ada32f6e77b566341795372386bac5fc41766bdc98d6f2844be69e6c456b461c9649971f42a4dbc97016226e
7
+ data.tar.gz: d49c215799a867e70ced384893081933c596ea65cc2e325782110c796f8f690b410f5a24cb1858e91c3699d635893a3ce273154cd12900575331899c108af54b
data/README.md CHANGED
@@ -97,7 +97,7 @@ Supports Slim and Haml via `config.mjml_rb.rails_template_language = :slim`. See
97
97
  MJML string → Parser → AST → Validator → Renderer → HTML
98
98
  ```
99
99
 
100
- 1. **Parser** — normalizes source, expands `mj-include`, builds `AstNode` tree
100
+ 1. **Parser** — normalizes source, expands `mj-include`, produces a Nokogiri XML node tree
101
101
  2. **Validator** — checks structure, hierarchy, and attribute types
102
102
  3. **Renderer** — resolves head metadata, applies defaults, emits responsive HTML
103
103
 
@@ -23,16 +23,16 @@ module MjmlRb
23
23
  attributes_node.element_children.each do |child|
24
24
  case child.tag_name
25
25
  when "mj-all"
26
- context[:global_defaults].merge!(child.attributes)
26
+ context[:global_defaults].merge!(node_string_attributes(child))
27
27
  when "mj-class"
28
- name = child.attributes["name"]
28
+ name = child["name"]
29
29
  next unless name
30
30
 
31
31
  context[:classes][name] ||= {}
32
- context[:classes][name].merge!(child.attributes.reject { |key, _| key == "name" })
32
+ context[:classes][name].merge!(node_string_attributes(child).reject { |key, _| key == "name" })
33
33
 
34
34
  defaults = child.element_children.each_with_object({}) do |class_child, memo|
35
- memo[class_child.tag_name] = class_child.attributes
35
+ memo[class_child.tag_name] = node_string_attributes(class_child)
36
36
  end
37
37
  next if defaults.empty?
38
38
 
@@ -40,7 +40,7 @@ module MjmlRb
40
40
  context[:classes_default][name].merge!(defaults)
41
41
  else
42
42
  context[:tag_defaults][child.tag_name] ||= {}
43
- context[:tag_defaults][child.tag_name].merge!(child.attributes)
43
+ context[:tag_defaults][child.tag_name].merge!(node_string_attributes(child))
44
44
  end
45
45
  end
46
46
  end
@@ -76,6 +76,10 @@ module MjmlRb
76
76
  def html_attrs(hash)
77
77
  renderer.send(:html_attrs, hash)
78
78
  end
79
+
80
+ def node_string_attributes(node)
81
+ renderer.send(:node_string_attributes, node)
82
+ end
79
83
  end
80
84
  end
81
85
  end
@@ -18,7 +18,7 @@ module MjmlRb
18
18
  end
19
19
 
20
20
  def handle_head(node, context)
21
- width = node.attributes["width"].to_s.strip
21
+ width = node["width"].to_s.strip
22
22
  context[:breakpoint] = width unless width.empty?
23
23
  end
24
24
  end
@@ -42,14 +42,14 @@ module MjmlRb
42
42
  context[:preview] = raw_inner(node).strip
43
43
  when "mj-style"
44
44
  css = raw_inner(node)
45
- if node.attributes["inline"] == "inline"
45
+ if node["inline"] == "inline"
46
46
  context[:inline_styles] << css
47
47
  else
48
48
  context[:user_styles] << css
49
49
  end
50
50
  when "mj-font"
51
- name = node.attributes["name"]
52
- href = node.attributes["href"]
51
+ name = node["name"]
52
+ href = node["href"]
53
53
  context[:fonts][name] = href if name && href
54
54
  end
55
55
  end
@@ -32,16 +32,16 @@ module MjmlRb
32
32
  node.element_children.each do |selector|
33
33
  next unless selector.tag_name == "mj-selector"
34
34
 
35
- path = selector.attributes["path"].to_s.strip
35
+ path = selector["path"].to_s.strip
36
36
  next if path.empty?
37
37
 
38
38
  custom_attrs = selector.element_children.each_with_object({}) do |child, memo|
39
39
  next unless child.tag_name == "mj-html-attribute"
40
40
 
41
- name = child.attributes["name"].to_s.strip
41
+ name = child["name"].to_s.strip
42
42
  next if name.empty?
43
43
 
44
- memo[name] = child.text_content
44
+ memo[name] = child.content
45
45
  end
46
46
  next if custom_attrs.empty?
47
47
 
@@ -237,7 +237,7 @@ module MjmlRb
237
237
 
238
238
  def render_social_element(node, attrs)
239
239
  a = attrs # already merged with defaults by caller
240
- net_name = node.attributes["name"]
240
+ net_name = node["name"]
241
241
  network = SOCIAL_NETWORKS[net_name] || {}
242
242
 
243
243
  # Resolve href: if network has a share-url, substitute [[URL]] with the raw href
@@ -325,7 +325,7 @@ module MjmlRb
325
325
  HTML
326
326
 
327
327
  # Content cell (text)
328
- content = node.text_content.strip
328
+ content = node.content.strip
329
329
  content_cell = if content.empty?
330
330
  ""
331
331
  else
@@ -130,7 +130,7 @@ module MjmlRb
130
130
  end
131
131
 
132
132
  def normalize_table_node_attributes(node)
133
- attrs = node.attributes.dup
133
+ attrs = node_string_attributes(node).dup
134
134
  style_map = parse_style_map(attrs["style"])
135
135
 
136
136
  if %w[table td th a].include?(node.tag_name)
@@ -0,0 +1,15 @@
1
+ require "nokogiri"
2
+
3
+ module MjmlRb
4
+ module NodeCompat
5
+ def tag_name
6
+ name
7
+ end
8
+
9
+ def file
10
+ self["data-mjml-file"]
11
+ end
12
+ end
13
+ end
14
+
15
+ Nokogiri::XML::Node.prepend(MjmlRb::NodeCompat)
@@ -1,6 +1,6 @@
1
1
  require "nokogiri"
2
2
 
3
- require_relative "ast_node"
3
+ require_relative "node_compat"
4
4
 
5
5
  module MjmlRb
6
6
  class Parser
@@ -57,7 +57,8 @@ module MjmlRb
57
57
  xml = replace_html_entities(xml)
58
58
  doc = Nokogiri::XML(xml) { |config| config.strict }
59
59
  normalize_root_head_elements(doc)
60
- element_to_ast(doc.root, keep_comments: opts[:keep_comments])
60
+ prepare_node(doc.root, keep_comments: opts[:keep_comments])
61
+ doc.root
61
62
  rescue Nokogiri::XML::SyntaxError => e
62
63
  raise ParseError.new("XML parse error: #{e.message}")
63
64
  end
@@ -402,55 +403,37 @@ module MjmlRb
402
403
  raise Errno::ENOENT, include_path
403
404
  end
404
405
 
405
- def element_to_ast(element, keep_comments:)
406
+ def prepare_node(element, keep_comments:)
406
407
  raise ParseError, "Missing XML root element" unless element
407
408
 
408
- # Extract metadata annotations (added by annotate_include_source)
409
- # and strip them from the public attributes hash.
410
- # Line numbers come from Nokogiri's native node.line.
411
- meta_line = element.line
412
- meta_file = element["data-mjml-file"]
413
- attrs = {}
414
- element.attributes.each do |name, attr|
415
- attrs[name] = attr.value unless name.start_with?("data-mjml-")
409
+ # Mark non-mj elements for raw HTML handling
410
+ unless element.name.start_with?("mj-") || element.name == "mjml"
411
+ element["data-mjml-raw"] = "true"
416
412
  end
417
- attrs["data-mjml-raw"] = "true" unless element.name.start_with?("mj-") || element.name == "mjml"
418
413
 
419
- # For ending-tag elements whose content was wrapped in CDATA, store
420
- # the raw HTML directly as content instead of parsing structurally.
414
+ # For ending-tag elements whose content was wrapped in CDATA,
415
+ # mark them so raw_inner knows to extract content directly.
421
416
  if ENDING_TAGS_FOR_CDATA.include?(element.name)
422
- raw_content = element.children.select { |c| c.cdata? || c.text? }.map(&:content).join
423
- return AstNode.new(
424
- tag_name: element.name,
425
- attributes: attrs,
426
- children: [],
427
- content: raw_content.empty? ? nil : raw_content,
428
- line: meta_line,
429
- file: meta_file
430
- )
417
+ element["data-mjml-ending-tag"] = "true"
418
+ return
431
419
  end
432
420
 
433
- children = element.children.each_with_object([]) do |child, memo|
421
+ # Strip ignorable whitespace text nodes, unwanted comments,
422
+ # and recurse into element children.
423
+ element.children.to_a.each do |child|
434
424
  if child.element?
435
- memo << element_to_ast(child, keep_comments: keep_comments)
425
+ prepare_node(child, keep_comments: keep_comments)
436
426
  elsif child.text? || child.cdata?
437
427
  text = child.content
438
- next if text.empty?
439
- next if text.strip.empty? && ignorable_whitespace_text?(text, parent_element_name: element.name)
440
-
441
- memo << AstNode.new(tag_name: "#text", content: text)
428
+ if text.empty? || (text.strip.empty? && ignorable_whitespace_text?(text, parent_element_name: element.name))
429
+ child.remove
430
+ end
442
431
  elsif child.comment?
443
- memo << AstNode.new(tag_name: "#comment", content: child.content) if keep_comments
432
+ child.remove unless keep_comments
433
+ else
434
+ child.remove
444
435
  end
445
436
  end
446
-
447
- AstNode.new(
448
- tag_name: element.name,
449
- attributes: attrs,
450
- children: children,
451
- line: meta_line,
452
- file: meta_file
453
- )
454
437
  end
455
438
 
456
439
  # Lenient XML parse used during include expansion and intermediate steps.
@@ -70,9 +70,9 @@ module MjmlRb
70
70
 
71
71
  context = build_context(head, options)
72
72
  context[:before_doctype] = root_file_start_raw(document)
73
- context[:lang] = options[:lang] || document.attributes["lang"] || "und"
74
- context[:dir] = options[:dir] || document.attributes["dir"] || "auto"
75
- context[:force_owa_desktop] = document.attributes["owa"] == "desktop"
73
+ context[:lang] = options[:lang] || document["lang"] || "und"
74
+ context[:dir] = options[:dir] || document["dir"] || "auto"
75
+ context[:force_owa_desktop] = document["owa"] == "desktop"
76
76
  context[:printer_support] = options[:printer_support] || options[:printerSupport]
77
77
  context[:column_widths] = {}
78
78
  append_component_head_styles(document, context)
@@ -225,7 +225,7 @@ module MjmlRb
225
225
  return [100] if total == 0
226
226
 
227
227
  widths = columns.map do |col|
228
- w = col.attributes["width"]
228
+ w = col["width"]
229
229
  if w && w.to_s =~ /(\d+(?:\.\d+)?)\s*%/
230
230
  $1.to_f
231
231
  elsif w && w.to_s =~ /(\d+(?:\.\d+)?)\s*px/
@@ -668,7 +668,7 @@ module MjmlRb
668
668
  attrs.merge!(context[:global_defaults] || {})
669
669
  attrs.merge!(context[:tag_defaults][node.tag_name] || {})
670
670
 
671
- node_classes = node.attributes["mj-class"].to_s.split(/\s+/).reject(&:empty?)
671
+ node_classes = node["mj-class"].to_s.split(/\s+/).reject(&:empty?)
672
672
  class_attrs = node_classes.each_with_object({}) do |klass, memo|
673
673
  mj_class_attrs = (context[:classes] || {})[klass] || {}
674
674
  if memo["css-class"] && mj_class_attrs["css-class"]
@@ -683,33 +683,29 @@ module MjmlRb
683
683
  attrs.merge!(((context[:classes_default] || {})[klass] || {})[node.tag_name] || {})
684
684
  end
685
685
 
686
- attrs.merge!(node.attributes)
686
+ attrs.merge!(node_string_attributes(node))
687
687
  attrs
688
688
  end
689
689
 
690
690
  def html_inner(node)
691
- if node.respond_to?(:inner_html)
692
- node.inner_html
693
- else
694
- escape_html(node.text_content)
695
- end
691
+ node.inner_html
696
692
  end
697
693
 
698
694
  def raw_inner(node)
699
- # For ending-tag nodes whose content was preserved as raw HTML by the parser
700
- return node.content if node.element? && node.content
701
-
702
- if node.respond_to?(:children)
703
- node.children.map do |child|
704
- if child.text?
705
- child.content.to_s
706
- else
707
- child.to_html
708
- end
709
- end.join
710
- else
711
- node.text_content
695
+ # For ending-tag nodes whose content was preserved as raw HTML by the parser.
696
+ # The parser marks these with data-mjml-ending-tag; their children are CDATA
697
+ # nodes containing the raw HTML content.
698
+ if node.element? && node["data-mjml-ending-tag"]
699
+ return node.children.select { |c| c.cdata? || c.text? }.map(&:content).join
712
700
  end
701
+
702
+ node.children.map do |child|
703
+ if child.text?
704
+ child.content.to_s
705
+ else
706
+ child.to_html
707
+ end
708
+ end.join
713
709
  end
714
710
 
715
711
  def annotate_raw_html(content)
@@ -755,7 +751,7 @@ module MjmlRb
755
751
 
756
752
  def with_inherited_mj_class(context, node)
757
753
  previous = context[:inherited_mj_class]
758
- current = node.attributes["mj-class"]
754
+ current = node["mj-class"]
759
755
  context[:inherited_mj_class] = (current && !current.empty?) ? current : previous
760
756
  yield
761
757
  ensure
@@ -765,7 +761,7 @@ module MjmlRb
765
761
  def root_file_start_raw(document)
766
762
  document.element_children.filter_map do |child|
767
763
  next unless child.tag_name == "mj-raw"
768
- next unless child.attributes["position"] == "file-start"
764
+ next unless child["position"] == "file-start"
769
765
 
770
766
  raw_inner(child)
771
767
  end.join("\n")
@@ -779,6 +775,20 @@ module MjmlRb
779
775
  result
780
776
  end
781
777
 
778
+ # Internal-only attributes set by the parser for metadata tracking.
779
+ # These are excluded from the public attributes hash; data-mjml-raw
780
+ # is intentionally kept because it is used by the rendering pipeline.
781
+ INTERNAL_ATTRIBUTES = %w[data-mjml-file data-mjml-ending-tag].freeze
782
+
783
+ def node_string_attributes(node)
784
+ result = {}
785
+ node.attributes.each do |name, attr|
786
+ next if INTERNAL_ATTRIBUTES.include?(name)
787
+ result[name] = attr.respond_to?(:value) ? attr.value : attr.to_s
788
+ end
789
+ result
790
+ end
791
+
782
792
  def escape_html(value)
783
793
  CGI.escapeHTML(value.to_s)
784
794
  end
@@ -16,7 +16,7 @@ module MjmlRb
16
16
  end
17
17
 
18
18
  def validate(mjml_or_ast, options = {})
19
- root = mjml_or_ast.is_a?(AstNode) ? mjml_or_ast : parse_ast(mjml_or_ast, options)
19
+ root = mjml_or_ast.is_a?(Nokogiri::XML::Node) ? mjml_or_ast : parse_ast(mjml_or_ast, options)
20
20
  unless root&.tag_name == "mjml"
21
21
  return { errors: [error("Root element must be <mjml>", tag_name: root&.tag_name)], warnings: [] }
22
22
  end
@@ -97,7 +97,7 @@ module MjmlRb
97
97
  def validate_required_attributes(node, errors)
98
98
  required = REQUIRED_BY_TAG[node.tag_name] || []
99
99
  required.each do |attr|
100
- next if node.attributes.key?(attr)
100
+ next if node.has_attribute?(attr)
101
101
 
102
102
  errors << error("Attribute `#{attr}` is required for <#{node.tag_name}>",
103
103
  tag_name: node.tag_name, line: node.line, file: node.file)
@@ -108,7 +108,7 @@ module MjmlRb
108
108
  allowed_attributes = allowed_attributes_for(node.tag_name)
109
109
  return if allowed_attributes.empty?
110
110
 
111
- node.attributes.each_key do |attribute_name|
111
+ string_attrs(node).each_key do |attribute_name|
112
112
  next if allowed_attributes.key?(attribute_name)
113
113
  next if GLOBAL_ALLOWED_ATTRIBUTES.include?(attribute_name)
114
114
 
@@ -121,7 +121,7 @@ module MjmlRb
121
121
  allowed_attributes = allowed_attributes_for(node.tag_name)
122
122
  return if allowed_attributes.empty?
123
123
 
124
- node.attributes.each do |attribute_name, attribute_value|
124
+ string_attrs(node).each do |attribute_name, attribute_value|
125
125
  next if GLOBAL_ALLOWED_ATTRIBUTES.include?(attribute_name)
126
126
 
127
127
  expected_type = allowed_attributes[attribute_name]
@@ -218,6 +218,17 @@ module MjmlRb
218
218
  end
219
219
  end
220
220
 
221
+ INTERNAL_ATTRIBUTES = %w[data-mjml-file data-mjml-ending-tag data-mjml-raw].freeze
222
+
223
+ def string_attrs(node)
224
+ result = {}
225
+ node.attributes.each do |name, attr|
226
+ next if INTERNAL_ATTRIBUTES.include?(name)
227
+ result[name] = attr.respond_to?(:value) ? attr.value : attr.to_s
228
+ end
229
+ result
230
+ end
231
+
221
232
  def error(message, line: nil, tag_name: nil, file: nil)
222
233
  location = [
223
234
  ("line #{line}" if line),
@@ -1,3 +1,3 @@
1
1
  module MjmlRb
2
- VERSION = "0.5.1".freeze
2
+ VERSION = "0.5.2".freeze
3
3
  end
data/lib/mjml-rb.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  require_relative "mjml-rb/version"
2
2
  require_relative "mjml-rb/result"
3
- require_relative "mjml-rb/ast_node"
3
+ require_relative "mjml-rb/node_compat"
4
4
  require_relative "mjml-rb/dependencies"
5
5
  require_relative "mjml-rb/component_registry"
6
6
  require_relative "mjml-rb/config_file"
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.5.1
4
+ version: 0.5.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrei Andriichuk
@@ -51,7 +51,6 @@ files:
51
51
  - Rakefile
52
52
  - bin/mjml
53
53
  - lib/mjml-rb.rb
54
- - lib/mjml-rb/ast_node.rb
55
54
  - lib/mjml-rb/cli.rb
56
55
  - lib/mjml-rb/compiler.rb
57
56
  - lib/mjml-rb/component_registry.rb
@@ -80,6 +79,7 @@ files:
80
79
  - lib/mjml-rb/components/text.rb
81
80
  - lib/mjml-rb/config_file.rb
82
81
  - lib/mjml-rb/dependencies.rb
82
+ - lib/mjml-rb/node_compat.rb
83
83
  - lib/mjml-rb/parser.rb
84
84
  - lib/mjml-rb/railtie.rb
85
85
  - lib/mjml-rb/renderer.rb
@@ -1,37 +0,0 @@
1
- module MjmlRb
2
- class AstNode
3
- attr_reader :tag_name, :attributes, :children, :content, :line, :file
4
-
5
- def initialize(tag_name:, attributes: {}, children: [], content: nil, line: nil, file: nil)
6
- @tag_name = tag_name.to_s
7
- @attributes = attributes.transform_keys(&:to_s)
8
- @children = Array(children)
9
- @content = content
10
- @line = line
11
- @file = file
12
- end
13
-
14
- def text?
15
- @tag_name == "#text"
16
- end
17
-
18
- def comment?
19
- @tag_name == "#comment"
20
- end
21
-
22
- def element?
23
- !text? && !comment?
24
- end
25
-
26
- def text_content
27
- return @content.to_s if text?
28
- result = +""
29
- @children.each { |child| result << child.text_content }
30
- result
31
- end
32
-
33
- def element_children
34
- @element_children ||= @children.select(&:element?)
35
- end
36
- end
37
- end