emjay 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.
Files changed (59) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +158 -0
  4. data/lib/emjay/body_component.rb +142 -0
  5. data/lib/emjay/component.rb +61 -0
  6. data/lib/emjay/components/body/mj_accordion.rb +99 -0
  7. data/lib/emjay/components/body/mj_accordion_element.rb +127 -0
  8. data/lib/emjay/components/body/mj_accordion_text.rb +123 -0
  9. data/lib/emjay/components/body/mj_accordion_title.rb +171 -0
  10. data/lib/emjay/components/body/mj_body.rb +70 -0
  11. data/lib/emjay/components/body/mj_button.rb +198 -0
  12. data/lib/emjay/components/body/mj_carousel.rb +410 -0
  13. data/lib/emjay/components/body/mj_carousel_image.rb +188 -0
  14. data/lib/emjay/components/body/mj_column.rb +287 -0
  15. data/lib/emjay/components/body/mj_divider.rb +120 -0
  16. data/lib/emjay/components/body/mj_group.rb +196 -0
  17. data/lib/emjay/components/body/mj_hero.rb +382 -0
  18. data/lib/emjay/components/body/mj_image.rb +188 -0
  19. data/lib/emjay/components/body/mj_navbar.rb +187 -0
  20. data/lib/emjay/components/body/mj_navbar_link.rb +129 -0
  21. data/lib/emjay/components/body/mj_raw.rb +34 -0
  22. data/lib/emjay/components/body/mj_section.rb +442 -0
  23. data/lib/emjay/components/body/mj_social.rb +174 -0
  24. data/lib/emjay/components/body/mj_social_element.rb +272 -0
  25. data/lib/emjay/components/body/mj_spacer.rb +57 -0
  26. data/lib/emjay/components/body/mj_table.rb +113 -0
  27. data/lib/emjay/components/body/mj_text.rb +100 -0
  28. data/lib/emjay/components/body/mj_wrapper.rb +56 -0
  29. data/lib/emjay/components/head/mj_attributes.rb +38 -0
  30. data/lib/emjay/components/head/mj_breakpoint.rb +28 -0
  31. data/lib/emjay/components/head/mj_font.rb +24 -0
  32. data/lib/emjay/components/head/mj_head.rb +20 -0
  33. data/lib/emjay/components/head/mj_html_attributes.rb +33 -0
  34. data/lib/emjay/components/head/mj_preview.rb +24 -0
  35. data/lib/emjay/components/head/mj_style.rb +34 -0
  36. data/lib/emjay/components/head/mj_title.rb +24 -0
  37. data/lib/emjay/global_data.rb +64 -0
  38. data/lib/emjay/head_component.rb +37 -0
  39. data/lib/emjay/helpers/conditional_tag.rb +24 -0
  40. data/lib/emjay/helpers/fonts.rb +34 -0
  41. data/lib/emjay/helpers/gen_random_hex_string.rb +9 -0
  42. data/lib/emjay/helpers/make_lower_breakpoint.rb +17 -0
  43. data/lib/emjay/helpers/media_queries.rb +47 -0
  44. data/lib/emjay/helpers/merge_outlook_conditionals.rb +11 -0
  45. data/lib/emjay/helpers/minify_outlook_conditionals.rb +18 -0
  46. data/lib/emjay/helpers/shorthand_parser.rb +33 -0
  47. data/lib/emjay/helpers/styles.rb +34 -0
  48. data/lib/emjay/helpers/suffix_css_classes.rb +12 -0
  49. data/lib/emjay/helpers/width_parser.rb +26 -0
  50. data/lib/emjay/rails/mail_interceptor.rb +37 -0
  51. data/lib/emjay/rails/template_handler.rb +16 -0
  52. data/lib/emjay/railtie.rb +21 -0
  53. data/lib/emjay/registry.rb +19 -0
  54. data/lib/emjay/renderer.rb +302 -0
  55. data/lib/emjay/skeleton.rb +80 -0
  56. data/lib/emjay/version.rb +5 -0
  57. data/lib/emjay.rb +66 -0
  58. data/llms.txt +130 -0
  59. metadata +129 -0
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../head_component"
4
+ require_relative "../../registry"
5
+
6
+ module Emjay
7
+ module Components
8
+ class MjHead < HeadComponent
9
+ def self.component_name
10
+ "mj-head"
11
+ end
12
+
13
+ def handler
14
+ handler_children
15
+ end
16
+ end
17
+ end
18
+
19
+ Registry.register(Components::MjHead)
20
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../head_component"
4
+ require_relative "../../registry"
5
+
6
+ module Emjay
7
+ module Components
8
+ class MjHtmlAttributes < HeadComponent
9
+ def self.component_name
10
+ "mj-html-attributes"
11
+ end
12
+
13
+ def handler
14
+ add = @context[:add]
15
+ children = @props[:children] || []
16
+
17
+ children.select { |c| c[:tag_name] == "mj-selector" }.each do |selector|
18
+ path = (selector[:attributes] || {})["path"]
19
+
20
+ custom = (selector[:children] || [])
21
+ .select { |c| c[:tag_name] == "mj-html-attribute" && (c[:attributes] || {})["name"] }
22
+ .each_with_object({}) { |c, acc|
23
+ acc[c[:attributes]["name"]] = c[:content] || ""
24
+ }
25
+
26
+ add.call(:html_attributes, path, custom)
27
+ end
28
+ end
29
+ end
30
+ end
31
+
32
+ Registry.register(Components::MjHtmlAttributes)
33
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../head_component"
4
+ require_relative "../../registry"
5
+
6
+ module Emjay
7
+ module Components
8
+ class MjPreview < HeadComponent
9
+ def self.component_name
10
+ "mj-preview"
11
+ end
12
+
13
+ def self.ending_tag?
14
+ true
15
+ end
16
+
17
+ def handler
18
+ @context[:add].call(:preview, get_content)
19
+ end
20
+ end
21
+ end
22
+
23
+ Registry.register(Components::MjPreview)
24
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../head_component"
4
+ require_relative "../../registry"
5
+
6
+ module Emjay
7
+ module Components
8
+ class MjStyle < HeadComponent
9
+ def self.component_name
10
+ "mj-style"
11
+ end
12
+
13
+ def self.ending_tag?
14
+ true
15
+ end
16
+
17
+ def self.allowed_attributes
18
+ {"inline" => "string"}
19
+ end
20
+
21
+ def handler
22
+ add = @context[:add]
23
+ key = if get_attribute("inline") == "inline"
24
+ :inline_style
25
+ else
26
+ :style
27
+ end
28
+ add.call(key, get_content)
29
+ end
30
+ end
31
+ end
32
+
33
+ Registry.register(Components::MjStyle)
34
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../head_component"
4
+ require_relative "../../registry"
5
+
6
+ module Emjay
7
+ module Components
8
+ class MjTitle < HeadComponent
9
+ def self.component_name
10
+ "mj-title"
11
+ end
12
+
13
+ def self.ending_tag?
14
+ true
15
+ end
16
+
17
+ def handler
18
+ @context[:add].call(:title, get_content)
19
+ end
20
+ end
21
+ end
22
+
23
+ Registry.register(Components::MjTitle)
24
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Emjay
4
+ class GlobalData
5
+ ARRAY_FIELDS = %i[inline_style components_head_style head_raw style].freeze
6
+ HASH_FIELDS = %i[classes classes_default default_attributes html_attributes fonts head_style media_queries].freeze
7
+ SCALAR_FIELDS = %i[before_doctype breakpoint preview title force_owa_desktop lang dir].freeze
8
+
9
+ attr_accessor(*ARRAY_FIELDS, *HASH_FIELDS, *SCALAR_FIELDS)
10
+
11
+ def initialize(options = {})
12
+ @before_doctype = ""
13
+ @breakpoint = options[:breakpoint] || "480px"
14
+ @classes = {}
15
+ @classes_default = {}
16
+ @default_attributes = {}
17
+ @html_attributes = {}
18
+ @fonts = options.fetch(:fonts, default_fonts)
19
+ @inline_style = []
20
+ @head_style = {}
21
+ @components_head_style = []
22
+ @head_raw = []
23
+ @media_queries = {}
24
+ @preview = ""
25
+ @style = []
26
+ @title = ""
27
+ @force_owa_desktop = false
28
+ @lang = "und"
29
+ @dir = "auto"
30
+ end
31
+
32
+ # Mirrors JS headHelpers.add() semantics
33
+ def add(key, *params)
34
+ val = send(key)
35
+ if val.is_a?(Array)
36
+ val.push(*params)
37
+ elsif val.is_a?(Hash)
38
+ if params.length > 1
39
+ val[params[0]] = if val[params[0]].is_a?(Hash)
40
+ val[params[0]].merge(params[1])
41
+ else
42
+ params[1]
43
+ end
44
+ else
45
+ send(:"#{key}=", params[0])
46
+ end
47
+ else
48
+ send(:"#{key}=", params[0])
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def default_fonts
55
+ {
56
+ "Open Sans" => "https://fonts.googleapis.com/css?family=Open+Sans:300,400,500,700",
57
+ "Droid Sans" => "https://fonts.googleapis.com/css?family=Droid+Sans:300,400,500,700",
58
+ "Lato" => "https://fonts.googleapis.com/css?family=Lato:300,400,500,700",
59
+ "Roboto" => "https://fonts.googleapis.com/css?family=Roboto:300,400,500,700",
60
+ "Ubuntu" => "https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700"
61
+ }
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "component"
4
+
5
+ module Emjay
6
+ # Base class for head components. Port of JS HeadComponent from createComponent.js.
7
+ class HeadComponent < Component
8
+ def handler
9
+ # Subclasses override
10
+ end
11
+
12
+ def handler_children
13
+ children = @props[:children] || []
14
+ components = @context[:components] || {}
15
+
16
+ children.filter_map do |child|
17
+ component_class = components[child[:tag_name]]
18
+
19
+ unless component_class
20
+ warn "No matching component for tag: #{child[:tag_name]}"
21
+ next
22
+ end
23
+
24
+ component = component_class.new(
25
+ attributes: child[:attributes] || {},
26
+ children: child[:children] || [],
27
+ content: child[:content] || "",
28
+ context: get_child_context
29
+ )
30
+
31
+ component.handler if component.respond_to?(:handler)
32
+
33
+ component.render if component.respond_to?(:render)
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Emjay
4
+ module ConditionalTag
5
+ START_CONDITIONAL_TAG = "<!--[if mso | IE]>"
6
+ START_MSO_CONDITIONAL_TAG = "<!--[if mso]>"
7
+ END_CONDITIONAL_TAG = "<![endif]-->"
8
+ START_NEGATION_CONDITIONAL_TAG = "<!--[if !mso | IE]><!-->"
9
+ START_MSO_NEGATION_CONDITIONAL_TAG = "<!--[if !mso]><!-->"
10
+ END_NEGATION_CONDITIONAL_TAG = "<!--<![endif]-->"
11
+
12
+ def self.conditional_tag(content, negation: false)
13
+ start_tag = negation ? START_NEGATION_CONDITIONAL_TAG : START_CONDITIONAL_TAG
14
+ end_tag = negation ? END_NEGATION_CONDITIONAL_TAG : END_CONDITIONAL_TAG
15
+ "\n #{start_tag}\n #{content}\n #{end_tag}\n "
16
+ end
17
+
18
+ def self.mso_conditional_tag(content, negation: false)
19
+ start_tag = negation ? START_MSO_NEGATION_CONDITIONAL_TAG : START_MSO_CONDITIONAL_TAG
20
+ end_tag = negation ? END_NEGATION_CONDITIONAL_TAG : END_CONDITIONAL_TAG
21
+ "\n #{start_tag}\n #{content}\n #{end_tag}\n "
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Emjay
4
+ module Fonts
5
+ # Builds font import tags (<link> and @import) for fonts used in the content.
6
+ # Port of fonts.js buildFontsTags
7
+ def self.build_tags(content, inline_style, fonts = {})
8
+ to_import = []
9
+
10
+ fonts.each do |name, url|
11
+ regex = /"[^"]*font-family:[^"]*#{Regexp.escape(name)}[^"]*"/mi
12
+ inline_regex = /font-family:[^;}]*#{Regexp.escape(name)}/mi
13
+
14
+ if content.match?(regex) || inline_style.any? { |s| s.match?(inline_regex) }
15
+ to_import << url
16
+ end
17
+ end
18
+
19
+ return "" if to_import.empty?
20
+
21
+ links = to_import.map { |url| %(<link href="#{url}" rel="stylesheet" type="text/css">) }.join("\n")
22
+ imports = to_import.map { |url| "@import url(#{url});" }.join("\n")
23
+
24
+ <<~HTML
25
+ <!--[if !mso]><!-->
26
+ #{links}
27
+ <style type="text/css">
28
+ #{imports}
29
+ </style>
30
+ <!--<![endif]-->
31
+ HTML
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Emjay
4
+ module GenRandomHexString
5
+ def self.call(length)
6
+ length.times.map { rand(16).to_s(16) }.join
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Emjay
4
+ module MakeLowerBreakpoint
5
+ # Given a breakpoint string like "600px", returns "599px".
6
+ # Port of mjml-core/src/helpers/makeLowerBreakpoint.js
7
+ def self.call(breakpoint)
8
+ match = breakpoint.to_s.match(/[0-9]+/)
9
+ return breakpoint unless match
10
+
11
+ pixels = match[0].to_i
12
+ "#{pixels - 1}px"
13
+ rescue
14
+ breakpoint
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Emjay
4
+ module MediaQueries
5
+ # Builds <style> tags for responsive media queries.
6
+ # Port of mediaQueries.js
7
+ def self.build_tags(breakpoint, media_queries = {}, force_owa_desktop: false, printer_support: false)
8
+ return "" if media_queries.empty?
9
+
10
+ base = media_queries.map { |class_name, mq| ".#{class_name} #{mq}" }
11
+ thunderbird = media_queries.map { |class_name, mq| ".moz-text-html .#{class_name} #{mq}" }
12
+ owa = base.map { |mq| "[owa] #{mq}" }
13
+
14
+ result = +""
15
+ result << <<~HTML
16
+ <style type="text/css">
17
+ @media only screen and (min-width:#{breakpoint}) {
18
+ #{base.join("\n")}
19
+ }
20
+ </style>
21
+ <style media="screen and (min-width:#{breakpoint})">
22
+ #{thunderbird.join("\n")}
23
+ </style>
24
+ HTML
25
+
26
+ if printer_support
27
+ result << <<~HTML
28
+ <style type="text/css">
29
+ @media only print {
30
+ #{base.join("\n")}
31
+ }
32
+ </style>
33
+ HTML
34
+ end
35
+
36
+ if force_owa_desktop
37
+ result << <<~HTML
38
+ <style type="text/css">
39
+ #{owa.join("\n")}
40
+ </style>
41
+ HTML
42
+ end
43
+
44
+ result
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Emjay
4
+ module MergeOutlookConditionals
5
+ # Removes adjacent `<![endif]-->...<!--[if mso | IE]>` pairs.
6
+ # Port of mergeOutlookConditionnals.js
7
+ def self.call(content)
8
+ content.gsub(/<!\[endif\]-->\s*?<!--\[if mso \| IE\]>/m, "")
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Emjay
4
+ module MinifyOutlookConditionals
5
+ # Collapses whitespace inside Outlook conditional blocks.
6
+ # Port of minifyOutlookConditionnals.js
7
+ def self.call(content)
8
+ content.gsub(/(<!--\[if\s[^\]]+\]>)([\s\S]*?)(<!\[endif\]-->)/m) do
9
+ prefix = $1
10
+ inner = $2
11
+ suffix = $3
12
+ processed = inner.gsub(/(^|>)(\s+)(<|$)/m) { "#{$1}#{$3}" }
13
+ .gsub(/\s{2,}/m, " ")
14
+ "#{prefix}#{processed}#{suffix}"
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Emjay
4
+ module ShorthandParser
5
+ # Parses CSS shorthand values (like padding/margin) into per-direction integers.
6
+ # Port of shorthandParser.js
7
+ def self.call(css_value, direction)
8
+ parts = css_value.to_s.strip.gsub(/\s+/, " ").split(" ", 4)
9
+
10
+ directions = case parts.length
11
+ when 2
12
+ {top: 0, bottom: 0, left: 1, right: 1}
13
+ when 3
14
+ {top: 0, left: 1, right: 1, bottom: 2}
15
+ when 4
16
+ {top: 0, right: 1, bottom: 2, left: 3}
17
+ else
18
+ return css_value.to_i
19
+ end
20
+
21
+ (parts[directions[direction.to_sym]] || "0").to_i
22
+ end
23
+ end
24
+
25
+ module BorderParser
26
+ # Extracts the first number from a border string (e.g. "1px solid black" -> 1).
27
+ # Port of borderParser in shorthandParser.js
28
+ def self.call(border)
29
+ match = border.to_s.match(/(?:(?:^| )(\d+))/)
30
+ match ? match[1].to_i : 0
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Emjay
4
+ module Styles
5
+ # Builds <style> tag from component head styles and head style functions.
6
+ # Port of styles.js buildStyleFromComponents
7
+ def self.build_from_components(breakpoint, components_head_style, head_style)
8
+ head_styles = head_style.values
9
+ all = components_head_style + head_styles
10
+
11
+ return "" if all.empty?
12
+
13
+ css = all.map { |style_fn| style_fn.call(breakpoint) }.join("\n")
14
+
15
+ <<~HTML.chomp
16
+ <style type="text/css">#{css}
17
+ </style>
18
+ HTML
19
+ end
20
+
21
+ # Builds <style> tag from user-provided style strings/procs.
22
+ # Port of styles.js buildStyleFromTags
23
+ def self.build_from_tags(breakpoint, styles)
24
+ return "" if styles.empty?
25
+
26
+ css = styles.map { |style| style.respond_to?(:call) ? style.call(breakpoint) : style }.join("\n")
27
+
28
+ <<~HTML.chomp
29
+ <style type="text/css">#{css}
30
+ </style>
31
+ HTML
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Emjay
4
+ module SuffixCssClasses
5
+ # Appends a suffix to each CSS class in a space-separated string.
6
+ # Port of suffixCssClasses.js
7
+ def self.call(classes, suffix)
8
+ return "" unless classes && !classes.empty?
9
+ classes.split(" ").map { |c| "#{c}-#{suffix}" }.join(" ")
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Emjay
4
+ module WidthParser
5
+ UNIT_REGEX = /[\d.,]*(\D*)$/
6
+
7
+ # Parses a width string like "600px" or "50%" into { parsed_width:, unit: }.
8
+ # Port of widthParser.js
9
+ def self.call(width, parse_float_to_int: true)
10
+ width_str = width.to_s
11
+ unit_match = UNIT_REGEX.match(width_str)
12
+ width_unit = unit_match ? unit_match[1] : ""
13
+
14
+ parsed_width = if width_unit == "%" && !parse_float_to_int
15
+ width_str.to_f
16
+ else
17
+ width_str.to_i
18
+ end
19
+
20
+ {
21
+ parsed_width: parsed_width,
22
+ unit: width_unit.empty? ? "px" : width_unit
23
+ }
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Emjay
4
+ module Rails
5
+ class MailInterceptor
6
+ MJML_TAG_PATTERN = /<mjml[\s>]/i
7
+
8
+ def self.delivering_email(message)
9
+ new.compile_mjml!(message)
10
+ end
11
+
12
+ def self.previewing_email(message)
13
+ new.compile_mjml!(message)
14
+ end
15
+
16
+ def compile_mjml!(message)
17
+ if message.multipart?
18
+ message.parts.each { |part| compile_part!(part) }
19
+ else
20
+ compile_part!(message)
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def compile_part!(part)
27
+ return if part.multipart?
28
+ return unless part.content_type&.include?("text/html") || part.content_type.nil?
29
+
30
+ body = part.body.decoded
31
+ return unless MJML_TAG_PATTERN.match?(body)
32
+
33
+ part.body = Emjay.to_html(body)
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Emjay
4
+ module Rails
5
+ module TemplateHandler
6
+ def self.call(template, source)
7
+ # Pure ERB passthrough — exists only to register .mjml as a valid
8
+ # template extension with ERB support. MJML → HTML compilation
9
+ # happens later via Emjay::Rails::MailInterceptor, after Rails
10
+ # has assembled the full render (template + layout).
11
+ erb_handler = ActionView::Template.registered_template_handler(:erb)
12
+ erb_handler.call(template, source)
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Emjay
4
+ class Railtie < Rails::Railtie
5
+ initializer "emjay.register_template_handler" do
6
+ ActiveSupport.on_load(:action_view) do
7
+ require "emjay/rails/template_handler"
8
+ ActionView::Template.register_template_handler(:mjml, Emjay::Rails::TemplateHandler)
9
+ end
10
+ end
11
+
12
+ initializer "emjay.register_mail_interceptor" do
13
+ ActiveSupport.on_load(:action_mailer) do
14
+ require "emjay/rails/mail_interceptor"
15
+ interceptor = Emjay::Rails::MailInterceptor
16
+ ActionMailer::Base.register_interceptor(interceptor)
17
+ ActionMailer::Base.register_preview_interceptor(interceptor)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Emjay
4
+ module Registry
5
+ @components = {}
6
+
7
+ def self.register(component_class)
8
+ @components[component_class.component_name] = component_class
9
+ end
10
+
11
+ def self.find(tag_name)
12
+ @components[tag_name]
13
+ end
14
+
15
+ def self.components
16
+ @components
17
+ end
18
+ end
19
+ end