sevk 1.0.0 → 1.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.
- checksums.yaml +4 -4
- data/LICENSE +21 -0
- data/README.md +6 -6
- data/lib/sevk/client.rb +140 -63
- data/lib/sevk/error.rb +38 -5
- data/lib/sevk/markup/document_parser.rb +83 -0
- data/lib/sevk/markup/html_document.rb +82 -0
- data/lib/sevk/markup/number_formatting.rb +40 -0
- data/lib/sevk/markup/render_limits.rb +52 -0
- data/lib/sevk/markup/renderer.rb +232 -729
- data/lib/sevk/markup/style_helpers.rb +131 -0
- data/lib/sevk/markup/syntax_highlighting.rb +227 -0
- data/lib/sevk/markup/tag_scanner.rb +112 -0
- data/lib/sevk/markup/template_engine.rb +247 -0
- data/lib/sevk/resources/activity.rb +23 -0
- data/lib/sevk/resources/audiences.rb +22 -11
- data/lib/sevk/resources/base.rb +15 -3
- data/lib/sevk/resources/broadcasts.rb +56 -12
- data/lib/sevk/resources/contacts.rb +32 -17
- data/lib/sevk/resources/domains.rb +22 -8
- data/lib/sevk/resources/emails.rb +85 -34
- data/lib/sevk/resources/inbound.rb +123 -0
- data/lib/sevk/resources/outbound.rb +55 -0
- data/lib/sevk/resources/segments.rb +34 -20
- data/lib/sevk/resources/subscriptions.rb +18 -11
- data/lib/sevk/resources/templates.rb +20 -8
- data/lib/sevk/resources/topics.rb +31 -20
- data/lib/sevk/resources/usage.rb +15 -0
- data/lib/sevk/resources/webhooks.rb +21 -7
- data/lib/sevk/version.rb +1 -1
- data/lib/sevk/webhook_verifier.rb +91 -0
- data/lib/sevk.rb +24 -238
- metadata +21 -94
- data/Rakefile +0 -42
- data/lib/sevk/resources/events.rb +0 -18
- data/sevk.gemspec +0 -41
data/lib/sevk/markup/renderer.rb
CHANGED
|
@@ -1,189 +1,150 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
4
|
-
require
|
|
3
|
+
require 'rouge'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'strscan'
|
|
6
|
+
|
|
7
|
+
require_relative 'document_parser'
|
|
8
|
+
require_relative 'html_document'
|
|
9
|
+
require_relative 'number_formatting'
|
|
10
|
+
require_relative 'render_limits'
|
|
11
|
+
require_relative 'style_helpers'
|
|
12
|
+
require_relative 'syntax_highlighting'
|
|
13
|
+
require_relative 'tag_scanner'
|
|
14
|
+
require_relative 'template_engine'
|
|
5
15
|
|
|
6
16
|
module Sevk
|
|
7
17
|
module Markup
|
|
8
|
-
# Font configuration
|
|
9
18
|
class FontConfig
|
|
10
19
|
attr_accessor :id, :name, :url
|
|
11
20
|
|
|
12
|
-
def initialize(id:
|
|
21
|
+
def initialize(id: '', name: '', url: '')
|
|
13
22
|
@id = id
|
|
14
23
|
@name = name
|
|
15
24
|
@url = url
|
|
16
25
|
end
|
|
17
26
|
end
|
|
18
27
|
|
|
19
|
-
# Head settings for email generation
|
|
20
28
|
class EmailHeadSettings
|
|
21
29
|
attr_accessor :title, :preview_text, :styles, :fonts, :lang, :dir
|
|
22
30
|
|
|
23
31
|
def initialize
|
|
24
|
-
@title =
|
|
25
|
-
@preview_text =
|
|
26
|
-
@styles =
|
|
32
|
+
@title = ''
|
|
33
|
+
@preview_text = ''
|
|
34
|
+
@styles = ''
|
|
27
35
|
@fonts = []
|
|
28
|
-
@lang =
|
|
29
|
-
@dir =
|
|
36
|
+
@lang = ''
|
|
37
|
+
@dir = ''
|
|
30
38
|
end
|
|
31
39
|
end
|
|
32
40
|
|
|
33
|
-
# Parsed email content
|
|
34
|
-
class ParsedEmailContent
|
|
35
|
-
attr_accessor :body, :head_settings
|
|
36
|
-
|
|
37
|
-
def initialize
|
|
38
|
-
@body = ""
|
|
39
|
-
@head_settings = EmailHeadSettings.new
|
|
40
|
-
end
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
# Sevk Markup Renderer
|
|
44
|
-
# Converts Sevk markup to email-compatible HTML using regex-based parsing (like Node.js)
|
|
45
41
|
class Renderer
|
|
42
|
+
include DocumentParser
|
|
43
|
+
include HtmlDocument
|
|
44
|
+
include NumberFormatting
|
|
45
|
+
include RenderLimits
|
|
46
|
+
include StyleHelpers
|
|
47
|
+
include SyntaxHighlighting
|
|
48
|
+
include TagScanner
|
|
49
|
+
include TemplateEngine
|
|
50
|
+
|
|
46
51
|
def initialize
|
|
47
52
|
@head_settings = EmailHeadSettings.new
|
|
48
53
|
end
|
|
49
54
|
|
|
55
|
+
# Converts sevk markup into complete, email-compatible HTML.
|
|
50
56
|
def render(markup)
|
|
51
|
-
|
|
57
|
+
@head_settings = EmailHeadSettings.new
|
|
52
58
|
parse_head_settings(markup)
|
|
53
|
-
|
|
54
|
-
# Extract clean body content (strips <mail>/<head> wrapper tags)
|
|
55
59
|
body_content = extract_body_content(markup)
|
|
56
|
-
|
|
57
|
-
# Normalize markup
|
|
58
60
|
normalized = normalize_markup(body_content)
|
|
59
|
-
|
|
60
|
-
# Process markup using regex
|
|
61
61
|
processed = process_markup(normalized)
|
|
62
|
-
|
|
63
62
|
generate_html(processed)
|
|
64
63
|
end
|
|
65
64
|
|
|
66
65
|
private
|
|
67
66
|
|
|
68
|
-
def extract_body_content(markup)
|
|
69
|
-
if markup.include?("<mail") || markup.include?("<email")
|
|
70
|
-
if (match = markup.match(/<body[^>]*>([\s\S]*?)<\/body>/i))
|
|
71
|
-
return match[1].strip
|
|
72
|
-
end
|
|
73
|
-
end
|
|
74
|
-
markup
|
|
75
|
-
end
|
|
76
|
-
|
|
77
67
|
def normalize_markup(content)
|
|
78
68
|
result = content
|
|
79
69
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
result = result.gsub(/<link\s+href=/i, "<sevk-link href=")
|
|
83
|
-
result = result.gsub("</link>", "</sevk-link>")
|
|
84
|
-
end
|
|
70
|
+
result = result.gsub(/<link(\s+(?:[\w-]+=(?:"[^"]*"|'[^']*')\s+)*)href=/i, '<sevk-link\1href=')
|
|
71
|
+
result = result.gsub(%r{</link>}i, '</sevk-link>')
|
|
85
72
|
|
|
86
|
-
|
|
87
|
-
result = "<mail><body>#{result}</body></mail>"
|
|
88
|
-
end
|
|
73
|
+
result = "<mail><body>#{result}</body></mail>" unless result.match?(/<(?:sevk-email|email|mail)(?=[\s>])/i)
|
|
89
74
|
|
|
90
75
|
result
|
|
91
76
|
end
|
|
92
77
|
|
|
93
|
-
def parse_head_settings(markup)
|
|
94
|
-
# Parse lang and dir from <mail> or <email> root tag
|
|
95
|
-
if (root_match = markup.match(/<(?:email|mail)([^>]*)>/i))
|
|
96
|
-
root_attrs = root_match[1]
|
|
97
|
-
if (lang_match = root_attrs.match(/lang=["']([^"']*)["']/i))
|
|
98
|
-
@head_settings.lang = lang_match[1]
|
|
99
|
-
end
|
|
100
|
-
if (dir_match = root_attrs.match(/dir=["']([^"']*)["']/i))
|
|
101
|
-
@head_settings.dir = dir_match[1]
|
|
102
|
-
end
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
# Extract title
|
|
106
|
-
if (match = markup.match(/<title[^>]*>([\s\S]*?)<\/title>/i))
|
|
107
|
-
@head_settings.title = match[1].strip
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
# Extract preview
|
|
111
|
-
if (match = markup.match(/<preview[^>]*>([\s\S]*?)<\/preview>/i))
|
|
112
|
-
@head_settings.preview_text = match[1].strip
|
|
113
|
-
end
|
|
114
|
-
|
|
115
|
-
# Extract styles
|
|
116
|
-
if (match = markup.match(/<style[^>]*>([\s\S]*?)<\/style>/i))
|
|
117
|
-
@head_settings.styles = match[1].strip
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
# Extract fonts
|
|
121
|
-
markup.scan(/<font[^>]*name=["']([^"']*)["'][^>]*url=["']([^"']*)["'][^>]*\/?>/i).each_with_index do |(name, url), i|
|
|
122
|
-
@head_settings.fonts << FontConfig.new(id: "font-#{i}", name: name, url: url)
|
|
123
|
-
end
|
|
124
|
-
end
|
|
125
|
-
|
|
126
78
|
def process_markup(content)
|
|
127
|
-
|
|
79
|
+
@gap_rules = []
|
|
80
|
+
@codeblock_budget = 0
|
|
81
|
+
@scan_work = 0
|
|
82
|
+
assert_markup_depth(content)
|
|
128
83
|
|
|
129
|
-
|
|
130
|
-
if result.include?("<link")
|
|
131
|
-
result = result.gsub(/<link\s+href=/i, "<sevk-link href=")
|
|
132
|
-
result = result.gsub("</link>", "</sevk-link>")
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
# Process block tags (before other tags)
|
|
136
|
-
result = process_tag(result, "block") do |attrs, inner|
|
|
84
|
+
result = process_tag(content, 'block') do |attrs, inner|
|
|
137
85
|
process_block_tag(attrs, inner)
|
|
138
86
|
end
|
|
139
87
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
attrs = parse_attributes(Regexp.last_match(1) || "")
|
|
143
|
-
process_block_tag(attrs)
|
|
88
|
+
result = replace_self_closing(result, 'block', self_close_only: true) do |attrs_str|
|
|
89
|
+
process_block_tag(parse_attributes(attrs_str))
|
|
144
90
|
end
|
|
91
|
+
assert_markup_depth(result)
|
|
92
|
+
|
|
93
|
+
result = result.gsub(/<link(\s+(?:[\w-]+=(?:"[^"]*"|'[^']*')\s+)*)href=/i, '<sevk-link\1href=')
|
|
94
|
+
result = result.gsub(%r{</link>}i, '</sevk-link>')
|
|
145
95
|
|
|
146
|
-
#
|
|
147
|
-
result = process_tag(result,
|
|
96
|
+
# Section: padding and text-align go on td (Outlook-friendly), rest on outer table.
|
|
97
|
+
result = process_tag(result, 'section') do |attrs, inner|
|
|
148
98
|
style = extract_all_style_attributes(attrs)
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
99
|
+
td_style = {}
|
|
100
|
+
td_style['padding'] = style.delete('padding') if style.key?('padding')
|
|
101
|
+
td_style['text-align'] = style.delete('text-align') if style.key?('text-align')
|
|
102
|
+
# Tables default to border-collapse:collapse via the global reset,
|
|
103
|
+
# which disables border-radius. Flip to separate when any corner
|
|
104
|
+
# radius is set so the rounded border actually renders.
|
|
105
|
+
radius_keys = %w[
|
|
106
|
+
border-radius border-top-left-radius border-top-right-radius
|
|
107
|
+
border-bottom-left-radius border-bottom-right-radius
|
|
108
|
+
]
|
|
109
|
+
style['border-collapse'] = 'separate' if radius_keys.any? { |key| style.key?(key) }
|
|
158
110
|
|
|
159
|
-
|
|
160
|
-
|
|
111
|
+
[
|
|
112
|
+
%(<table align="center" width="100%" border="0" cellPadding="0" ),
|
|
113
|
+
%(cellSpacing="0" role="presentation" style="#{style_to_string(style)}">\n),
|
|
114
|
+
"<tbody>\n",
|
|
115
|
+
"<tr>\n",
|
|
116
|
+
%(<td style="#{style_to_string(td_style)}">#{inner}</td>\n),
|
|
117
|
+
"</tr>\n",
|
|
118
|
+
"</tbody>\n",
|
|
119
|
+
'</table>'
|
|
120
|
+
].join
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
result = process_tag(result, 'column') do |attrs, inner|
|
|
161
124
|
style = extract_all_style_attributes(attrs)
|
|
162
|
-
style[
|
|
125
|
+
style['vertical-align'] ||= 'top'
|
|
163
126
|
style_str = style_to_string(style)
|
|
164
127
|
%(<td class="sevk-column" style="#{style_str}">#{inner}</td>)
|
|
165
128
|
end
|
|
166
129
|
|
|
167
|
-
# Process row tags
|
|
168
130
|
row_counter = 0
|
|
169
|
-
result = process_tag(result,
|
|
170
|
-
gap = attrs[
|
|
131
|
+
result = process_tag(result, 'row') do |attrs, inner|
|
|
132
|
+
gap = attrs['gap'] || '0'
|
|
171
133
|
style = extract_all_style_attributes(attrs)
|
|
172
|
-
style.delete(
|
|
134
|
+
style.delete('gap')
|
|
173
135
|
style_str = style_to_string(style)
|
|
174
|
-
gap_px = gap.
|
|
175
|
-
gap_num = gap_px
|
|
136
|
+
gap_px = gap.sub('px', '')
|
|
137
|
+
gap_num = parse_px(gap_px)
|
|
176
138
|
row_id = "sevk-row-#{row_counter}"
|
|
177
139
|
row_counter += 1
|
|
178
140
|
|
|
179
|
-
# Assign equal widths to columns if more than one
|
|
180
141
|
processed_inner = inner
|
|
181
|
-
column_count = inner.scan(
|
|
142
|
+
column_count = inner.scan('class="sevk-column"').length
|
|
182
143
|
if column_count > 1
|
|
183
144
|
equal_width = "#{(100 / column_count).to_i}%"
|
|
184
145
|
processed_inner = processed_inner.gsub(/<td class="sevk-column" style="([^"]*)"/) do
|
|
185
146
|
existing_style = Regexp.last_match(1)
|
|
186
|
-
if existing_style.include?(
|
|
147
|
+
if existing_style.include?('width:')
|
|
187
148
|
%(<td class="sevk-column" style="#{existing_style}")
|
|
188
149
|
else
|
|
189
150
|
%(<td class="sevk-column" style="width:#{equal_width};#{existing_style}")
|
|
@@ -191,25 +152,41 @@ module Sevk
|
|
|
191
152
|
end
|
|
192
153
|
end
|
|
193
154
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
155
|
+
if gap_num.positive?
|
|
156
|
+
spacer_td = [
|
|
157
|
+
%(</td><td class="sevk-gap" ),
|
|
158
|
+
%(style="width:#{gap_px}px;min-width:#{gap_px}px" width="#{gap_px}"></td>),
|
|
159
|
+
%(<td class="sevk-column")
|
|
160
|
+
].join
|
|
161
|
+
processed_inner = processed_inner.gsub(%r{</td>\s*<td class="sevk-column"}, spacer_td)
|
|
162
|
+
@gap_rules << [
|
|
163
|
+
".#{row_id} .sevk-gap{display:none !important;}",
|
|
164
|
+
".#{row_id} > tbody > tr > td.sevk-column{display:block !important;",
|
|
165
|
+
"width:100% !important;margin-bottom:#{gap_px}px !important;}",
|
|
166
|
+
".#{row_id} > tbody > tr > td.sevk-column:last-of-type{margin-bottom:0 !important;}"
|
|
167
|
+
].join
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
[
|
|
171
|
+
%(<table align="center" width="100%" border="0" cellPadding="0" ),
|
|
172
|
+
%(cellSpacing="0" role="presentation" class="sevk-row-table #{row_id}" style="#{style_str}">\n),
|
|
173
|
+
%(<tbody style="width:100%">\n),
|
|
174
|
+
%(<tr style="width:100%">#{processed_inner}</tr>\n),
|
|
175
|
+
"</tbody>\n",
|
|
176
|
+
'</table>'
|
|
177
|
+
].join
|
|
200
178
|
end
|
|
201
179
|
|
|
202
|
-
|
|
203
|
-
result = process_tag(result, "container") do |attrs, inner|
|
|
180
|
+
result = process_tag(result, 'container') do |attrs, inner|
|
|
204
181
|
style = extract_all_style_attributes(attrs)
|
|
205
182
|
td_style = {}
|
|
206
183
|
table_style = {}
|
|
207
184
|
|
|
208
|
-
# Visual styles go on <td>, layout styles stay on <table>
|
|
209
185
|
visual_keys = %w[
|
|
210
186
|
background-color background-image background-size background-position background-repeat
|
|
211
187
|
border border-top border-right border-bottom border-left border-color border-width border-style
|
|
212
|
-
border-radius border-top-left-radius border-top-right-radius
|
|
188
|
+
border-radius border-top-left-radius border-top-right-radius
|
|
189
|
+
border-bottom-left-radius border-bottom-right-radius
|
|
213
190
|
padding padding-top padding-right padding-bottom padding-left
|
|
214
191
|
]
|
|
215
192
|
style.each do |key, value|
|
|
@@ -220,155 +197,146 @@ module Sevk
|
|
|
220
197
|
end
|
|
221
198
|
end
|
|
222
199
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
table_style["border-collapse"] = "separate" if has_border_radius
|
|
200
|
+
has_border_radius = td_style['border-radius'] || td_style['border-top-left-radius'] ||
|
|
201
|
+
td_style['border-top-right-radius'] || td_style['border-bottom-left-radius'] ||
|
|
202
|
+
td_style['border-bottom-right-radius']
|
|
203
|
+
table_style['border-collapse'] = 'separate' if has_border_radius
|
|
228
204
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
table_style[
|
|
232
|
-
table_style["width"] = "100%"
|
|
205
|
+
if table_style['width'] && table_style['width'] != '100%' && table_style['width'] != 'auto'
|
|
206
|
+
table_style['max-width'] ||= table_style['width']
|
|
207
|
+
table_style['width'] = '100%'
|
|
233
208
|
end
|
|
234
209
|
|
|
235
210
|
table_style_str = style_to_string(table_style)
|
|
236
211
|
td_style_str = style_to_string(td_style)
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
212
|
+
# No `align="center"` on container: centering is opt-in via the
|
|
213
|
+
# user's markup (e.g. `margin="0 auto"`). Editor preview and the
|
|
214
|
+
# rendered email follow the same "no implicit centering" contract.
|
|
215
|
+
[
|
|
216
|
+
%(<table width="100%" border="0" cellPadding="0" cellSpacing="0" ),
|
|
217
|
+
%(role="presentation" style="#{table_style_str}">\n),
|
|
218
|
+
"<tbody>\n",
|
|
219
|
+
%(<tr style="width:100%">\n),
|
|
220
|
+
%(<td style="#{td_style_str}">#{inner}</td>\n),
|
|
221
|
+
"</tr>\n",
|
|
222
|
+
"</tbody>\n",
|
|
223
|
+
'</table>'
|
|
224
|
+
].join
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
result = process_tag(result, 'heading') do |attrs, inner|
|
|
228
|
+
level = attrs['level'].to_s.empty? ? '1' : attrs['level']
|
|
249
229
|
style = extract_all_style_attributes(attrs)
|
|
250
230
|
style['margin'] ||= '0'
|
|
251
231
|
style_str = style_to_string(style)
|
|
252
232
|
%(<h#{level} style="#{style_str}">#{inner}</h#{level}>)
|
|
253
233
|
end
|
|
254
234
|
|
|
255
|
-
|
|
256
|
-
result = process_tag(result, "paragraph") do |attrs, inner|
|
|
235
|
+
result = process_tag(result, 'paragraph') do |attrs, inner|
|
|
257
236
|
style = extract_all_style_attributes(attrs)
|
|
258
237
|
style['margin'] ||= '0'
|
|
259
238
|
style_str = style_to_string(style)
|
|
260
239
|
%(<p style="#{style_str}">#{inner}</p>)
|
|
261
240
|
end
|
|
262
241
|
|
|
263
|
-
|
|
264
|
-
result = process_tag(result, "text") do |attrs, inner|
|
|
242
|
+
result = process_tag(result, 'text') do |attrs, inner|
|
|
265
243
|
style = extract_all_style_attributes(attrs)
|
|
266
244
|
style_str = style_to_string(style)
|
|
267
245
|
%(<span style="#{style_str}">#{inner}</span>)
|
|
268
246
|
end
|
|
269
247
|
|
|
270
|
-
|
|
271
|
-
result = process_tag(result, "button") do |attrs, inner|
|
|
248
|
+
result = process_tag(result, 'button') do |attrs, inner|
|
|
272
249
|
process_button(attrs, inner)
|
|
273
250
|
end
|
|
274
251
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
height = attrs["height"]
|
|
252
|
+
result = result.gsub(%r{<image([^>]*)/?>}i) do
|
|
253
|
+
attrs = parse_attributes(Regexp.last_match(1) || '')
|
|
254
|
+
src = attrs['src'] || ''
|
|
255
|
+
alt = attrs['alt'] || ''
|
|
256
|
+
width = attrs['width']
|
|
257
|
+
height = attrs['height']
|
|
282
258
|
|
|
283
259
|
style = extract_all_style_attributes(attrs)
|
|
284
|
-
style[
|
|
285
|
-
style[
|
|
286
|
-
style["outline"] ||= "none"
|
|
287
|
-
style["border"] ||= "none"
|
|
288
|
-
style["text-decoration"] ||= "none"
|
|
260
|
+
style['vertical-align'] ||= 'middle'
|
|
261
|
+
style['max-width'] ||= '100%'
|
|
289
262
|
|
|
290
263
|
style_str = style_to_string(style)
|
|
291
|
-
|
|
292
|
-
|
|
264
|
+
width_value = width.to_s.sub('px', '')
|
|
265
|
+
height_value = height.to_s.sub('px', '')
|
|
266
|
+
width_attr = width_value.empty? ? '' : %( width="#{width_value}")
|
|
267
|
+
height_attr = height_value.empty? ? '' : %( height="#{height_value}")
|
|
293
268
|
|
|
294
269
|
%(<img src="#{src}" alt="#{alt}"#{width_attr}#{height_attr} style="#{style_str}" />)
|
|
295
270
|
end
|
|
296
271
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
attrs = parse_attributes(Regexp.last_match(1) || "")
|
|
272
|
+
result = result.gsub(%r{<divider([^>]*)/?>}i) do
|
|
273
|
+
attrs = parse_attributes(Regexp.last_match(1) || '')
|
|
300
274
|
style = extract_all_style_attributes(attrs)
|
|
301
275
|
style_str = style_to_string(style)
|
|
302
|
-
|
|
303
|
-
class_str = class_attr ? %( class="#{class_attr}") : ""
|
|
276
|
+
class_str = class_attribute(attrs)
|
|
304
277
|
%(<hr style="#{style_str}"#{class_str} />)
|
|
305
278
|
end
|
|
306
279
|
|
|
307
|
-
|
|
308
|
-
result = result.gsub(%r{</
|
|
280
|
+
result = result.gsub(%r{</divider>}i, '')
|
|
281
|
+
result = result.gsub(%r{</image>}i, '')
|
|
309
282
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
target = attrs["target"] || "_blank"
|
|
283
|
+
result = process_tag(result, 'sevk-link') do |attrs, inner|
|
|
284
|
+
href = attrs['href'].to_s.empty? ? '#' : attrs['href']
|
|
285
|
+
target = attrs['target'].to_s.empty? ? '_blank' : attrs['target']
|
|
314
286
|
style = extract_all_style_attributes(attrs)
|
|
315
287
|
style_str = style_to_string(style)
|
|
316
288
|
%(<a href="#{href}" target="#{target}" style="#{style_str}">#{inner}</a>)
|
|
317
289
|
end
|
|
318
290
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
tag = list_type == "ordered" ? "ol" : "ul"
|
|
291
|
+
result = process_tag(result, 'list') do |attrs, inner|
|
|
292
|
+
list_type = attrs['type'] || 'unordered'
|
|
293
|
+
tag = list_type == 'ordered' ? 'ol' : 'ul'
|
|
323
294
|
style = extract_all_style_attributes(attrs)
|
|
324
295
|
style['margin'] ||= '0'
|
|
325
|
-
style[
|
|
296
|
+
style['list-style-type'] = attrs['list-style-type'] if attrs['list-style-type']
|
|
326
297
|
style_str = style_to_string(style)
|
|
327
|
-
|
|
328
|
-
class_str = class_attr ? %( class="#{class_attr}") : ""
|
|
298
|
+
class_str = class_attribute(attrs)
|
|
329
299
|
%(<#{tag} style="#{style_str}"#{class_str}>#{inner}</#{tag}>)
|
|
330
300
|
end
|
|
331
301
|
|
|
332
|
-
|
|
333
|
-
result = process_tag(result, "li") do |attrs, inner|
|
|
302
|
+
result = process_tag(result, 'li') do |attrs, inner|
|
|
334
303
|
style = extract_all_style_attributes(attrs)
|
|
335
304
|
style_str = style_to_string(style)
|
|
336
|
-
|
|
337
|
-
class_str = class_attr ? %( class="#{class_attr}") : ""
|
|
305
|
+
class_str = class_attribute(attrs)
|
|
338
306
|
%(<li style="#{style_str}"#{class_str}>#{inner}</li>)
|
|
339
307
|
end
|
|
340
308
|
|
|
341
|
-
|
|
342
|
-
result = process_tag(result, "codeblock") do |attrs, inner|
|
|
309
|
+
result = process_tag(result, 'codeblock') do |attrs, inner|
|
|
343
310
|
process_codeblock(attrs, inner)
|
|
344
311
|
end
|
|
345
312
|
|
|
346
|
-
# Clean up stray Sevk closing tags
|
|
347
313
|
stray_closing_tags = %w[container section row column heading paragraph text button sevk-link]
|
|
348
314
|
stray_closing_tags.each do |tag|
|
|
349
|
-
result = result.gsub(%r{</#{tag}>}i,
|
|
315
|
+
result = result.gsub(%r{</#{tag}>}i, '')
|
|
350
316
|
end
|
|
351
317
|
|
|
352
|
-
# Clean up wrapper tags
|
|
353
318
|
wrapper_patterns = [
|
|
354
|
-
/<sevk-email[^>]*>/i,
|
|
355
|
-
/<sevk-body[^>]*>/i,
|
|
356
|
-
/<email[^>]*>/i,
|
|
357
|
-
/<mail[^>]*>/i,
|
|
358
|
-
/<body[^>]*>/i,
|
|
319
|
+
/<sevk-email[^>]*>/i, %r{</sevk-email>}i,
|
|
320
|
+
/<sevk-body[^>]*>/i, %r{</sevk-body>}i,
|
|
321
|
+
/<email[^>]*>/i, %r{</email>}i,
|
|
322
|
+
/<mail[^>]*>/i, %r{</mail>}i,
|
|
323
|
+
/<body[^>]*>/i, %r{</body>}i
|
|
359
324
|
]
|
|
360
325
|
wrapper_patterns.each do |pattern|
|
|
361
|
-
result = result.gsub(pattern,
|
|
326
|
+
result = result.gsub(pattern, '')
|
|
362
327
|
end
|
|
363
328
|
|
|
364
329
|
result.strip
|
|
365
330
|
end
|
|
366
331
|
|
|
332
|
+
# Outlook button padding hack ported from react-email's Button component
|
|
333
|
+
# (https://github.com/resend/react-email/blob/main/packages/components/src/button.tsx).
|
|
334
|
+
# Uses mso-padding-alt:0, hairspace runs scaled by mso-font-width, and
|
|
335
|
+
# mso-text-raise to get Outlook to honour horizontal/vertical padding.
|
|
367
336
|
def process_button(attrs, inner)
|
|
368
|
-
href = attrs[
|
|
337
|
+
href = attrs['href'].to_s.empty? ? '#' : attrs['href']
|
|
369
338
|
style = extract_all_style_attributes(attrs)
|
|
370
339
|
|
|
371
|
-
# Parse padding
|
|
372
340
|
padding_top, padding_right, padding_bottom, padding_left = parse_padding(style)
|
|
373
341
|
|
|
374
342
|
y = padding_top + padding_bottom
|
|
@@ -378,565 +346,100 @@ module Sevk
|
|
|
378
346
|
pr_font_width, pr_space_count = compute_font_width_and_space_count(padding_right)
|
|
379
347
|
|
|
380
348
|
button_style = {
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
349
|
+
'line-height' => '100%',
|
|
350
|
+
'text-decoration' => 'none',
|
|
351
|
+
'display' => 'inline-block',
|
|
352
|
+
'max-width' => '100%',
|
|
353
|
+
'mso-padding-alt' => '0px'
|
|
386
354
|
}
|
|
387
355
|
|
|
388
|
-
# Merge with extracted styles
|
|
389
356
|
button_style.merge!(style)
|
|
390
357
|
|
|
391
|
-
|
|
392
|
-
button_style[
|
|
393
|
-
button_style[
|
|
394
|
-
button_style[
|
|
395
|
-
button_style["padding-left"] = "#{padding_left}px"
|
|
358
|
+
button_style['padding-top'] = "#{padding_top}px"
|
|
359
|
+
button_style['padding-right'] = "#{padding_right}px"
|
|
360
|
+
button_style['padding-bottom'] = "#{padding_bottom}px"
|
|
361
|
+
button_style['padding-left'] = "#{padding_left}px"
|
|
396
362
|
|
|
397
363
|
style_str = style_to_string(button_style)
|
|
398
364
|
|
|
399
|
-
left_mso_spaces =
|
|
400
|
-
right_mso_spaces =
|
|
401
|
-
|
|
402
|
-
|
|
365
|
+
left_mso_spaces = ' ' * pl_space_count
|
|
366
|
+
right_mso_spaces = ' ' * pr_space_count
|
|
367
|
+
|
|
368
|
+
[
|
|
369
|
+
%(<a href="#{href}" target="_blank" style="#{style_str}">),
|
|
370
|
+
%(<!--[if mso]><i style="mso-font-width:#{(pl_font_width * 100).round}%;),
|
|
371
|
+
%(mso-text-raise:#{text_raise}" hidden>#{left_mso_spaces}</i><![endif]-->),
|
|
372
|
+
%(<span style="max-width:100%;display:inline-block;line-height:120%;),
|
|
373
|
+
%(mso-padding-alt:0px;mso-text-raise:#{px_to_pt(padding_bottom)}">#{inner}</span>),
|
|
374
|
+
%(<!--[if mso]><i style="mso-font-width:#{(pr_font_width * 100).round}%" hidden>),
|
|
375
|
+
%(#{right_mso_spaces}​</i><![endif]--></a>)
|
|
376
|
+
].join
|
|
403
377
|
end
|
|
404
378
|
|
|
405
|
-
|
|
406
|
-
ONE_DARK_THEME = {
|
|
407
|
-
"Comment" => { "color" => "#5c6370", "font-style" => "italic" },
|
|
408
|
-
"Comment.Single" => { "color" => "#5c6370", "font-style" => "italic" },
|
|
409
|
-
"Comment.Multiline" => { "color" => "#5c6370", "font-style" => "italic" },
|
|
410
|
-
"Comment.Preproc" => { "color" => "#5c6370", "font-style" => "italic" },
|
|
411
|
-
"Comment.Doc" => { "color" => "#5c6370", "font-style" => "italic" },
|
|
412
|
-
"Comment.Special" => { "color" => "#5c6370", "font-style" => "italic" },
|
|
413
|
-
"Keyword" => { "color" => "#c678dd" },
|
|
414
|
-
"Keyword.Constant" => { "color" => "#c678dd" },
|
|
415
|
-
"Keyword.Declaration" => { "color" => "#c678dd" },
|
|
416
|
-
"Keyword.Namespace" => { "color" => "#c678dd" },
|
|
417
|
-
"Keyword.Pseudo" => { "color" => "#c678dd" },
|
|
418
|
-
"Keyword.Reserved" => { "color" => "#c678dd" },
|
|
419
|
-
"Keyword.Type" => { "color" => "#c678dd" },
|
|
420
|
-
"Literal.String" => { "color" => "#98c379" },
|
|
421
|
-
"Literal.String.Affix" => { "color" => "#98c379" },
|
|
422
|
-
"Literal.String.Backtick" => { "color" => "#98c379" },
|
|
423
|
-
"Literal.String.Char" => { "color" => "#98c379" },
|
|
424
|
-
"Literal.String.Doc" => { "color" => "#98c379" },
|
|
425
|
-
"Literal.String.Double" => { "color" => "#98c379" },
|
|
426
|
-
"Literal.String.Escape" => { "color" => "#d19a66" },
|
|
427
|
-
"Literal.String.Heredoc" => { "color" => "#98c379" },
|
|
428
|
-
"Literal.String.Interpol" => { "color" => "#98c379" },
|
|
429
|
-
"Literal.String.Other" => { "color" => "#98c379" },
|
|
430
|
-
"Literal.String.Regex" => { "color" => "#98c379" },
|
|
431
|
-
"Literal.String.Single" => { "color" => "#98c379" },
|
|
432
|
-
"Literal.String.Symbol" => { "color" => "#e06c75" },
|
|
433
|
-
"Literal.Number" => { "color" => "#d19a66" },
|
|
434
|
-
"Literal.Number.Bin" => { "color" => "#d19a66" },
|
|
435
|
-
"Literal.Number.Float" => { "color" => "#d19a66" },
|
|
436
|
-
"Literal.Number.Hex" => { "color" => "#d19a66" },
|
|
437
|
-
"Literal.Number.Integer" => { "color" => "#d19a66" },
|
|
438
|
-
"Literal.Number.Oct" => { "color" => "#d19a66" },
|
|
439
|
-
"Name.Function" => { "color" => "#61afef" },
|
|
440
|
-
"Name.Function.Magic" => { "color" => "#61afef" },
|
|
441
|
-
"Name.Builtin" => { "color" => "#61afef" },
|
|
442
|
-
"Name.Builtin.Pseudo" => { "color" => "#e06c75" },
|
|
443
|
-
"Name.Variable" => { "color" => "#e06c75" },
|
|
444
|
-
"Name.Variable.Class" => { "color" => "#e06c75" },
|
|
445
|
-
"Name.Variable.Global" => { "color" => "#e06c75" },
|
|
446
|
-
"Name.Variable.Instance" => { "color" => "#e06c75" },
|
|
447
|
-
"Name.Variable.Magic" => { "color" => "#e06c75" },
|
|
448
|
-
"Name.Class" => { "color" => "#d19a66" },
|
|
449
|
-
"Name.Constant" => { "color" => "#d19a66" },
|
|
450
|
-
"Name.Decorator" => { "color" => "#61afef" },
|
|
451
|
-
"Name.Attribute" => { "color" => "#d19a66" },
|
|
452
|
-
"Name.Tag" => { "color" => "#e06c75" },
|
|
453
|
-
"Name.Namespace" => { "color" => "#d19a66" },
|
|
454
|
-
"Operator" => { "color" => "#61afef" },
|
|
455
|
-
"Operator.Word" => { "color" => "#c678dd" },
|
|
456
|
-
"Punctuation" => { "color" => "#abb2bf" },
|
|
457
|
-
"Generic.Deleted" => { "color" => "#e06c75" },
|
|
458
|
-
"Generic.Inserted" => { "color" => "#98c379" },
|
|
459
|
-
"Generic.Heading" => { "color" => "#61afef", "font-weight" => "bold" },
|
|
460
|
-
"Generic.Subheading" => { "color" => "#61afef" },
|
|
461
|
-
"Generic.Emph" => { "font-style" => "italic" },
|
|
462
|
-
"Generic.Strong" => { "font-weight" => "bold" },
|
|
463
|
-
}.freeze
|
|
464
|
-
|
|
465
|
-
def process_codeblock(attrs, inner)
|
|
466
|
-
language = attrs["language"] || "text"
|
|
467
|
-
custom_style = extract_all_style_attributes(attrs)
|
|
468
|
-
|
|
469
|
-
code = inner.strip
|
|
470
|
-
|
|
471
|
-
# Try to find a Rouge lexer for the language
|
|
472
|
-
lexer = begin
|
|
473
|
-
Rouge::Lexer.find(language)&.new
|
|
474
|
-
rescue StandardError
|
|
475
|
-
nil
|
|
476
|
-
end
|
|
477
|
-
|
|
478
|
-
# Base pre styles (One Dark)
|
|
479
|
-
base_style = {
|
|
480
|
-
"background-color" => "#282c34",
|
|
481
|
-
"color" => "#abb2bf",
|
|
482
|
-
"font-family" => "'Fira Code', 'Fira Mono', Menlo, Consolas, 'DejaVu Sans Mono', monospace",
|
|
483
|
-
"font-size" => "13px",
|
|
484
|
-
"direction" => "ltr",
|
|
485
|
-
"text-align" => "left",
|
|
486
|
-
"white-space" => "pre",
|
|
487
|
-
"word-spacing" => "normal",
|
|
488
|
-
"word-break" => "normal",
|
|
489
|
-
"line-height" => "1.5",
|
|
490
|
-
"tab-size" => "2",
|
|
491
|
-
"hyphens" => "none",
|
|
492
|
-
"padding" => "1em",
|
|
493
|
-
"margin" => "0.5em 0",
|
|
494
|
-
"overflow" => "auto",
|
|
495
|
-
"border-radius" => "0.3em",
|
|
496
|
-
"width" => "100%",
|
|
497
|
-
"box-sizing" => "border-box"
|
|
498
|
-
}
|
|
499
|
-
base_style.merge!(custom_style)
|
|
500
|
-
|
|
501
|
-
if lexer.nil?
|
|
502
|
-
# Fallback: plain pre/code with no highlighting
|
|
503
|
-
style_str = style_to_string(base_style)
|
|
504
|
-
escaped = code.gsub("&", "&").gsub("<", "<").gsub(">", ">")
|
|
505
|
-
return %(<pre style="#{style_str}"><code>#{escaped}</code></pre>)
|
|
506
|
-
end
|
|
507
|
-
|
|
508
|
-
# Tokenize and highlight
|
|
509
|
-
tokens = lexer.lex(code)
|
|
510
|
-
lines = build_highlighted_lines(tokens)
|
|
511
|
-
|
|
512
|
-
lines_html = lines.map do |line_tokens|
|
|
513
|
-
spans = line_tokens.map { |tok_type, tok_text| render_rouge_token(tok_type, tok_text) }.join
|
|
514
|
-
%(<p style="margin:0;min-height:1em">#{spans}</p>)
|
|
515
|
-
end
|
|
516
|
-
|
|
517
|
-
style_str = style_to_string(base_style)
|
|
518
|
-
%(<pre style="#{style_str}"><code>#{lines_html.join}</code></pre>)
|
|
519
|
-
end
|
|
520
|
-
|
|
521
|
-
def build_highlighted_lines(tokens)
|
|
522
|
-
lines = [[]]
|
|
523
|
-
tokens.each do |tok_type, tok_text|
|
|
524
|
-
parts = tok_text.split(/(\n)/, -1)
|
|
525
|
-
parts.each_with_index do |part, i|
|
|
526
|
-
if part == "\n"
|
|
527
|
-
lines << []
|
|
528
|
-
elsif !part.empty?
|
|
529
|
-
lines.last << [tok_type, part]
|
|
530
|
-
end
|
|
531
|
-
end
|
|
532
|
-
end
|
|
533
|
-
lines
|
|
534
|
-
end
|
|
535
|
-
|
|
536
|
-
def render_rouge_token(tok_type, text)
|
|
537
|
-
escaped = text.gsub("&", "&").gsub("<", "<").gsub(">", ">")
|
|
538
|
-
# Replace spaces with non-breaking space + zero-width joiner + zero-width space for email safety
|
|
539
|
-
escaped = escaped.gsub(" ", "\u00A0\u200D\u200B")
|
|
540
|
-
|
|
541
|
-
styles = resolve_token_styles(tok_type)
|
|
542
|
-
if styles.empty?
|
|
543
|
-
escaped
|
|
544
|
-
else
|
|
545
|
-
style_str = styles.map { |k, v| "#{k}:#{v}" }.join(";")
|
|
546
|
-
%(<span style="#{style_str}">#{escaped}</span>)
|
|
547
|
-
end
|
|
548
|
-
end
|
|
549
|
-
|
|
550
|
-
def resolve_token_styles(tok_type)
|
|
551
|
-
# Walk up the token hierarchy to find a matching theme entry
|
|
552
|
-
# e.g., Token::Literal::String::Double -> "Literal.String.Double" -> "Literal.String" -> ...
|
|
553
|
-
qualname = tok_type.qualname
|
|
554
|
-
loop do
|
|
555
|
-
return ONE_DARK_THEME[qualname].dup if ONE_DARK_THEME.key?(qualname)
|
|
556
|
-
|
|
557
|
-
dot_index = qualname.rindex(".")
|
|
558
|
-
break unless dot_index
|
|
559
|
-
|
|
560
|
-
qualname = qualname[0...dot_index]
|
|
561
|
-
end
|
|
562
|
-
|
|
563
|
-
# Check base token name
|
|
564
|
-
return ONE_DARK_THEME[qualname].dup if ONE_DARK_THEME.key?(qualname)
|
|
565
|
-
|
|
566
|
-
{}
|
|
567
|
-
end
|
|
568
|
-
|
|
569
|
-
def truthy?(val)
|
|
570
|
-
return false if val.nil?
|
|
571
|
-
return false if val == false
|
|
572
|
-
return false if val == 0
|
|
573
|
-
return false if val.is_a?(String) && val.empty?
|
|
574
|
-
return false if val.is_a?(Array) && val.empty?
|
|
575
|
-
|
|
576
|
-
true
|
|
577
|
-
end
|
|
578
|
-
|
|
579
|
-
def evaluate_condition(expr, config)
|
|
580
|
-
trimmed = expr.strip
|
|
581
|
-
|
|
582
|
-
# OR: split on ||, return true if any part is true
|
|
583
|
-
if trimmed.include?('||')
|
|
584
|
-
return trimmed.split('||').any? { |part| evaluate_condition(part, config) }
|
|
585
|
-
end
|
|
586
|
-
|
|
587
|
-
# AND: split on &&, return true if all parts are true
|
|
588
|
-
if trimmed.include?('&&')
|
|
589
|
-
return trimmed.split('&&').all? { |part| evaluate_condition(part, config) }
|
|
590
|
-
end
|
|
591
|
-
|
|
592
|
-
# Equality: key == "value"
|
|
593
|
-
if (eq_match = trimmed.match(/^(\w+)\s*==\s*"([^"]*)"$/))
|
|
594
|
-
return (config[eq_match[1]] || '').to_s == eq_match[2]
|
|
595
|
-
end
|
|
596
|
-
|
|
597
|
-
# Inequality: key != "value"
|
|
598
|
-
if (neq_match = trimmed.match(/^(\w+)\s*!=\s*"([^"]*)"$/))
|
|
599
|
-
return (config[neq_match[1]] || '').to_s != neq_match[2]
|
|
600
|
-
end
|
|
601
|
-
|
|
602
|
-
# Simple truthy check
|
|
603
|
-
truthy?(config[trimmed])
|
|
604
|
-
end
|
|
605
|
-
|
|
606
|
-
def render_template(template, config)
|
|
607
|
-
result = template.dup
|
|
608
|
-
|
|
609
|
-
# Process #each loops
|
|
610
|
-
result = result.gsub(/\{%#each\s+(\w+)\s+as\s+(\w+)%\}([\s\S]*?)\{%\/each%\}/) do
|
|
611
|
-
key = Regexp.last_match(1)
|
|
612
|
-
alias_name = Regexp.last_match(2)
|
|
613
|
-
body = Regexp.last_match(3)
|
|
614
|
-
arr = config[key]
|
|
615
|
-
if arr.is_a?(Array)
|
|
616
|
-
arr.map do |item|
|
|
617
|
-
iteration = body.dup
|
|
618
|
-
if item.is_a?(Hash)
|
|
619
|
-
item.each do |prop, val|
|
|
620
|
-
iteration = iteration.gsub(/\{%#{Regexp.escape(alias_name)}\.#{Regexp.escape(prop)}(?:\s*\?\?\s*([^%]*?))?\s*%\}/) do
|
|
621
|
-
fallback = Regexp.last_match(1)
|
|
622
|
-
truthy?(val) ? val.to_s : (fallback ? fallback.strip : '')
|
|
623
|
-
end
|
|
624
|
-
end
|
|
625
|
-
end
|
|
626
|
-
# Replace bare alias reference
|
|
627
|
-
iteration = iteration.gsub(/\{%#{Regexp.escape(alias_name)}(?:\s*\?\?\s*([^%]*?))?\s*%\}/) do
|
|
628
|
-
fallback = Regexp.last_match(1)
|
|
629
|
-
truthy?(item) ? item.to_s : (fallback ? fallback.strip : '')
|
|
630
|
-
end
|
|
631
|
-
iteration
|
|
632
|
-
end.join
|
|
633
|
-
else
|
|
634
|
-
''
|
|
635
|
-
end
|
|
636
|
-
end
|
|
637
|
-
|
|
638
|
-
# Process nested #if conditionals (innermost first)
|
|
639
|
-
loop do
|
|
640
|
-
matched = false
|
|
641
|
-
result = result.gsub(/\{%#if\s+([^%]+)%\}((?:(?!\{%#if\s)[\s\S])*?)\{%\/if%\}/) do
|
|
642
|
-
matched = true
|
|
643
|
-
condition = Regexp.last_match(1)
|
|
644
|
-
body = Regexp.last_match(2)
|
|
645
|
-
if body.include?('{%else%}')
|
|
646
|
-
parts = body.split('{%else%}', 2)
|
|
647
|
-
evaluate_condition(condition, config) ? parts[0] : parts[1]
|
|
648
|
-
else
|
|
649
|
-
evaluate_condition(condition, config) ? body : ''
|
|
650
|
-
end
|
|
651
|
-
end
|
|
652
|
-
break unless matched
|
|
653
|
-
end
|
|
654
|
-
|
|
655
|
-
# Process variable injection with fallback
|
|
656
|
-
result = result.gsub(/\{%(\w+)(?:\s*\?\?\s*([^%]*?))?\s*%\}/) do
|
|
657
|
-
key = Regexp.last_match(1)
|
|
658
|
-
fallback = Regexp.last_match(2)
|
|
659
|
-
val = config[key]
|
|
660
|
-
truthy?(val) ? val.to_s : (fallback ? fallback.strip : '')
|
|
661
|
-
end
|
|
662
|
-
|
|
663
|
-
result
|
|
664
|
-
end
|
|
665
|
-
|
|
666
|
-
def process_block_tag(attrs, inner = '')
|
|
667
|
-
template = inner.strip.empty? ? (attrs['template'] || '') : inner.strip
|
|
668
|
-
return '' if template.empty?
|
|
669
|
-
|
|
670
|
-
config_str = (attrs['config'] || '{}').gsub("'", '"').gsub('"', '"').gsub('&', '&')
|
|
671
|
-
config = begin
|
|
672
|
-
JSON.parse(config_str)
|
|
673
|
-
rescue StandardError
|
|
674
|
-
{}
|
|
675
|
-
end
|
|
676
|
-
render_template(template, config)
|
|
677
|
-
end
|
|
678
|
-
|
|
679
|
-
def parse_padding(style)
|
|
680
|
-
if style["padding"]
|
|
681
|
-
parts = style["padding"].split
|
|
682
|
-
case parts.length
|
|
683
|
-
when 1
|
|
684
|
-
val = parse_px(parts[0])
|
|
685
|
-
[val, val, val, val]
|
|
686
|
-
when 2
|
|
687
|
-
vertical = parse_px(parts[0])
|
|
688
|
-
horizontal = parse_px(parts[1])
|
|
689
|
-
[vertical, horizontal, vertical, horizontal]
|
|
690
|
-
when 4
|
|
691
|
-
[parse_px(parts[0]), parse_px(parts[1]), parse_px(parts[2]), parse_px(parts[3])]
|
|
692
|
-
else
|
|
693
|
-
[0, 0, 0, 0]
|
|
694
|
-
end
|
|
695
|
-
else
|
|
696
|
-
[
|
|
697
|
-
parse_px(style["padding-top"] || "0"),
|
|
698
|
-
parse_px(style["padding-right"] || "0"),
|
|
699
|
-
parse_px(style["padding-bottom"] || "0"),
|
|
700
|
-
parse_px(style["padding-left"] || "0")
|
|
701
|
-
]
|
|
702
|
-
end
|
|
703
|
-
end
|
|
704
|
-
|
|
705
|
-
def parse_px(s)
|
|
706
|
-
s.to_s.gsub("px", "").to_i
|
|
707
|
-
end
|
|
708
|
-
|
|
709
|
-
def px_to_pt(px)
|
|
710
|
-
(px * 3) / 4
|
|
711
|
-
end
|
|
712
|
-
|
|
713
|
-
def compute_font_width_and_space_count(expected_width)
|
|
714
|
-
return [0, 0] if expected_width == 0
|
|
715
|
-
|
|
716
|
-
smallest_space_count = 0
|
|
717
|
-
max_font_width = 5.0
|
|
718
|
-
|
|
719
|
-
loop do
|
|
720
|
-
required_font_width = if smallest_space_count > 0
|
|
721
|
-
expected_width.to_f / smallest_space_count / 2.0
|
|
722
|
-
else
|
|
723
|
-
Float::INFINITY
|
|
724
|
-
end
|
|
725
|
-
|
|
726
|
-
return [required_font_width, smallest_space_count] if required_font_width <= max_font_width
|
|
727
|
-
|
|
728
|
-
smallest_space_count += 1
|
|
729
|
-
end
|
|
730
|
-
end
|
|
731
|
-
|
|
732
|
-
def process_tag(content, tag_name)
|
|
379
|
+
def process_tag(content, tag_name, &processor)
|
|
733
380
|
result = content
|
|
734
|
-
|
|
735
|
-
close_tag = "</#{tag_name}>"
|
|
736
|
-
open_tag_start = "<#{tag_name}"
|
|
737
|
-
|
|
738
|
-
max_iterations = 10_000
|
|
739
|
-
iterations = 0
|
|
740
|
-
|
|
741
|
-
while iterations < max_iterations
|
|
742
|
-
iterations += 1
|
|
743
|
-
|
|
744
|
-
# Find all opening tags
|
|
745
|
-
matches = result.to_enum(:scan, open_pattern).map { Regexp.last_match }
|
|
746
|
-
break if matches.empty?
|
|
747
|
-
|
|
748
|
-
processed = false
|
|
749
|
-
|
|
750
|
-
# Find the innermost tag (one that has no nested same tags)
|
|
751
|
-
matches.reverse_each do |match|
|
|
752
|
-
start = match.begin(0)
|
|
753
|
-
inner_start = match.end(0)
|
|
754
|
-
attrs_str = match[1]
|
|
755
|
-
|
|
756
|
-
# Find the next close tag after this opening tag (case-insensitive)
|
|
757
|
-
close_match = result.match(Regexp.new("<\\/#{tag_name}>", Regexp::IGNORECASE), inner_start)
|
|
758
|
-
next unless close_match
|
|
381
|
+
patterns = tag_patterns(tag_name)
|
|
759
382
|
|
|
760
|
-
|
|
761
|
-
|
|
383
|
+
MAX_TAG_ITERATIONS.times do
|
|
384
|
+
charge_scan_work(result.bytesize)
|
|
385
|
+
replacements = collect_replacements(result, patterns, &processor)
|
|
386
|
+
break if replacements.empty?
|
|
762
387
|
|
|
763
|
-
|
|
764
|
-
|
|
388
|
+
rebuilt = rebuild_result(result, replacements)
|
|
389
|
+
break if rebuilt == result
|
|
765
390
|
|
|
766
|
-
|
|
767
|
-
attrs = parse_attributes(attrs_str)
|
|
768
|
-
replacement = yield(attrs, inner)
|
|
769
|
-
end_pos = close_pos + close_tag.length
|
|
770
|
-
|
|
771
|
-
result = result[0...start] + replacement + result[end_pos..]
|
|
772
|
-
processed = true
|
|
773
|
-
break
|
|
774
|
-
end
|
|
775
|
-
|
|
776
|
-
break unless processed
|
|
391
|
+
result = rebuilt
|
|
777
392
|
end
|
|
778
393
|
|
|
779
394
|
result
|
|
780
395
|
end
|
|
781
396
|
|
|
782
|
-
def
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
end
|
|
787
|
-
attrs
|
|
788
|
-
end
|
|
789
|
-
|
|
790
|
-
def extract_all_style_attributes(attrs)
|
|
791
|
-
style = {}
|
|
792
|
-
|
|
793
|
-
# Typography attributes
|
|
794
|
-
if attrs["text-color"]
|
|
795
|
-
style["color"] = attrs["text-color"]
|
|
796
|
-
elsif attrs["color"]
|
|
797
|
-
style["color"] = attrs["color"]
|
|
798
|
-
end
|
|
799
|
-
style["background-color"] = attrs["background-color"] if attrs["background-color"]
|
|
800
|
-
style["font-size"] = attrs["font-size"] if attrs["font-size"]
|
|
801
|
-
style["font-family"] = attrs["font-family"] if attrs["font-family"]
|
|
802
|
-
style["font-weight"] = attrs["font-weight"] if attrs["font-weight"]
|
|
803
|
-
style["line-height"] = attrs["line-height"] if attrs["line-height"]
|
|
804
|
-
style["text-align"] = attrs["text-align"] if attrs["text-align"]
|
|
805
|
-
style["text-decoration"] = attrs["text-decoration"] if attrs["text-decoration"]
|
|
806
|
-
|
|
807
|
-
# Dimensions
|
|
808
|
-
style["width"] = attrs["width"] if attrs["width"]
|
|
809
|
-
style["height"] = attrs["height"] if attrs["height"]
|
|
810
|
-
style["max-width"] = attrs["max-width"] if attrs["max-width"]
|
|
811
|
-
style["max-height"] = attrs["max-height"] if attrs["max-height"]
|
|
812
|
-
style["min-width"] = attrs["min-width"] if attrs["min-width"]
|
|
813
|
-
style["min-height"] = attrs["min-height"] if attrs["min-height"]
|
|
814
|
-
|
|
815
|
-
# Spacing - Padding
|
|
816
|
-
if attrs["padding"]
|
|
817
|
-
style["padding"] = attrs["padding"]
|
|
818
|
-
else
|
|
819
|
-
style["padding-top"] = attrs["padding-top"] if attrs["padding-top"]
|
|
820
|
-
style["padding-right"] = attrs["padding-right"] if attrs["padding-right"]
|
|
821
|
-
style["padding-bottom"] = attrs["padding-bottom"] if attrs["padding-bottom"]
|
|
822
|
-
style["padding-left"] = attrs["padding-left"] if attrs["padding-left"]
|
|
823
|
-
end
|
|
397
|
+
def collect_replacements(result, patterns, &processor)
|
|
398
|
+
scanner = StringScanner.new(result)
|
|
399
|
+
replacements = []
|
|
400
|
+
consumed_until = 0
|
|
824
401
|
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
else
|
|
829
|
-
style["margin-top"] = attrs["margin-top"] if attrs["margin-top"]
|
|
830
|
-
style["margin-right"] = attrs["margin-right"] if attrs["margin-right"]
|
|
831
|
-
style["margin-bottom"] = attrs["margin-bottom"] if attrs["margin-bottom"]
|
|
832
|
-
style["margin-left"] = attrs["margin-left"] if attrs["margin-left"]
|
|
833
|
-
end
|
|
402
|
+
while scanner.skip_until(patterns.open)
|
|
403
|
+
start = scanner.pos - scanner.matched_size
|
|
404
|
+
next if start < consumed_until
|
|
834
405
|
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
style["border-right"] = attrs["border-right"] if attrs["border-right"]
|
|
841
|
-
style["border-bottom"] = attrs["border-bottom"] if attrs["border-bottom"]
|
|
842
|
-
style["border-left"] = attrs["border-left"] if attrs["border-left"]
|
|
843
|
-
style["border-color"] = attrs["border-color"] if attrs["border-color"]
|
|
844
|
-
style["border-width"] = attrs["border-width"] if attrs["border-width"]
|
|
845
|
-
style["border-style"] = attrs["border-style"] if attrs["border-style"]
|
|
846
|
-
end
|
|
406
|
+
attrs_str = scanner[1]
|
|
407
|
+
inner_start = scanner.pos
|
|
408
|
+
close_pos = find_close(result, patterns, inner_start)
|
|
409
|
+
charge_scan_work((close_pos || result.bytesize) - inner_start)
|
|
410
|
+
next unless close_pos
|
|
847
411
|
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
style["border-radius"] = attrs["border-radius"]
|
|
851
|
-
else
|
|
852
|
-
style["border-top-left-radius"] = attrs["border-top-left-radius"] if attrs["border-top-left-radius"]
|
|
853
|
-
style["border-top-right-radius"] = attrs["border-top-right-radius"] if attrs["border-top-right-radius"]
|
|
854
|
-
style["border-bottom-left-radius"] = attrs["border-bottom-left-radius"] if attrs["border-bottom-left-radius"]
|
|
855
|
-
style["border-bottom-right-radius"] = attrs["border-bottom-right-radius"] if attrs["border-bottom-right-radius"]
|
|
856
|
-
end
|
|
412
|
+
inner = result.byteslice(inner_start, close_pos - inner_start)
|
|
413
|
+
next if inner.match?(patterns.nested)
|
|
857
414
|
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
style["background-image"] = "url('#{attrs["background-image"]}')"
|
|
861
|
-
style["background-size"] = attrs["background-size"] || "cover"
|
|
862
|
-
style["background-position"] = attrs["background-position"] || "center"
|
|
863
|
-
style["background-repeat"] = attrs["background-repeat"] || "no-repeat"
|
|
864
|
-
else
|
|
865
|
-
style["background-size"] = attrs["background-size"] if attrs["background-size"]
|
|
866
|
-
style["background-position"] = attrs["background-position"] if attrs["background-position"]
|
|
867
|
-
style["background-repeat"] = attrs["background-repeat"] if attrs["background-repeat"]
|
|
415
|
+
consumed_until = close_pos + patterns.close_length
|
|
416
|
+
replacements << [start, consumed_until, processor.call(parse_attributes(attrs_str), inner)]
|
|
868
417
|
end
|
|
869
418
|
|
|
870
|
-
|
|
419
|
+
replacements
|
|
871
420
|
end
|
|
872
421
|
|
|
873
|
-
def
|
|
874
|
-
|
|
422
|
+
def find_close(result, patterns, inner_start)
|
|
423
|
+
scanner = StringScanner.new(result)
|
|
424
|
+
scanner.pos = inner_start
|
|
425
|
+
scanner.skip_until(patterns.close) ? scanner.pos - patterns.close_length : nil
|
|
875
426
|
end
|
|
876
427
|
|
|
877
|
-
def
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
styles = @head_settings.styles.empty? ? "" : %(<style type="text/css">#{@head_settings.styles}</style>)
|
|
888
|
-
|
|
889
|
-
preview_text = ""
|
|
890
|
-
unless @head_settings.preview_text.empty?
|
|
891
|
-
preview_text = %(<div style="display:none;font-size:1px;color:#ffffff;line-height:1px;max-height:0px;max-width:0px;opacity:0;overflow:hidden;">#{CGI.escapeHTML(@head_settings.preview_text)}</div>)
|
|
428
|
+
def rebuild_result(result, replacements)
|
|
429
|
+
rebuilt = +''
|
|
430
|
+
cursor = 0
|
|
431
|
+
replacements.each do |r_start, r_end, r_text|
|
|
432
|
+
rebuilt << result.byteslice(cursor, r_start - cursor) << r_text
|
|
433
|
+
cursor = r_end
|
|
434
|
+
if rebuilt.bytesize > MAX_RENDER_OUTPUT
|
|
435
|
+
raise ArgumentError, "Email markup expands beyond the maximum size of #{MAX_RENDER_OUTPUT} bytes"
|
|
436
|
+
end
|
|
892
437
|
end
|
|
893
|
-
|
|
894
|
-
<<~HTML
|
|
895
|
-
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
|
896
|
-
<html lang="#{lang}" dir="#{dir}" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
|
897
|
-
<head>
|
|
898
|
-
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/>
|
|
899
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
|
900
|
-
<meta name="x-apple-disable-message-reformatting"/>
|
|
901
|
-
<meta content="IE=edge" http-equiv="X-UA-Compatible"/>
|
|
902
|
-
<meta name="format-detection" content="telephone=no,address=no,email=no,date=no,url=no"/>
|
|
903
|
-
<!--[if mso]>
|
|
904
|
-
<noscript>
|
|
905
|
-
<xml>
|
|
906
|
-
<o:OfficeDocumentSettings>
|
|
907
|
-
<o:AllowPNG/>
|
|
908
|
-
<o:PixelsPerInch>96</o:PixelsPerInch>
|
|
909
|
-
</o:OfficeDocumentSettings>
|
|
910
|
-
</xml>
|
|
911
|
-
</noscript>
|
|
912
|
-
<![endif]-->
|
|
913
|
-
<style type="text/css">
|
|
914
|
-
#outlook a { padding: 0; }
|
|
915
|
-
body { margin: 0; padding: 0; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; }
|
|
916
|
-
table, td { border-collapse: collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
|
|
917
|
-
.sevk-row-table { border-collapse: separate !important; }
|
|
918
|
-
img { border: 0; height: auto; line-height: 100%; outline: none; text-decoration: none; -ms-interpolation-mode: bicubic; }
|
|
919
|
-
@media only screen and (max-width: 479px) {
|
|
920
|
-
.sevk-row-table { width: 100% !important; }
|
|
921
|
-
.sevk-column { display: block !important; width: 100% !important; max-width: 100% !important; }
|
|
922
|
-
}
|
|
923
|
-
</style>
|
|
924
|
-
#{title}
|
|
925
|
-
#{font_links}
|
|
926
|
-
#{styles}
|
|
927
|
-
</head>
|
|
928
|
-
<body style="margin:0;padding:0;word-spacing:normal;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;font-family:ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;">
|
|
929
|
-
<div aria-roledescription="email" role="article">
|
|
930
|
-
#{preview_text}
|
|
931
|
-
#{content}
|
|
932
|
-
</div>
|
|
933
|
-
</body>
|
|
934
|
-
</html>
|
|
935
|
-
HTML
|
|
438
|
+
rebuilt << result.byteslice(cursor, result.bytesize - cursor)
|
|
936
439
|
end
|
|
937
440
|
end
|
|
938
441
|
|
|
939
|
-
#
|
|
442
|
+
# Converts sevk markup into complete, email-compatible HTML.
|
|
940
443
|
def self.render(markup)
|
|
941
444
|
Renderer.new.render(markup)
|
|
942
445
|
end
|