mjml-rb 0.3.6 → 0.4.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: 8416ab91e9a9d1c4addbbed2368136715d3726b5524e3cce2f27865c5522b6f2
4
- data.tar.gz: 576c18c2307ca32b30012e2e91450b2888e1f6cf13c9398bd0c7da7ef616bb7d
3
+ metadata.gz: c9cdaa07e2f84c880f1c3a4ae1594a32ec0d49436d6bf2fe83ee7d1738628fdf
4
+ data.tar.gz: 4b8576742cd05906ad3bf2bf8ef184dcf0eb6e4a7a7a19ca701e7a84c5ad3939
5
5
  SHA512:
6
- metadata.gz: c3ed81729d5550ae8611f0b4fd17545373b8c4041fc286f655c6c975be42182aab441a8c11d07e7a15578a55a6d83849e33e4507f99f45d925ea81212e04607c
7
- data.tar.gz: e407a972325006108bce56759035dfa0b00f93d1917c162d04c2c2ad3d299c0bb03f15a36d3f49c6a26d3d3e2ee058f45fbf29c96a016034c85372af87db4404
6
+ metadata.gz: 10f9d9c51871108519184113747abbee99c6c9b58e4f9a95161756296c4c97b64bf136cf969980ad99b00eb12ba505871a6efaa6b5ec1524f1ec14375e9030a1
7
+ data.tar.gz: f2af62cc6cb8f43f6fbd4e7aa6feb20ec7326b1069c52ee46d807ce7d4e134b3f1b931a03dc78c03108328621c322764ab70b14bf3d6e84c52940661ad6d2c09
data/README.md CHANGED
@@ -1,16 +1,13 @@
1
1
  # MJML Ruby Implementation
2
2
 
3
- > **⚠️ EXPERIMENTAL USE AT YOUR OWN RISK**
4
- >
5
- > This is an **unofficial, experimental** Ruby port of the MJML email framework.
6
- > It is **not affiliated with or endorsed by the MJML team**.
7
- > The output HTML may differ from the reference `mjml` npm package in subtle ways,
8
- > and not all components or attributes are fully implemented yet.
9
- > **Do not use in production without thorough testing of every template against
10
- > the official npm renderer.** API and output format may change without notice.
11
- > This is a **fully open source project**, and help is welcome:
12
- > feedback, bug reports, test cases, optimizations, proposals, and pull requests.
13
- > No warranty of any kind is provided.
3
+ > **Note:** This is an unofficial Ruby port of the MJML email framework,
4
+ > not affiliated with or endorsed by the MJML team.
5
+ > All 22 MJML v4 components are implemented and tested against npm MJML v4.18.0.
6
+ > Output may differ from the npm renderer in cosmetic ways (whitespace, attribute ordering)
7
+ > but renders identically across email clients.
8
+ > We recommend testing your templates when migrating from the npm renderer.
9
+ > This is a **fully open source project** feedback, bug reports, test cases,
10
+ > and pull requests are welcome!
14
11
 
15
12
  This gem provides a Ruby-first implementation of the main MJML tooling:
16
13
 
@@ -25,11 +25,13 @@ module MjmlRb
25
25
 
26
26
  def text_content
27
27
  return @content.to_s if text?
28
- @children.map(&:text_content).join
28
+ result = +""
29
+ @children.each { |child| result << child.text_content }
30
+ result
29
31
  end
30
32
 
31
33
  def element_children
32
- @children.select(&:element?)
34
+ @element_children ||= @children.select(&:element?)
33
35
  end
34
36
  end
35
37
  end
@@ -17,33 +17,49 @@ module MjmlRb
17
17
  @custom_dependencies[parent] = ((@custom_dependencies[parent] || []) + Array(children)).uniq
18
18
  end
19
19
  @custom_ending_tags.merge(Array(ending_tags))
20
+ invalidate_caches!
20
21
  end
