inkcite 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/LICENSE +20 -0
- data/README.md +110 -0
- data/Rakefile +8 -0
- data/assets/facebook-like.css +62 -0
- data/assets/facebook-like.js +59 -0
- data/assets/init/config.yml +97 -0
- data/assets/init/helpers.tsv +31 -0
- data/assets/init/source.html +60 -0
- data/assets/init/source.txt +6 -0
- data/bin/inkcite +6 -0
- data/inkcite.gemspec +42 -0
- data/lib/inkcite.rb +32 -0
- data/lib/inkcite/cli/base.rb +128 -0
- data/lib/inkcite/cli/build.rb +130 -0
- data/lib/inkcite/cli/init.rb +58 -0
- data/lib/inkcite/cli/preview.rb +30 -0
- data/lib/inkcite/cli/server.rb +123 -0
- data/lib/inkcite/cli/test.rb +61 -0
- data/lib/inkcite/email.rb +219 -0
- data/lib/inkcite/mailer.rb +140 -0
- data/lib/inkcite/minifier.rb +151 -0
- data/lib/inkcite/parser.rb +111 -0
- data/lib/inkcite/renderer.rb +177 -0
- data/lib/inkcite/renderer/base.rb +186 -0
- data/lib/inkcite/renderer/button.rb +168 -0
- data/lib/inkcite/renderer/div.rb +29 -0
- data/lib/inkcite/renderer/element.rb +82 -0
- data/lib/inkcite/renderer/footnote.rb +132 -0
- data/lib/inkcite/renderer/google_analytics.rb +35 -0
- data/lib/inkcite/renderer/image.rb +95 -0
- data/lib/inkcite/renderer/image_base.rb +82 -0
- data/lib/inkcite/renderer/in_browser.rb +38 -0
- data/lib/inkcite/renderer/like.rb +73 -0
- data/lib/inkcite/renderer/link.rb +243 -0
- data/lib/inkcite/renderer/litmus.rb +33 -0
- data/lib/inkcite/renderer/lorem.rb +39 -0
- data/lib/inkcite/renderer/mobile_image.rb +67 -0
- data/lib/inkcite/renderer/mobile_style.rb +40 -0
- data/lib/inkcite/renderer/mobile_toggle.rb +27 -0
- data/lib/inkcite/renderer/outlook_background.rb +48 -0
- data/lib/inkcite/renderer/partial.rb +31 -0
- data/lib/inkcite/renderer/preheader.rb +22 -0
- data/lib/inkcite/renderer/property.rb +39 -0
- data/lib/inkcite/renderer/responsive.rb +334 -0
- data/lib/inkcite/renderer/span.rb +21 -0
- data/lib/inkcite/renderer/table.rb +67 -0
- data/lib/inkcite/renderer/table_base.rb +149 -0
- data/lib/inkcite/renderer/td.rb +92 -0
- data/lib/inkcite/uploader.rb +173 -0
- data/lib/inkcite/util.rb +85 -0
- data/lib/inkcite/version.rb +3 -0
- data/lib/inkcite/view.rb +745 -0
- data/lib/inkcite/view/context.rb +38 -0
- data/lib/inkcite/view/media_query.rb +60 -0
- data/lib/inkcite/view/tag_stack.rb +38 -0
- data/test/email_spec.rb +16 -0
- data/test/parser_spec.rb +72 -0
- data/test/project/config.yml +98 -0
- data/test/project/helpers.tsv +56 -0
- data/test/project/images/inkcite.jpg +0 -0
- data/test/project/source.html +58 -0
- data/test/project/source.txt +6 -0
- data/test/renderer/button_spec.rb +45 -0
- data/test/renderer/div_spec.rb +101 -0
- data/test/renderer/element_spec.rb +31 -0
- data/test/renderer/footnote_spec.rb +57 -0
- data/test/renderer/image_spec.rb +82 -0
- data/test/renderer/link_spec.rb +84 -0
- data/test/renderer/mobile_image_spec.rb +27 -0
- data/test/renderer/mobile_style_spec.rb +37 -0
- data/test/renderer/td_spec.rb +126 -0
- data/test/renderer_spec.rb +28 -0
- data/test/view_spec.rb +15 -0
- metadata +333 -0
@@ -0,0 +1,111 @@
|
|
1
|
+
module Inkcite
|
2
|
+
class Parser
|
3
|
+
|
4
|
+
def self.each str, regex=BRACKET_REGEX, &block
|
5
|
+
|
6
|
+
# Make sure we're dealing with a string.
|
7
|
+
str = str.to_s
|
8
|
+
|
9
|
+
# If the string provided is frozen, we need to duplicate it because we
|
10
|
+
# don't want to modify the original.
|
11
|
+
str = str.dup if str.frozen?
|
12
|
+
|
13
|
+
# Counts the number of replacements and prevents an infinite loop.
|
14
|
+
failsafe = 0
|
15
|
+
|
16
|
+
# While there are matches within the string, repeatedly iterate.
|
17
|
+
while match = regex.match(str)
|
18
|
+
|
19
|
+
# Get the position, as an array, of the match within the string.
|
20
|
+
offset = match.offset(0)
|
21
|
+
|
22
|
+
# Provide the block with the area that was matched, sans wrapper brackets.
|
23
|
+
# Replace the brackets and original value with the block's results.
|
24
|
+
result = yield(match[1].to_s) || EMPTY_STRING
|
25
|
+
str[offset.first, offset.last - offset.first] = result
|
26
|
+
|
27
|
+
# Ensure we don't infinite loop.
|
28
|
+
failsafe += 1
|
29
|
+
raise "Infinite replacement detected: #{failsafe} #{str}" if failsafe >= MAX_RECURSION
|
30
|
+
end
|
31
|
+
|
32
|
+
str
|
33
|
+
end
|
34
|
+
|
35
|
+
def self.parameters str
|
36
|
+
|
37
|
+
# Add an extra space to the end to ensure that the last parameter
|
38
|
+
# gets parsed correctly.
|
39
|
+
str = str.to_s + SPACE
|
40
|
+
|
41
|
+
# Will hold the parameters successfully parsed.
|
42
|
+
params = { }
|
43
|
+
|
44
|
+
# True if we're within a quoted value.
|
45
|
+
quote = false
|
46
|
+
|
47
|
+
# Will hold each key and value we find.
|
48
|
+
key = nil
|
49
|
+
|
50
|
+
# This will hold the substring that is assembled - it will either be the
|
51
|
+
# key (when an equals in encountered) or the value (when a space or closing
|
52
|
+
# quote is discovered).
|
53
|
+
value = ''
|
54
|
+
|
55
|
+
length = str.length - 1
|
56
|
+
for i in 0..length
|
57
|
+
|
58
|
+
# Read the next character in the string.
|
59
|
+
chr = str[i]
|
60
|
+
|
61
|
+
if chr == QUOTE
|
62
|
+
|
63
|
+
# Each time a quote is discovered, toggle the flag indicating that we're
|
64
|
+
# inside of a value or (when false) we're assembling a key.
|
65
|
+
quote = !quote
|
66
|
+
|
67
|
+
elsif chr == EQUAL && !quote
|
68
|
+
|
69
|
+
# When an equal sign is encountered and we're not inside of a quote, then
|
70
|
+
# convert the assembled value into a symbolized key.
|
71
|
+
unless value.blank?
|
72
|
+
key = value.to_sym
|
73
|
+
value = ''
|
74
|
+
end
|
75
|
+
|
76
|
+
elsif chr == SPACE && !quote
|
77
|
+
|
78
|
+
# When a space is encountered, if we're not inside of a quote block then
|
79
|
+
# assign the assembled value to the previously discovered key.
|
80
|
+
if key
|
81
|
+
params[key] = value
|
82
|
+
value = ''
|
83
|
+
key = nil
|
84
|
+
end
|
85
|
+
|
86
|
+
else
|
87
|
+
value << chr
|
88
|
+
|
89
|
+
end
|
90
|
+
|
91
|
+
end
|
92
|
+
|
93
|
+
params
|
94
|
+
end
|
95
|
+
|
96
|
+
private
|
97
|
+
|
98
|
+
# When the handler returns nil
|
99
|
+
EMPTY_STRING = ''
|
100
|
+
|
101
|
+
# We fail if we recurse through a property more than this many times.
|
102
|
+
MAX_RECURSION = 1000
|
103
|
+
|
104
|
+
BRACKET_REGEX = /\{([^\{\}]+)\}/
|
105
|
+
|
106
|
+
SPACE = ' '
|
107
|
+
QUOTE = '"'
|
108
|
+
EQUAL = '='
|
109
|
+
|
110
|
+
end
|
111
|
+
end
|
@@ -0,0 +1,177 @@
|
|
1
|
+
require_relative 'renderer/base'
|
2
|
+
require_relative 'renderer/element'
|
3
|
+
require_relative 'renderer/responsive'
|
4
|
+
require_relative 'renderer/image_base'
|
5
|
+
require_relative 'renderer/table_base'
|
6
|
+
|
7
|
+
require_relative 'renderer/button'
|
8
|
+
require_relative 'renderer/div'
|
9
|
+
require_relative 'renderer/footnote'
|
10
|
+
require_relative 'renderer/google_analytics'
|
11
|
+
require_relative 'renderer/image'
|
12
|
+
require_relative 'renderer/in_browser'
|
13
|
+
require_relative 'renderer/like'
|
14
|
+
require_relative 'renderer/link'
|
15
|
+
require_relative 'renderer/litmus'
|
16
|
+
require_relative 'renderer/lorem'
|
17
|
+
require_relative 'renderer/mobile_image'
|
18
|
+
require_relative 'renderer/mobile_style'
|
19
|
+
require_relative 'renderer/mobile_toggle'
|
20
|
+
require_relative 'renderer/outlook_background'
|
21
|
+
require_relative 'renderer/partial'
|
22
|
+
require_relative 'renderer/preheader'
|
23
|
+
require_relative 'renderer/property'
|
24
|
+
require_relative 'renderer/span'
|
25
|
+
require_relative 'renderer/table'
|
26
|
+
require_relative 'renderer/td'
|
27
|
+
|
28
|
+
module Inkcite
|
29
|
+
module Renderer
|
30
|
+
|
31
|
+
def self.fix_illegal_characters value, context
|
32
|
+
|
33
|
+
# These special characters cause rendering problems in a variety
|
34
|
+
# of email clients. Convert them to the correct unicode characters.
|
35
|
+
# https://www.campaignmonitor.com/blog/post/1810/why-are-all-my-apostrophes-mis
|
36
|
+
|
37
|
+
if context.text?
|
38
|
+
value.gsub!(/[–—]/, '-')
|
39
|
+
value.gsub!(/™/, '(tm)')
|
40
|
+
value.gsub!(/®/, '(r)')
|
41
|
+
value.gsub!(/[‘’`]/, "'")
|
42
|
+
value.gsub!(/[“”]/, '"')
|
43
|
+
value.gsub!(/…/, '...')
|
44
|
+
|
45
|
+
else
|
46
|
+
value.gsub!(/[–—]/, '–')
|
47
|
+
value.gsub!(/\-\-/, '–')
|
48
|
+
value.gsub!(/™/, '™')
|
49
|
+
value.gsub!(/®/, '®')
|
50
|
+
value.gsub!(/[‘’`]/, '’')
|
51
|
+
value.gsub!(/“/, '“')
|
52
|
+
value.gsub!(/”/, '”')
|
53
|
+
value.gsub!(/é/, 'é')
|
54
|
+
value.gsub!(/…/, '…')
|
55
|
+
|
56
|
+
end
|
57
|
+
|
58
|
+
value
|
59
|
+
end
|
60
|
+
|
61
|
+
def self.hex color
|
62
|
+
|
63
|
+
# Convert #rgb into #rrggbb
|
64
|
+
if !color.blank? && color.length < 7
|
65
|
+
red = color[1]
|
66
|
+
green = color[2]
|
67
|
+
blue = color[3]
|
68
|
+
color = "##{red}#{red}#{green}#{green}#{blue}#{blue}"
|
69
|
+
end
|
70
|
+
|
71
|
+
color
|
72
|
+
end
|
73
|
+
|
74
|
+
# Joins the key-value-pairs of the provided hash into a readable
|
75
|
+
# string destined for HTML or CSS style declarations. For example,
|
76
|
+
# { :bgcolor => '"#fff"' } would become bgcolor="#fff" using the
|
77
|
+
# default equality and space delimiters.
|
78
|
+
def self.join_hash hash, equal=EQUAL, sep=SPACE
|
79
|
+
|
80
|
+
pairs = []
|
81
|
+
|
82
|
+
hash.keys.sort.each do |att|
|
83
|
+
val = hash[att]
|
84
|
+
pairs << "#{att}#{equal}#{val}" unless val.blank?
|
85
|
+
end
|
86
|
+
|
87
|
+
pairs.join(sep)
|
88
|
+
end
|
89
|
+
|
90
|
+
# Applies a "px" extension to unlabeled integer values. If a labeled
|
91
|
+
# value is detected (e.g. 2em) or a non-integer value is provided
|
92
|
+
# (e.g. "normal") then the value is returned directly.
|
93
|
+
def self.px val
|
94
|
+
|
95
|
+
# Quick abort if a non-integer value has been provided. This catches
|
96
|
+
# cases like 3em and normal. When provided, the value is not converted
|
97
|
+
# to pixels and instead is returned directly.
|
98
|
+
return val if val && val.to_i.to_s != val.to_s
|
99
|
+
|
100
|
+
val = val.to_i
|
101
|
+
val = "#{val}px" unless val == 0
|
102
|
+
val
|
103
|
+
end
|
104
|
+
|
105
|
+
def self.quote val
|
106
|
+
"\"#{val}\""
|
107
|
+
end
|
108
|
+
|
109
|
+
def self.render str, context
|
110
|
+
|
111
|
+
Parser.each(str) do |tag|
|
112
|
+
|
113
|
+
# Split the string into the tag and it's attributes.
|
114
|
+
name, opts = tag.split(SPACE, 2)
|
115
|
+
|
116
|
+
# Convert the options string (e.g. color=#ff9900 border=none) into parameters.
|
117
|
+
opts = Parser.parameters(opts)
|
118
|
+
|
119
|
+
# Strip off the leading slash (/) if there is one. Renderers are
|
120
|
+
open_tag = (name.starts_with?(SLASH) ? name[1..-1] : name).to_sym
|
121
|
+
|
122
|
+
# Choose a renderer either from the dynamic set or use the default one that
|
123
|
+
# simply renders from the property values.
|
124
|
+
renderer = renderers[open_tag] || default_renderer
|
125
|
+
|
126
|
+
renderer.render name, opts, context
|
127
|
+
|
128
|
+
end
|
129
|
+
|
130
|
+
end
|
131
|
+
|
132
|
+
def self.render_styles styles
|
133
|
+
join_hash(styles, COLON, SEMI_COLON)
|
134
|
+
end
|
135
|
+
|
136
|
+
private
|
137
|
+
|
138
|
+
COLON = ':'
|
139
|
+
EQUAL = '='
|
140
|
+
SEMI_COLON = ';'
|
141
|
+
SPACE = ' '
|
142
|
+
SLASH = '/'
|
143
|
+
|
144
|
+
def self.default_renderer
|
145
|
+
@default_renderer ||= Property.new
|
146
|
+
end
|
147
|
+
|
148
|
+
def self.renderers
|
149
|
+
|
150
|
+
# Dynamic renderers for custom behavior and tags.
|
151
|
+
@renderers ||= {
|
152
|
+
:a => Link.new,
|
153
|
+
:button => Button.new,
|
154
|
+
:div => Div.new,
|
155
|
+
:footnote => Footnote.new,
|
156
|
+
:footnotes => Footnotes.new,
|
157
|
+
:google => GoogleAnalytics.new,
|
158
|
+
:img => Image.new,
|
159
|
+
:'in-browser' => InBrowser.new,
|
160
|
+
:include => Partial.new,
|
161
|
+
:like => Like.new,
|
162
|
+
:litmus => Litmus.new,
|
163
|
+
:lorem => Lorem.new,
|
164
|
+
:'mobile-img' => MobileImage.new,
|
165
|
+
:'mobile-style' => MobileStyle.new,
|
166
|
+
:'mobile-toggle-on' => MobileToggleOn.new,
|
167
|
+
:'outlook-bg' => OutlookBackground.new,
|
168
|
+
:preheader => Preheader.new,
|
169
|
+
:span => Span.new,
|
170
|
+
:table => Table.new,
|
171
|
+
:td => Td.new
|
172
|
+
}
|
173
|
+
|
174
|
+
end
|
175
|
+
|
176
|
+
end
|
177
|
+
end
|
@@ -0,0 +1,186 @@
|
|
1
|
+
module Inkcite
|
2
|
+
module Renderer
|
3
|
+
class Base
|
4
|
+
|
5
|
+
# Constants for style and property names with dashes in them.
|
6
|
+
BACKGROUND_COLOR = :'background-color'
|
7
|
+
BACKGROUND_IMAGE = :'background-image'
|
8
|
+
BACKGROUND_REPEAT = :'background-repeat'
|
9
|
+
BACKGROUND_POSITION = :'background-position'
|
10
|
+
BACKGROUND_SIZE = :'background-size'
|
11
|
+
BORDER_BOTTOM = :'border-bottom'
|
12
|
+
BORDER_COLLAPSE = :'border-collapse'
|
13
|
+
BORDER_RADIUS = :'border-radius'
|
14
|
+
BORDER_SPACING = :'border-spacing'
|
15
|
+
BOX_SHADOW = :'box-shadow'
|
16
|
+
FONT_FAMILY = :'font-family'
|
17
|
+
FONT_SIZE = :'font-size'
|
18
|
+
FONT_WEIGHT = :'font-weight'
|
19
|
+
LETTER_SPACING = :'letter-spacing'
|
20
|
+
LINE_HEIGHT = :'line-height'
|
21
|
+
LINK_COLOR = :'#link'
|
22
|
+
MARGIN_TOP = :'margin-top'
|
23
|
+
PADDING_X = :'padding-x'
|
24
|
+
PADDING_Y = :'padding-y'
|
25
|
+
TEXT_ALIGN = :'text-align'
|
26
|
+
TEXT_DECORATION = :'text-decoration'
|
27
|
+
TEXT_SHADOW = :'text-shadow'
|
28
|
+
TEXT_SHADOW_BLUR = :'shadow-blur'
|
29
|
+
TEXT_SHADOW_OFFSET = :'shadow-offset'
|
30
|
+
VERTICAL_ALIGN = :'vertical-align'
|
31
|
+
|
32
|
+
# CSS Directions
|
33
|
+
DIRECTIONS = [ :top, :right, :bottom, :left ]
|
34
|
+
|
35
|
+
# Attribute and CSS dimensions
|
36
|
+
DIMENSIONS = [ :width, :height ]
|
37
|
+
|
38
|
+
# Common value declarations
|
39
|
+
POUND_SIGN = '#'
|
40
|
+
NONE = 'none'
|
41
|
+
|
42
|
+
# Zero-width space character
|
43
|
+
ZERO_WIDTH_SPACE = '​'
|
44
|
+
|
45
|
+
def render tag, opt, ctx
|
46
|
+
raise "Not implemented: #{tag} #{opts}"
|
47
|
+
end
|
48
|
+
|
49
|
+
protected
|
50
|
+
|
51
|
+
# Convenience proxy
|
52
|
+
def detect *opts
|
53
|
+
Util.detect(*opts)
|
54
|
+
end
|
55
|
+
|
56
|
+
def detect_font att, font, opt, parent, ctx
|
57
|
+
val = detect(opt[att], ctx["#{font}-#{att}"], parent ? parent[att] : nil)
|
58
|
+
|
59
|
+
# Sometimes font values reference other defined values so we need
|
60
|
+
# to run them through the renderer to resolve them.
|
61
|
+
val = Inkcite::Renderer.render(val, ctx)
|
62
|
+
|
63
|
+
# Convience
|
64
|
+
val = nil if none?(val)
|
65
|
+
|
66
|
+
val
|
67
|
+
end
|
68
|
+
|
69
|
+
# Convenience pass-thru to Renderer's static helper method.
|
70
|
+
def hex color
|
71
|
+
Renderer.hex(color)
|
72
|
+
end
|
73
|
+
|
74
|
+
def none? val
|
75
|
+
val.blank? || val == NONE
|
76
|
+
end
|
77
|
+
|
78
|
+
# Sets the element's in-line bgcolor style if it has been defined
|
79
|
+
# in the provided options.
|
80
|
+
def mix_background element, opt
|
81
|
+
|
82
|
+
# Background color of the image, if populated.
|
83
|
+
bgcolor = detect(opt[:bgcolor], opt[BACKGROUND_COLOR])
|
84
|
+
element.style[BACKGROUND_COLOR] = hex(bgcolor) unless none?(bgcolor)
|
85
|
+
|
86
|
+
end
|
87
|
+
|
88
|
+
def mix_font element, opt, ctx, parent=nil
|
89
|
+
|
90
|
+
# Always ensure we have a parent to inherit from.
|
91
|
+
parent ||= {}
|
92
|
+
|
93
|
+
# Check for a font in either the element's specified options or inherit a setting from
|
94
|
+
# from the parent if provided.
|
95
|
+
font = detect(opt[:font], parent[:font])
|
96
|
+
|
97
|
+
# Fonts can be disabled on individual cells if the parent table
|
98
|
+
# has set one for the entire table.
|
99
|
+
font = nil if none?(font)
|
100
|
+
|
101
|
+
font_family = detect_font(FONT_FAMILY, font, opt, parent, ctx)
|
102
|
+
element.style[FONT_FAMILY] = font_family unless font_family.blank?
|
103
|
+
|
104
|
+
font_size = detect_font(FONT_SIZE, font, opt, parent, ctx)
|
105
|
+
element.style[FONT_SIZE] = px(font_size) unless font_size.blank?
|
106
|
+
|
107
|
+
color = detect_font(:color, font, opt, parent, ctx)
|
108
|
+
element.style[:color] = hex(color) unless color.blank?
|
109
|
+
|
110
|
+
line_height = detect_font(LINE_HEIGHT, font, opt, parent, ctx)
|
111
|
+
element.style[LINE_HEIGHT] = px(line_height) unless line_height.blank?
|
112
|
+
|
113
|
+
font_weight = detect_font(FONT_WEIGHT, font, opt, parent, ctx)
|
114
|
+
element.style[FONT_WEIGHT] = font_weight unless font_weight.blank?
|
115
|
+
|
116
|
+
letter_spacing = detect_font(LETTER_SPACING, font, opt, parent, ctx)
|
117
|
+
element.style[LETTER_SPACING] = px(letter_spacing) unless none?(letter_spacing)
|
118
|
+
|
119
|
+
# With font support comes text shadow support.
|
120
|
+
mix_text_shadow element, opt, ctx
|
121
|
+
|
122
|
+
font
|
123
|
+
end
|
124
|
+
|
125
|
+
def mix_text_shadow element, opt, ctx
|
126
|
+
|
127
|
+
shadow = detect(opt[:shadow], opt[TEXT_SHADOW])
|
128
|
+
return if shadow.blank?
|
129
|
+
|
130
|
+
# Allow shadows to be disabled because sometimes a child element (like an
|
131
|
+
# image within a cell or an entire cell within a table) wants to disable
|
132
|
+
# the shadowing forced by a parent.
|
133
|
+
if none?(shadow)
|
134
|
+
element.style[TEXT_SHADOW] = shadow
|
135
|
+
|
136
|
+
else
|
137
|
+
|
138
|
+
shadow_offset = detect(opt[TEXT_SHADOW_OFFSET], ctx[TEXT_SHADOW_OFFSET], 1)
|
139
|
+
shadow_blur = detect(opt[TEXT_SHADOW_BLUR], ctx[TEXT_SHADOW_BLUR], 0)
|
140
|
+
|
141
|
+
element.style[TEXT_SHADOW] = "0 #{px(shadow_offset)} #{px(shadow_blur)} #{hex(shadow)}"
|
142
|
+
|
143
|
+
end
|
144
|
+
|
145
|
+
end
|
146
|
+
|
147
|
+
def px val
|
148
|
+
Renderer.px(val)
|
149
|
+
end
|
150
|
+
|
151
|
+
def quote val
|
152
|
+
Renderer.quote(val)
|
153
|
+
end
|
154
|
+
|
155
|
+
def render_tag tag, attributes=nil, styles=nil
|
156
|
+
|
157
|
+
# Convert the style hash into CSS style attribute.
|
158
|
+
unless styles.blank?
|
159
|
+
attributes ||= {}
|
160
|
+
attributes[:style] = quote(Renderer.render_styles(styles))
|
161
|
+
end
|
162
|
+
|
163
|
+
# Check to see if this is a self-closing tag.
|
164
|
+
self_close = attributes && attributes.delete(:self_close) == true
|
165
|
+
|
166
|
+
html = "<#{tag}"
|
167
|
+
|
168
|
+
unless attributes.blank?
|
169
|
+
|
170
|
+
# Make sure multiple classes are handled properly.
|
171
|
+
classes = attributes[:class]
|
172
|
+
attributes[:class] = quote([*classes].join(' ')) unless classes.blank?
|
173
|
+
|
174
|
+
html << SPACE + Renderer.join_hash(attributes)
|
175
|
+
|
176
|
+
end
|
177
|
+
|
178
|
+
html << '/' if self_close
|
179
|
+
html << '>'
|
180
|
+
|
181
|
+
html
|
182
|
+
end
|
183
|
+
|
184
|
+
end
|
185
|
+
end
|
186
|
+
end
|