inkcite 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (75) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +20 -0
  3. data/README.md +110 -0
  4. data/Rakefile +8 -0
  5. data/assets/facebook-like.css +62 -0
  6. data/assets/facebook-like.js +59 -0
  7. data/assets/init/config.yml +97 -0
  8. data/assets/init/helpers.tsv +31 -0
  9. data/assets/init/source.html +60 -0
  10. data/assets/init/source.txt +6 -0
  11. data/bin/inkcite +6 -0
  12. data/inkcite.gemspec +42 -0
  13. data/lib/inkcite.rb +32 -0
  14. data/lib/inkcite/cli/base.rb +128 -0
  15. data/lib/inkcite/cli/build.rb +130 -0
  16. data/lib/inkcite/cli/init.rb +58 -0
  17. data/lib/inkcite/cli/preview.rb +30 -0
  18. data/lib/inkcite/cli/server.rb +123 -0
  19. data/lib/inkcite/cli/test.rb +61 -0
  20. data/lib/inkcite/email.rb +219 -0
  21. data/lib/inkcite/mailer.rb +140 -0
  22. data/lib/inkcite/minifier.rb +151 -0
  23. data/lib/inkcite/parser.rb +111 -0
  24. data/lib/inkcite/renderer.rb +177 -0
  25. data/lib/inkcite/renderer/base.rb +186 -0
  26. data/lib/inkcite/renderer/button.rb +168 -0
  27. data/lib/inkcite/renderer/div.rb +29 -0
  28. data/lib/inkcite/renderer/element.rb +82 -0
  29. data/lib/inkcite/renderer/footnote.rb +132 -0
  30. data/lib/inkcite/renderer/google_analytics.rb +35 -0
  31. data/lib/inkcite/renderer/image.rb +95 -0
  32. data/lib/inkcite/renderer/image_base.rb +82 -0
  33. data/lib/inkcite/renderer/in_browser.rb +38 -0
  34. data/lib/inkcite/renderer/like.rb +73 -0
  35. data/lib/inkcite/renderer/link.rb +243 -0
  36. data/lib/inkcite/renderer/litmus.rb +33 -0
  37. data/lib/inkcite/renderer/lorem.rb +39 -0
  38. data/lib/inkcite/renderer/mobile_image.rb +67 -0
  39. data/lib/inkcite/renderer/mobile_style.rb +40 -0
  40. data/lib/inkcite/renderer/mobile_toggle.rb +27 -0
  41. data/lib/inkcite/renderer/outlook_background.rb +48 -0
  42. data/lib/inkcite/renderer/partial.rb +31 -0
  43. data/lib/inkcite/renderer/preheader.rb +22 -0
  44. data/lib/inkcite/renderer/property.rb +39 -0
  45. data/lib/inkcite/renderer/responsive.rb +334 -0
  46. data/lib/inkcite/renderer/span.rb +21 -0
  47. data/lib/inkcite/renderer/table.rb +67 -0
  48. data/lib/inkcite/renderer/table_base.rb +149 -0
  49. data/lib/inkcite/renderer/td.rb +92 -0
  50. data/lib/inkcite/uploader.rb +173 -0
  51. data/lib/inkcite/util.rb +85 -0
  52. data/lib/inkcite/version.rb +3 -0
  53. data/lib/inkcite/view.rb +745 -0
  54. data/lib/inkcite/view/context.rb +38 -0
  55. data/lib/inkcite/view/media_query.rb +60 -0
  56. data/lib/inkcite/view/tag_stack.rb +38 -0
  57. data/test/email_spec.rb +16 -0
  58. data/test/parser_spec.rb +72 -0
  59. data/test/project/config.yml +98 -0
  60. data/test/project/helpers.tsv +56 -0
  61. data/test/project/images/inkcite.jpg +0 -0
  62. data/test/project/source.html +58 -0
  63. data/test/project/source.txt +6 -0
  64. data/test/renderer/button_spec.rb +45 -0
  65. data/test/renderer/div_spec.rb +101 -0
  66. data/test/renderer/element_spec.rb +31 -0
  67. data/test/renderer/footnote_spec.rb +57 -0
  68. data/test/renderer/image_spec.rb +82 -0
  69. data/test/renderer/link_spec.rb +84 -0
  70. data/test/renderer/mobile_image_spec.rb +27 -0
  71. data/test/renderer/mobile_style_spec.rb +37 -0
  72. data/test/renderer/td_spec.rb +126 -0
  73. data/test/renderer_spec.rb +28 -0
  74. data/test/view_spec.rb +15 -0
  75. 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!(/[–—]/, '&#8211;')
47
+ value.gsub!(/\-\-/, '&#8211;')
48
+ value.gsub!(/™/, '&trade;')
49
+ value.gsub!(/®/, '&reg;')
50
+ value.gsub!(/[‘’`]/, '&#8217;')
51
+ value.gsub!(/“/, '&#8220;')
52
+ value.gsub!(/”/, '&#8221;')
53
+ value.gsub!(/é/, '&eacute;')
54
+ value.gsub!(/…/, '&#8230;')
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 = '&#8203;'
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