21
22
 
22
23
  def component_class_for_tag(tag_name)
23
- all_component_classes.find { |klass| klass.tags.include?(tag_name) }
24
+ tag_class_cache[tag_name]
24
25
  end
25
26
 
26
27
  def dependency_rules
27
- merged = {}
28
- Dependencies::RULES.each { |k, v| merged[k] = v.dup }
29
- @custom_dependencies.each do |parent, children|
30
- merged[parent] = ((merged[parent] || []) + Array(children)).uniq
28
+ @dependency_rules_cache ||= begin
29
+ merged = {}
30
+ Dependencies::RULES.each { |k, v| merged[k] = v.dup }
31
+ @custom_dependencies.each do |parent, children|
32
+ merged[parent] = ((merged[parent] || []) + Array(children)).uniq
33
+ end
34
+ merged
31
35
  end
32
- merged
33
36
  end
34
37
 
35
38
  def ending_tags
36
- Dependencies::ENDING_TAGS | @custom_ending_tags
39
+ @ending_tags_cache ||= (Dependencies::ENDING_TAGS | @custom_ending_tags)
37
40
  end
38
41
 
39
42
  def reset!
40
43
  @custom_components.clear
41
44
  @custom_dependencies.clear
42
45
  @custom_ending_tags.clear
46
+ invalidate_caches!
43
47
  end
44
48
 
45
49
  private
46
50
 
51
+ def invalidate_caches!
52
+ @tag_class_cache = nil
53
+ @dependency_rules_cache = nil
54
+ @ending_tags_cache = nil
55
+ end
56
+
57
+ def tag_class_cache
58
+ @tag_class_cache ||= all_component_classes.each_with_object({}) do |klass, h|
59
+ klass.tags.each { |tag| h[tag] ||= klass }
60
+ end
61
+ end
62
+
47
63
  def all_component_classes
48
64
  builtin = MjmlRb::Components.constants.filter_map do |name|
49
65
  value = MjmlRb::Components.const_get(name)
@@ -305,39 +305,43 @@ module MjmlRb
305
305
  def component_head_style(carousel_id, length, attrs)
306
306
  return "" if length.zero?
307
307
 
