Class: Inkcite::View

Inherits:
Object
  • Object
show all
Defined in:
lib/inkcite/view.rb,
lib/inkcite/view/context.rb,
lib/inkcite/view/tag_stack.rb,
lib/inkcite/view/media_query.rb

Defined Under Namespace

Classes: Context, MediaQuery, TagStack

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(email, environment, format, version) ⇒ View

Returns a new instance of View.



40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
# File 'lib/inkcite/view.rb', line 40

def initialize email, environment, format, version
  @email = email
  @environment = environment
  @format = format
  @version = version

  # Read the helper(s) for this view of the email.  This will load
  # the default helpers.tsv and any version-specific (e.g. returning-customer.tsv)
  # helper allowing for overrides.
  @config = load_helpers

  # Merge in the email's configuration for convience - providing access
  # to the renderers.
  @config.merge!(email.config)

  # Expose the version, format as a properties so that it can be resolved when
  # processing pathnames and such.  These need to be strings because they are
  # cloned during rendering.
  @config[:version] = version.to_s
  @config[:format] = format.to_s
  @config[FILE_NAME] = file_name

  # Expose the project's directory name as the project entry.
  @config[:project] = File.basename(@email.path)

  # The MediaQuery object manages the responsive styles that are applied to
  # the email during rendering.  Check to see if a breakwidth has been supplied
  # in helpers.tsv so the designer can control the primary breakpoint.
  breakpoint = @config[:'mobile-breakpoint'].to_i
  if breakpoint <= 0
    breakpoint = @config[:width].to_i - 1
    breakpoint = 480 if breakpoint <= 0
  end

  @media_query = MediaQuery.new(self, breakpoint)

  # Set the version index based on the position of this
  # version in the list of those defined.
  @config[:'version-index'] = (email.versions.index(version) + 1).to_s

  # Tracks the line number and is recorded when errors are encountered
  # while rendering said line.
  @line_number = 0

  # True if VML is used during the preparation of this email.
  @vml_used = false

  # Initializing to prevent a ruby verbose warning.
  @footnotes = nil

end

Instance Attribute Details

#configObject

The configuration hash for the view



30
31
32
# File 'lib/inkcite/view.rb', line 30

def config
  @config
end

#contentObject (readonly)

The rendered html or content available after render! has been called.



12
13
14
# File 'lib/inkcite/view.rb', line 12

def content
  @content
end

#emailObject (readonly)

The base Email object this is a view of



9
10
11
# File 'lib/inkcite/view.rb', line 9

def email
  @email
end

#environmentObject (readonly)

One of :development, :preview or :production



15
16
17
# File 'lib/inkcite/view.rb', line 15

def environment
  @environment
end

#errorsObject

The array of error messages collected during rendering



33
34
35
# File 'lib/inkcite/view.rb', line 33

def errors
  @errors
end

#formatObject (readonly)

The format of the email (e.g. :email or :text)



21
22
23
# File 'lib/inkcite/view.rb', line 21

def format
  @format
end

#js_compressorObject

Will be populated with the css and js compressor objects after first use. Ensures we can reset the compressors after a rendering is complete.



38
39
40
# File 'lib/inkcite/view.rb', line 38

def js_compressor
  @js_compressor
end

#line_numberObject

Line number of the email file being processed



27
28
29
# File 'lib/inkcite/view.rb', line 27

def line_number
  @line_number
end

#media_queryObject (readonly)

Manages the Responsive::Rules applied to this email view.



24
25
26
# File 'lib/inkcite/view.rb', line 24

def media_query
  @media_query
end

#versionObject (readonly)

The version of the email (e.g. :default)



18
19
20
# File 'lib/inkcite/view.rb', line 18

def version
  @version
end

Instance Method Details

#[](key) ⇒ Object



92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
# File 'lib/inkcite/view.rb', line 92

def [] key
  key = key.to_sym

  # Look for configuration specific to the environment and then format.
  env_cfg = config[@environment] || EMPTY_HASH
  ver_cfg = env_cfg[@version] || config[@version] || EMPTY_HASH
  fmt_cfg = env_cfg[@format] || EMPTY_HASH

  # Not using || operator because the value can be legitimately false (e.g. minify
  # is disabled) so only a nil should trigger moving on to the next level up the
  # hierarchy.
  val = ver_cfg[key]
  val = fmt_cfg[key] if val.nil?
  val = env_cfg[key] if val.nil?
  val = config[key] if val.nil?

  val
