inkcite 1.0.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 (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