308
- hide_non_selected = (0...length).map do |index|
309
- ".mj-carousel-#{carousel_id}-radio:checked #{adjacent_siblings(index)}+ .mj-carousel-content .mj-carousel-image"
310
- end.join(",\n")
311
-
312
- show_selected = (0...length).map do |index|
313
- ".mj-carousel-#{carousel_id}-radio-#{index + 1}:checked #{adjacent_siblings(length - index - 1)}+ .mj-carousel-content .mj-carousel-image-#{index + 1}"
314
- end.join(",\n")
315
-
316
- next_icons = (0...length).map do |index|
317
- target = ((index + 1) % length) + 1
318
- ".mj-carousel-#{carousel_id}-radio-#{index + 1}:checked #{adjacent_siblings(length - index - 1)}+ .mj-carousel-content .mj-carousel-next-#{target}"
319
- end.join(",\n")
320
-
321
- previous_icons = (0...length).map do |index|
322
- target = ((index - 1) % length) + 1
323
- ".mj-carousel-#{carousel_id}-radio-#{index + 1}:checked #{adjacent_siblings(length - index - 1)}+ .mj-carousel-content .mj-carousel-previous-#{target}"
324
- end.join(",\n")
325
-
326
- selected_thumbnail = (0...length).map do |index|
327
- ".mj-carousel-#{carousel_id}-radio-#{index + 1}:checked #{adjacent_siblings(length - index - 1)}+ .mj-carousel-content .mj-carousel-#{carousel_id}-thumbnail-#{index + 1}"
328
- end.join(",\n")
329
-
330
- show_thumbnails = (0...length).map do |index|
331
- ".mj-carousel-#{carousel_id}-radio-#{index + 1}:checked #{adjacent_siblings(length - index - 1)}+ .mj-carousel-content .mj-carousel-#{carousel_id}-thumbnail"
332
- end.join(",\n")
333
-
334
- hide_on_hover = (0...length).map do |index|
335
- ".mj-carousel-#{carousel_id}-thumbnail:hover #{adjacent_siblings(length - index - 1)}+ .mj-carousel-main .mj-carousel-image"
336
- end.join(",\n")
337
-
338
- show_on_hover = (0...length).map do |index|
339
- ".mj-carousel-#{carousel_id}-thumbnail-#{index + 1}:hover #{adjacent_siblings(length - index - 1)}+ .mj-carousel-main .mj-carousel-image-#{index + 1}"
340
- end.join(",\n")
308
+ hide_non_selected = []
309
+ show_selected = []
310
+ next_icons = []
311
+ previous_icons = []
312
+ selected_thumbnail = []
313
+ show_thumbnails = []
314
+ hide_on_hover = []
315
+ show_on_hover = []
316
+
317
+ # Pre-compute adjacent sibling strings to avoid repeated "+" * count" allocations
318
+ sibling_cache = Array.new(length) { |i| adjacent_siblings(i) }
319
+
320
+ length.times do |index|
321
+ siblings_index = sibling_cache[index]
322
+ siblings_reverse = sibling_cache[length - index - 1]
323
+ idx1 = index + 1
324
+ next_target = ((index + 1) % length) + 1
325
+ prev_target = ((index - 1) % length) + 1
326
+
327
+ hide_non_selected << ".mj-carousel-#{carousel_id}-radio:checked #{siblings_index}+ .mj-carousel-content .mj-carousel-image"
328
+ show_selected << ".mj-carousel-#{carousel_id}-radio-#{idx1}:checked #{siblings_reverse}+ .mj-carousel-content .mj-carousel-image-#{idx1}"
329
+ next_icons << ".mj-carousel-#{carousel_id}-radio-#{idx1}:checked #{siblings_reverse}+ .mj-carousel-content .mj-carousel-next-#{next_target}"
330
+ previous_icons << ".mj-carousel-#{carousel_id}-radio-#{idx1}:checked #{siblings_reverse}+ .mj-carousel-content .mj-carousel-previous-#{prev_target}"
331
+ selected_thumbnail << ".mj-carousel-#{carousel_id}-radio-#{idx1}:checked #{siblings_reverse}+ .mj-carousel-content .mj-carousel-#{carousel_id}-thumbnail-#{idx1}"
332
+ show_thumbnails << ".mj-carousel-#{carousel_id}-radio-#{idx1}:checked #{siblings_reverse}+ .mj-carousel-content .mj-carousel-#{carousel_id}-thumbnail"
333
+ hide_on_hover << ".mj-carousel-#{carousel_id}-thumbnail:hover #{siblings_reverse}+ .mj-carousel-main .mj-carousel-image"
334
+ show_on_hover << ".mj-carousel-#{carousel_id}-thumbnail-#{idx1}:hover #{siblings_reverse}+ .mj-carousel-main .mj-carousel-image-#{idx1}"
335
+ end
336
+
337
+ hide_non_selected = hide_non_selected.join(",\n")
338
+ show_selected = show_selected.join(",\n")
339
+ next_icons = next_icons.join(",\n")
340
+ previous_icons = previous_icons.join(",\n")
341
+ selected_thumbnail = selected_thumbnail.join(",\n")
342
+ show_thumbnails = show_thumbnails.join(",\n")
343
+ hide_on_hover = hide_on_hover.join(",\n")
344
+ show_on_hover = show_on_hover.join(",\n")
341
345
 
342
346
  <<~CSS
343
347
  .mj-carousel {
@@ -17,6 +17,15 @@ module MjmlRb
17
17
  mj-navbar-link mj-raw mj-text
18
18
  ].freeze