end

#assert_image_exists(src) ⇒ Object

Verifies that the provided image file (e.g. “banner.jpg”) exists in the project’s image subdirectory. If not, reports the missing image to the developer (unless that is explicitly disabled).



114
115
116
117
118
119
120
121
122
123
# File 'lib/inkcite/view.rb', line 114

def assert_image_exists src

  # This is the full path to the image on the dev's harddrive.
  path = @email.image_path(src)
  exists = File.exist?(path)

  error('Missing image', { :src => src }) if !exists

  exists
end

#browser?Boolean

Returns:

  • (Boolean)


125
126
127
# File 'lib/inkcite/view.rb', line 125

def browser?
  @format == :browser
end

#dataObject

Arbitrary storage of data



130
131
132
# File 'lib/inkcite/view.rb', line 130

def data
  @data ||= {}
end

#default?Boolean

Returns:

  • (Boolean)


134
135
136
# File 'lib/inkcite/view.rb', line 134

def default?
  @version == :default
end

#development?Boolean

Returns:

  • (Boolean)


138
139
140
# File 'lib/inkcite/view.rb', line 138

def development?
  @environment == :development
end

#email?Boolean

Returns:

  • (Boolean)


142
143
144
# File 'lib/inkcite/view.rb', line 142

def email?
  @format == :email
end

#error(message, obj = nil) ⇒ Object

Records an error message on the currently processing line of the source.



151
152
153
154
155
156
157
158
159
160
161
162
163
164
# File 'lib/inkcite/view.rb', line 151

def error message, obj=nil

  message << " (line #{self.line_number.to_i})"
  unless obj.blank?
    message << ' ['
    message << obj.collect { |k, v| "#{k}=#{v}" }.join(', ')
    message << ']'
  end

  @errors ||= []
  @errors << message

  true
end

#eval_erb(source, file_name) ⇒ Object



146
147
148
# File 'lib/inkcite/view.rb', line 146

def eval_erb source, file_name
  Erubis::Eruby.new(source, :filename => file_name, :trim => false, :numbering => true).evaluate(Context.new(self))
end

#file_name(ext = nil) ⇒ Object



199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
# File 'lib/inkcite/view.rb', line 199

def file_name ext=nil

  # Check to see if the file name has been configured.
  fn = self[FILE_NAME]
  if fn.blank?

    # Default naming based on the number of versions - only the format if there is
    # a single version or version and format when there are multiple versions.
    fn = if email.versions.length > 1
           '{version}-{format}'
         elsif text?
           'email'
         else
           '{format}'
         end

  end


  # Need to render the name to convert embedded tags to actual values.
  fn = Renderer.render(fn, self)

  # Sanity check to ensure there is an appropriate extension on the
  # file name.
  ext ||= (text?? TXT_EXTENSION : HTML_EXTENSION)
  fn << ext unless File.extname(fn) == ext

  fn
end


166
167
168
# File 'lib/inkcite/view.rb', line 166

def footer
  @footer ||= []
end

#footnotesObject



170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
# File 'lib/inkcite/view.rb', line 170

def footnotes

  if @footnotes.nil?
    @footnotes = []

    # Preload the array of footnotes if they exist
    footnotes_tsv_file = @email.project_file(FOOTNOTES_TSV_FILE)
    if File.exist?(footnotes_tsv_file)
      CSV.foreach(footnotes_tsv_file, { :col_sep => "\t" }) do |fn|

        id = fn[0]
        next if id.blank?

        text = fn[2]
        next if text.blank?

        # Read the symbol and replace it with nil (so that one will be auto-generated)
        symbol = fn[1]
        symbol = nil if symbol.blank?

        @footnotes << Renderer::Footnote::Instance.new(id, symbol, text, false)

      end
    end
  end

  @footnotes
end

#image_url(src) ⇒ Object

Returns the fully-qualified URL to the designated image (e.g. logo.gif) appropriate for the current rendering environment. In development mode, local will have either images/ or images-optim/ prepended on them depending on the status of image optimization.