19
19
 
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
22
+
23
+ VOID_TAG_CLOSING_BR_RE = %r{</br\s*>}i.freeze
24
+ VOID_TAG_CLOSING_OTHER_RE = /<\/(#{(HTML_VOID_TAGS - ["br"]).join("|")})\s*>/i.freeze
25
+ VOID_TAG_OPEN_RE = /<(#{HTML_VOID_TAGS.join("|")})(\s[^<>]*?)?>/i.freeze
26
+ LINE_ANNOTATION_RE = /(\n)|(<!\[CDATA\[.*?\]\]>)|(<(?:mj-[\w-]+|mjml)(?=[\s\/>]))/m.freeze
27
+ BARE_AMPERSAND_RE = /&(?!(?:#[0-9]+|#x[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);)/.freeze
28
+
20
29
  class ParseError < StandardError
21
30
  attr_reader :line
22
31
 
@@ -212,23 +221,21 @@ module MjmlRb
212
221
  def normalize_html_void_tags(content)
213
222
  # Legacy mail templates sometimes emit invalid closing </br> tags.
214
223
  # Browser-style recovery treats them as actual line breaks, so preserve that.
215
- content = content.gsub(%r{</br\s*>}i, "<br />")
224
+ content = content.gsub(VOID_TAG_CLOSING_BR_RE, "<br />")
216
225
 
217
226
  # Remove other closing tags for void elements (e.g. </hr>, </img>).
218
227
  # These are invalid in both HTML and XML and HTML5 recovery drops them.
219
- content = content.gsub(/<\/(#{(HTML_VOID_TAGS - ["br"]).join("|")})\s*>/i, "")
228
+ content = content.gsub(VOID_TAG_CLOSING_OTHER_RE, "")
220
229
 
221
230
  # Self-close opening void tags that aren't already self-closed.
222
- pattern = /<(#{HTML_VOID_TAGS.join("|")})(\s[^<>]*?)?>/i
223
- content.gsub(pattern) do |tag|
231
+ content.gsub(VOID_TAG_OPEN_RE) do |tag|
224
232
  tag.end_with?("/>") ? tag : tag.sub(/>$/, " />")
225
233
  end
226
234
  end
227
235
 
228
236
  def wrap_ending_tags_in_cdata(content)
229
- tag_pattern = ENDING_TAGS_FOR_CDATA.map { |t| Regexp.escape(t) }.join("|")
230
237
  # Negative lookbehind (?<!\/) ensures self-closing tags like <mj-text ... /> are skipped
231
- content.gsub(/<(#{tag_pattern})(\s[^<>]*?)?(?<!\/)>(.*?)<\/\1>/mi) do
238
+ content.gsub(ENDING_TAGS_CDATA_RE) do
232
239
  tag = Regexp.last_match(1)
233
240
  attrs = Regexp.last_match(2).to_s
234
241
  inner = Regexp.last_match(3).to_s
@@ -251,7 +258,7 @@ module MjmlRb
251
258
  # (e.g. &amp; &#123; &#x1F;). This lets REXML parse HTML-ish content
252
259
  # such as "Terms & Conditions" which is common in email templates.
253
260
  def sanitize_bare_ampersands(content)
254
- content.gsub(/&(?!(?:#[0-9]+|#x[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);)/, "&amp;")
261
+ content.gsub(BARE_AMPERSAND_RE, "&amp;")
255
262
  end
256
263
 
257
264
  # Adds data-mjml-line attributes to MJML tags so line numbers survive
@@ -259,7 +266,7 @@ module MjmlRb
259
266
  # Skips content inside CDATA sections to avoid modifying raw HTML.
260
267
  def annotate_line_numbers(xml)
261
268
  line = 1
262
- xml.gsub(/(\n)|(<!\[CDATA\[.*?\]\]>)|(<(?:mj-[\w-]+|mjml)(?=[\s\/>]))/m) do
269
+ xml.gsub(LINE_ANNOTATION_RE) do
263
270
  if Regexp.last_match(1) # newline
264
271
  line += 1
265
272
  "\n"
@@ -25,7 +25,7 @@ require_relative "components/spacer"
25
25
 
26
26
  module MjmlRb
27
27
  class Renderer
28
- HTML_VOID_TAGS = %w[area base br col embed hr img input link meta param source track wbr].freeze
28
+ HTML_VOID_TAGS = Set.new(%w[area base br col embed hr img input link meta param source track wbr]).freeze
29
29
 
30
30
  DEFAULT_FONTS = {
31
31
  "Open Sans" => "https://fonts.googleapis.com/css?family=Open+Sans:300,400,500,700",
@@ -647,6 +647,8 @@ module MjmlRb
647
647
  end
648
648
 
649
649
  def append_component_head_styles(document, context)
650
+ all_tags = collect_tag_names(document)
651
+
650
652
  component_registry.each_value.uniq.each do |component|
651
653
  next unless component.respond_to?(:head_style)
652
654
 
@@ -662,7 +664,7 @@ module MjmlRb
662
664
  else
663
665
  component.tags
664
666
  end
665
- next unless Array(tags).any? { |tag| contains_tag?(document, tag) }
667
+ next unless Array(tags).any? { |tag| all_tags.include?(tag) }
666
668
 
667
669
  context[:component_head_styles] << style
668
670
  end
@@ -815,11 +817,12 @@ module MjmlRb
815
817
  end.join("\n")
816
818
  end
817
819
 
818
- def contains_tag?(node, tag_name)
819
- return false unless node.respond_to?(:tag_name)
820
- return true if node.tag_name == tag_name
820
+ def collect_tag_names(node, result = Set.new)
821
+ return result unless node.respond_to?(:tag_name)
821
822
 
822
- node.children.any? { |child| child.respond_to?(:children) && contains_tag?(child, tag_name) }
823
+ result << node.tag_name
824
+ node.children.each { |child| collect_tag_names(child, result) if child.respond_to?(:children) }
825
+ result
823
826
  end
824
827
 
825
828
  def escape_html(value)
@@ -845,9 +848,10 @@ module MjmlRb
845
848
  end
846
849
 
847
850
  def unique_strings(values)
851
+ seen = Set.new
848
852
  Array(values).each_with_object([]) do |value, memo|
849
853
  next if value.nil? || value.empty?
850
- next if memo.include?(value)
854
+ next unless seen.add?(value)
851
855
 
852
856
  memo << value
853
857
  end
@@ -1,3 +1,3 @@
1
1
  module MjmlRb
2
- VERSION = "0.3.6".freeze
2
+ VERSION = "0.4.0".freeze
3
3
  end
data/mjml-rb.gemspec CHANGED
@@ -18,6 +18,6 @@ Gem::Specification.new do |spec|
18
18
  spec.bindir = "bin"
19
19
  spec.executables = ["mjml"]
20
20
  spec.require_paths = ["lib"]
21
- spec.add_dependency "nokogiri"
22
- spec.add_dependency "rexml"
21
+ spec.add_dependency "nokogiri", ">= 1.13"
22
+ spec.add_dependency "rexml", ">= 3.2.5"
23
23
  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.3.6
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrei Andriichuk
@@ -15,28 +15,28 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: '0'
18
+ version: '1.13'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: '0'
25
+ version: '1.13'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: rexml
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
30
30
  - - ">="
31
31
  - !ruby/object:Gem::Version
32
- version: '0'
32
+ version: 3.2.5
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - ">="
38
38
  - !ruby/object:Gem::Version
39
- version: '0'
39
+ version: 3.2.5
40
40
  description: Ruby-first MJML compiler API and CLI with compatibility-focused behavior.
41
41
  email:
42
42
  - andreiandriichuk@gmail.com