For non-development builds, fully-qualified URLs may be returned depending on the state of the config.yml and how image-host attributes have been configured.

If a fully-qualified URL is provided, the URL will be returned with the possible addition of the cache-breaker tag.



240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
# File 'lib/inkcite/view.rb', line 240

def image_url src

  src_url = ''

  if Util.is_fully_qualified?(src)
    src_url << src

  else

    # Prepend the image host onto the src if one is specified in the properties.
    # During local development, images are always expected in an images/ subdirectory.
    image_host = if development?
      (@email.optimize_images?? Minifier::IMAGE_CACHE : Email::IMAGES) + '/'
    else

      # Use the image host defined in config.yml or, out-of-the-box refer to images/
      # in the build directory.
      self[Email::IMAGE_HOST] || (Email::IMAGES + '/')

    end

    src_url << image_host unless image_host.blank?

    # Add the source of the image.
    src_url << src

  end

  # Cache-bust the image if the caller is expecting it to be there.
  Util::add_query_param(src_url, Time.now.to_i) if !production? && is_enabled?(Email::CACHE_BUST)

  # Transpose any embedded tags into actual values.
  Renderer.render(src_url, self)
end

#is_disabled?(key) ⇒ Boolean

Tests if a configuration value has been disabled. This assumes it is enabled by default but that a value of false, ‘false’ or 0 will indicate it is disabled.

Returns:

  • (Boolean)


286
287
288
289
# File 'lib/inkcite/view.rb', line 286

def is_disabled? key
  val = self[key]
  !val.nil? && (val == false || val == false.to_s)
end

#is_enabled?(key) ⇒ Boolean

Tests if a configuration value has been enabled. This assumes it is disabled by default but that a value of true, ‘true’ or 1 for the value indicates it is enabled.

Returns:

  • (Boolean)


278
279
280
281
# File 'lib/inkcite/view.rb', line 278

def is_enabled? key
  val = self[key]
  !val.blank? && val != false && (val == true || val == true.to_s || val.to_i == 1)
end

Map of hrefs by their unique ID



307
308
309
# File 'lib/inkcite/view.rb', line 307

def links
  @links ||= {}
end


292
293
294
295
296
297
298
299
300
301
302
303
304
# File 'lib/inkcite/view.rb', line 292

def links_file_name

  # There is nothing to return if trackable links aren't enabled.
  return nil unless track_links?

  fn = ''
  fn << "#{@version}-" if email.versions.length > 1
  fn << 'links.csv'

  # Need to render the name to convert embedded tags to actual values.
  Renderer.render(fn, self)

end

Returns a hash of the links.tsv file from the project which is used to populate the a and button hrefs when an href isn’t defined.



313
314
315
316
317
318
319
320
321
322
323
324
325
# File 'lib/inkcite/view.rb', line 313

def links_tsv
  @links_tsv ||= begin
    links_tsv_file = @email.project_file(LINKS_TSV_FILE)
    if File.exist?(links_tsv_file)
      Hash[CSV.read(links_tsv_file, { :col_sep => "\t" })]
    else
      {}
    end
  rescue Exception => e
    error("There was a problem reading #{LINKS_TSV_FILE}: #{e.message}")
    {}
  end
end

#meta(key) ⇒ Object



327
328
329
330
# File 'lib/inkcite/view.rb', line 327

def meta key
  md = 
  md.nil?? nil : md[key]
end

#parent_opts(tag) ⇒ Object

Returns the opts for the parent matching the designated tag, if any are presently open.



334
335
336
# File 'lib/inkcite/view.rb', line 334

def parent_opts tag
  tag_stack(tag).opts
end

#prefixesObject

Returns the array of browser prefixes that need to be included in CSS styles based on which version of the email this is.



340
341
342
# File 'lib/inkcite/view.rb', line 340

def prefixes
  [ '', '-webkit-' ]
end

#preview?Boolean

Returns:

  • (Boolean)


344
345
346
# File 'lib/inkcite/view.rb', line 344

def preview?
  @environment == :preview
end

#production?Boolean

Returns:

  • (Boolean)


348
349
350
# File 'lib/inkcite/view.rb', line 348

def production?
  @environment == :production
end

#read_source(source_file) ⇒ Object

Helper method which reads the designated file (e.g. source.html) and performs ERB on it, strips illegal characters and comments (if minified) and returns the filtered content.



355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
# File 'lib/inkcite/view.rb', line 355

def read_source source_file

  # Will be used to assemble the parameters passed to File.open.
  # First, always open the file in read mode.
  mode = [ 'r' ]

  # Detect abnormal file encoding and construct the string to
  # convert such encoding to UTF-8 if specified.
  encoding = self[SOURCE_ENCODING]
  unless encoding.blank? || encoding == UTF_8
    mode << encoding
    mode << UTF_8
  end

  # Read the original source which may include embedded Ruby.
  source = File.open(source_file, mode.join(':')).read

  # Run the content through Erubis
  source = self.eval_erb(source, source_file)

  # If minification is enabled this will remove anything that has been
  # <!-- commented out --> to ensure the email is as small as possible.
  source = Minifier.remove_comments(source, self)

  # Protect against unsupported characters
  source = Renderer.fix_illegal_characters(source, self)

  source
end

#render!Object



385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
# File 'lib/inkcite/view.rb', line 385

def render!
  raise "Already rendered" unless @content.blank?

  source_file = 'source'
  source_file << (text?? TXT_EXTENSION : HTML_EXTENSION)

  # Read the original source which may include embedded Ruby.
  filtered = read_source(@email.project_file(source_file))

  # Filter each of the lines of text and push them onto the stack of lines
  # that we be written into the text or html file.
  lines = render_each(filtered)

  @content = if text?
    lines.join(NEW_LINE)

  else

    # Minify the content of the email.
    minified = Minifier.html(lines, self)

    # Some last-minute fixes before we assemble the wrapping content.
    prevent_ios_date_detection minified

    # Prepare a copy of the HTML for saving as the file.
    html = []

    # Using HTML5 DOCTYPE
    # https://emails.hteumeuleu.com/which-doctype-should-you-use-in-html-emails-cd323fdb793c#.cxet9febe
    html << '<!DOCTYPE html>'

    # Resolve the HTML declaration for this email based on whether or not VML was used.
    html_declaration = '<html xmlns="http://www.w3.org/1999/xhtml"'
    html_declaration << ' xmlns:v="urn:schemas-microsoft-com:vml" lang="en" xml:lang="en"' if vml_used?
    html_declaration << '>'
    html << html_declaration

    html << '<head>'
    html << '<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>'
    html << '<meta name="viewport" content="width=device-width"/>'
    html << "<meta name=\"generator\" content=\"Inkcite #{Inkcite::VERSION}\"/>"

    # Enable responsive media queries on Windows phones courtesy of @jamesmacwhite
    # https://blog.jmwhite.co.uk/2014/03/01/windows-phone-does-support-css3-media-queries-in-html-email/
    html << Renderer.render('{not-outlook}<meta http-equiv="X-UA-Compatible" content="IE=edge" />{/not-outlook}', self)

    # Some native Android clients display the title before the preheader so
    # don't include it in non-development or email rendering per @moonstrips
    html << "<title>#{self.title if (development? || browser?)}</title>"

    # Add external script sources.
    html += external_scripts

    # Add external styles
    html += external_styles

    html << inline_styles
    html << '</head>'

    # Intentionally not setting the link colors because those should be entirely
    # controlled by the styles and attributes of the links themselves.  By not
    # setting it, links created sans-helper should be visually distinct.
    html << '<body style="width: 100% !important; min-width: 100% !important; margin: 0 !important; padding: 0; -webkit-text-size-adjust: none; -ms-text-size-adjust: none;'

    # A pleasing but obvious background exposed in development mode to alert
    # the designer that they have exposed the body background - which means
    # unpredictable results if sent.
    if development?
      html << " background: #ccc url('data:image/png;base64,#{Inkcite.blueprint_image64}');"
    end

    html << %q(">)

    html << minified

    # Append any arbitrary footer content
    html << inline_footer

    # Add inline scripts
    html << inline_scripts

    html << '</body></html>'

    # Remove all blank lines and assemble the wrapped content into a
    # a single string.
    html.select { |l| !l.blank? }.join(NEW_LINE)

  end

  # Ensure that all failsafes pass
  assert_failsafes

  # Verify that the tag stack is open which indicates all opened tags were
  # properly closed - e.g. all {table}s have matching {/table}s.
  #open_stack = @tag_stack && @tag_stack.select { |k, v| !v.empty? }
  #raise open_stack.inspect
  #error 'One or more {tags} may have been left open', { :open_stack => open_stack.collect(&:tag) } if open_stack

  @content
end

#rendered?Boolean

Returns:

  • (Boolean)


486
487
488
# File 'lib/inkcite/view.rb', line 486

def rendered?
  !@content.blank?
end

#scriptsObject



490
491
492
# File 'lib/inkcite/view.rb', line 490

def scripts
  @scripts ||= []
end

#set_meta(key, value) ⇒ Object



494
495
496
497
498
499
500
501
502
# File 'lib/inkcite/view.rb', line 494

def set_meta key, value
  md =  || {}
  md[key.to_sym] = value

  # Write the hash back to the email's meta data.
  @email.set_meta version, md

  value
end

#stylesObject



504
505
506
# File 'lib/inkcite/view.rb', line 504

def styles
  @styles ||= []
end

#subjectObject



508
509
510
# File 'lib/inkcite/view.rb', line 508

def subject
  @subject ||= Renderer.render((self[:subject] || self[:title] || UNTITLED_EMAIL), self)
end

#tag_stack(tag) ⇒ Object



512
513
514
515
# File 'lib/inkcite/view.rb', line 512

def tag_stack tag
  @tag_stack ||= Hash.new()
  @tag_stack[tag] ||= TagStack.new(tag, self)
end

#test!Object

Sends this version of the email to Litmus for testing.



522
523
524
# File 'lib/inkcite/view.rb', line 522

def test!
  EmailTest.test! self
end

#text?Boolean

Returns:

  • (Boolean)


526
527
528
# File 'lib/inkcite/view.rb', line 526

def text?
  @format == :text
end

#titleObject



517
518
519
# File 'lib/inkcite/view.rb', line 517

def title
  @title ||= Renderer.render((self[:title] || UNTITLED_EMAIL), self)
end

#track_links?Boolean

Returns:

  • (Boolean)


530
531
532
# File 'lib/inkcite/view.rb', line 530

def track_links?
  !self[Email::TRACK_LINKS].blank?
end

#unique_id(key) ⇒ Object

Generates an incremental ID for the designated key. The first time a key is used, it will return a 1. Subsequent requests for said key will return 2, 3, etc.



537
538
539
540
# File 'lib/inkcite/view.rb', line 537

def unique_id key
  @unique_ids ||= Hash.new(0)
  @unique_ids[key] += 1
end

#vml_enabled?Boolean

Returns true if vml is enabled in this context. This requires that the context is for an email and that the VML property is enabled.

Returns:

  • (Boolean)


544
545
546
# File 'lib/inkcite/view.rb', line 544

def vml_enabled?
  email? && !is_disabled?(:vml)
end

#vml_used!Object

Signifies that VML was used during the rendering and that



549
550
551
552
# File 'lib/inkcite/view.rb', line 549

def vml_used!
  raise 'VML was used but is not enabled' unless vml_enabled?
  @vml_used = true
end

#vml_used?Boolean

Returns:

  • (Boolean)


554
555
556
# File 'lib/inkcite/view.rb', line 554

def vml_used?
  @vml_used == true
end

#write(out) ⇒ Object



558
559
560
561
562
563
564
565
566
567
568
# File 'lib/inkcite/view.rb', line 558

def write out

  # Ensure that the version has been rendered fully
  render!

  # Fully-qualify the filename - e.g. public/project/issue/file_name and then write the
  # contents of the HTML to said file.
  out.write(@content)

  true
end


570
571
572
573
574
575
576
577
578
579
580
# File 'lib/inkcite/view.rb', line 570

def write_links_csv out

  unless @links.blank?
    csv = CSV.new(out, :force_quotes => true)

    # Write each link to the CSV file.
    @links.keys.sort.each { |k| csv << [k, @links[k]] }
  end

  true
end