sevk 0.1.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 -10
- data/lib/sevk/client.rb +148 -58
- 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 +282 -351
- 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 +30 -11
- data/lib/sevk/resources/base.rb +15 -3
- data/lib/sevk/resources/broadcasts.rb +91 -3
- data/lib/sevk/resources/contacts.rb +39 -12
- data/lib/sevk/resources/domains.rb +41 -3
- data/lib/sevk/resources/emails.rb +91 -9
- data/lib/sevk/resources/inbound.rb +123 -0
- data/lib/sevk/resources/outbound.rb +55 -0
- data/lib/sevk/resources/segments.rb +37 -15
- data/lib/sevk/resources/subscriptions.rb +18 -11
- data/lib/sevk/resources/templates.rb +20 -8
- data/lib/sevk/resources/topics.rb +37 -14
- data/lib/sevk/resources/usage.rb +15 -0
- data/lib/sevk/resources/webhooks.rb +52 -0
- data/lib/sevk/version.rb +1 -1
- data/lib/sevk/webhook_verifier.rb +91 -0
- data/lib/sevk.rb +24 -192
- metadata +25 -82
- data/Rakefile +0 -42
- data/sevk.gemspec +0 -40
data/lib/sevk/markup/renderer.rb
CHANGED
|
@@ -1,57 +1,64 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
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'
|
|
15
|
+
|
|
3
16
|
module Sevk
|
|
4
17
|
module Markup
|
|
5
|
-
# Font configuration
|
|
6
18
|
class FontConfig
|
|
7
19
|
attr_accessor :id, :name, :url
|
|
8
20
|
|
|
9
|
-
def initialize(id:
|
|
21
|
+
def initialize(id: '', name: '', url: '')
|
|
10
22
|
@id = id
|
|
11
23
|
@name = name
|
|
12
24
|
@url = url
|
|
13
25
|
end
|
|
14
26
|
end
|
|
15
27
|
|
|
16
|
-
# Head settings for email generation
|
|
17
28
|
class EmailHeadSettings
|
|
18
|
-
attr_accessor :title, :preview_text, :styles, :fonts
|
|
29
|
+
attr_accessor :title, :preview_text, :styles, :fonts, :lang, :dir
|
|
19
30
|
|
|
20
31
|
def initialize
|
|
21
|
-
@title =
|
|
22
|
-
@preview_text =
|
|
23
|
-
@styles =
|
|
32
|
+
@title = ''
|
|
33
|
+
@preview_text = ''
|
|
34
|
+
@styles = ''
|
|
24
35
|
@fonts = []
|
|
36
|
+
@lang = ''
|
|
37
|
+
@dir = ''
|
|
25
38
|
end
|
|
26
39
|
end
|
|
27
40
|
|
|
28
|
-
# Parsed email content
|
|
29
|
-
class ParsedEmailContent
|
|
30
|
-
attr_accessor :body, :head_settings
|
|
31
|
-
|
|
32
|
-
def initialize
|
|
33
|
-
@body = ""
|
|
34
|
-
@head_settings = EmailHeadSettings.new
|
|
35
|
-
end
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
# Sevk Markup Renderer
|
|
39
|
-
# Converts Sevk markup to email-compatible HTML using regex-based parsing (like Node.js)
|
|
40
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
|
+
|
|
41
51
|
def initialize
|
|
42
52
|
@head_settings = EmailHeadSettings.new
|
|
43
53
|
end
|
|
44
54
|
|
|
55
|
+
# Converts sevk markup into complete, email-compatible HTML.
|
|
45
56
|
def render(markup)
|
|
46
|
-
|
|
57
|
+
@head_settings = EmailHeadSettings.new
|
|
47
58
|
parse_head_settings(markup)
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
# Process markup using regex
|
|
53
|
-
processed = process_markup(markup)
|
|
54
|
-
|
|
59
|
+
body_content = extract_body_content(markup)
|
|
60
|
+
normalized = normalize_markup(body_content)
|
|
61
|
+
processed = process_markup(normalized)
|
|
55
62
|
generate_html(processed)
|
|
56
63
|
end
|
|
57
64
|
|
|
@@ -60,205 +67,276 @@ module Sevk
|
|
|
60
67
|
def normalize_markup(content)
|
|
61
68
|
result = content
|
|
62
69
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
result = result.gsub(/<link\s+href=/i, "<sevk-link href=")
|
|
66
|
-
result = result.gsub("</link>", "</sevk-link>")
|
|
67
|
-
end
|
|
70
|
+
result = result.gsub(/<link(\s+(?:[\w-]+=(?:"[^"]*"|'[^']*')\s+)*)href=/i, '<sevk-link\1href=')
|
|
71
|
+
result = result.gsub(%r{</link>}i, '</sevk-link>')
|
|
68
72
|
|
|
69
|
-
|
|
70
|
-
result = "<mail><body>#{result}</body></mail>"
|
|
71
|
-
end
|
|
73
|
+
result = "<mail><body>#{result}</body></mail>" unless result.match?(/<(?:sevk-email|email|mail)(?=[\s>])/i)
|
|
72
74
|
|
|
73
75
|
result
|
|
74
76
|
end
|
|
75
77
|
|
|
76
|
-
def
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
# Extract preview
|
|
83
|
-
if (match = markup.match(/<preview[^>]*>([\s\S]*?)<\/preview>/i))
|
|
84
|
-
@head_settings.preview_text = match[1].strip
|
|
85
|
-
end
|
|
78
|
+
def process_markup(content)
|
|
79
|
+
@gap_rules = []
|
|
80
|
+
@codeblock_budget = 0
|
|
81
|
+
@scan_work = 0
|
|
82
|
+
assert_markup_depth(content)
|
|
86
83
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
@head_settings.styles = match[1].strip
|
|
84
|
+
result = process_tag(content, 'block') do |attrs, inner|
|
|
85
|
+
process_block_tag(attrs, inner)
|
|
90
86
|
end
|
|
91
87
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
@head_settings.fonts << FontConfig.new(id: "font-#{i}", name: name, url: url)
|
|
88
|
+
result = replace_self_closing(result, 'block', self_close_only: true) do |attrs_str|
|
|
89
|
+
process_block_tag(parse_attributes(attrs_str))
|
|
95
90
|
end
|
|
96
|
-
|
|
91
|
+
assert_markup_depth(result)
|
|
97
92
|
|
|
98
|
-
|
|
99
|
-
result =
|
|
93
|
+
result = result.gsub(/<link(\s+(?:[\w-]+=(?:"[^"]*"|'[^']*')\s+)*)href=/i, '<sevk-link\1href=')
|
|
94
|
+
result = result.gsub(%r{</link>}i, '</sevk-link>')
|
|
100
95
|
|
|
101
|
-
#
|
|
102
|
-
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|
|
|
103
98
|
style = extract_all_style_attributes(attrs)
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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) }
|
|
110
|
+
|
|
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
|
|
112
121
|
end
|
|
113
122
|
|
|
114
|
-
|
|
115
|
-
result = process_tag(result, "row") do |attrs, inner|
|
|
123
|
+
result = process_tag(result, 'column') do |attrs, inner|
|
|
116
124
|
style = extract_all_style_attributes(attrs)
|
|
125
|
+
style['vertical-align'] ||= 'top'
|
|
117
126
|
style_str = style_to_string(style)
|
|
118
|
-
%(<
|
|
119
|
-
<tbody style="width:100%">
|
|
120
|
-
<tr style="width:100%">#{inner}</tr>
|
|
121
|
-
</tbody>
|
|
122
|
-
</table>)
|
|
127
|
+
%(<td class="sevk-column" style="#{style_str}">#{inner}</td>)
|
|
123
128
|
end
|
|
124
129
|
|
|
125
|
-
|
|
126
|
-
result = process_tag(result,
|
|
130
|
+
row_counter = 0
|
|
131
|
+
result = process_tag(result, 'row') do |attrs, inner|
|
|
132
|
+
gap = attrs['gap'] || '0'
|
|
127
133
|
style = extract_all_style_attributes(attrs)
|
|
134
|
+
style.delete('gap')
|
|
128
135
|
style_str = style_to_string(style)
|
|
129
|
-
|
|
136
|
+
gap_px = gap.sub('px', '')
|
|
137
|
+
gap_num = parse_px(gap_px)
|
|
138
|
+
row_id = "sevk-row-#{row_counter}"
|
|
139
|
+
row_counter += 1
|
|
140
|
+
|
|
141
|
+
processed_inner = inner
|
|
142
|
+
column_count = inner.scan('class="sevk-column"').length
|
|
143
|
+
if column_count > 1
|
|
144
|
+
equal_width = "#{(100 / column_count).to_i}%"
|
|
145
|
+
processed_inner = processed_inner.gsub(/<td class="sevk-column" style="([^"]*)"/) do
|
|
146
|
+
existing_style = Regexp.last_match(1)
|
|
147
|
+
if existing_style.include?('width:')
|
|
148
|
+
%(<td class="sevk-column" style="#{existing_style}")
|
|
149
|
+
else
|
|
150
|
+
%(<td class="sevk-column" style="width:#{equal_width};#{existing_style}")
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
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
|
|
130
178
|
end
|
|
131
179
|
|
|
132
|
-
|
|
133
|
-
result = process_tag(result, "container") do |attrs, inner|
|
|
180
|
+
result = process_tag(result, 'container') do |attrs, inner|
|
|
134
181
|
style = extract_all_style_attributes(attrs)
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
182
|
+
td_style = {}
|
|
183
|
+
table_style = {}
|
|
184
|
+
|
|
185
|
+
visual_keys = %w[
|
|
186
|
+
background-color background-image background-size background-position background-repeat
|
|
187
|
+
border border-top border-right border-bottom border-left border-color border-width border-style
|
|
188
|
+
border-radius border-top-left-radius border-top-right-radius
|
|
189
|
+
border-bottom-left-radius border-bottom-right-radius
|
|
190
|
+
padding padding-top padding-right padding-bottom padding-left
|
|
191
|
+
]
|
|
192
|
+
style.each do |key, value|
|
|
193
|
+
if visual_keys.include?(key)
|
|
194
|
+
td_style[key] = value
|
|
195
|
+
else
|
|
196
|
+
table_style[key] = value
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
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
|
|
204
|
+
|
|
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%'
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
table_style_str = style_to_string(table_style)
|
|
211
|
+
td_style_str = style_to_string(td_style)
|
|
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
|
|
143
225
|
end
|
|
144
226
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
level = attrs["level"] || "1"
|
|
227
|
+
result = process_tag(result, 'heading') do |attrs, inner|
|
|
228
|
+
level = attrs['level'].to_s.empty? ? '1' : attrs['level']
|
|
148
229
|
style = extract_all_style_attributes(attrs)
|
|
230
|
+
style['margin'] ||= '0'
|
|
149
231
|
style_str = style_to_string(style)
|
|
150
232
|
%(<h#{level} style="#{style_str}">#{inner}</h#{level}>)
|
|
151
233
|
end
|
|
152
234
|
|
|
153
|
-
|
|
154
|
-
result = process_tag(result, "paragraph") do |attrs, inner|
|
|
235
|
+
result = process_tag(result, 'paragraph') do |attrs, inner|
|
|
155
236
|
style = extract_all_style_attributes(attrs)
|
|
237
|
+
style['margin'] ||= '0'
|
|
156
238
|
style_str = style_to_string(style)
|
|
157
239
|
%(<p style="#{style_str}">#{inner}</p>)
|
|
158
240
|
end
|
|
159
241
|
|
|
160
|
-
|
|
161
|
-
result = process_tag(result, "text") do |attrs, inner|
|
|
242
|
+
result = process_tag(result, 'text') do |attrs, inner|
|
|
162
243
|
style = extract_all_style_attributes(attrs)
|
|
163
244
|
style_str = style_to_string(style)
|
|
164
245
|
%(<span style="#{style_str}">#{inner}</span>)
|
|
165
246
|
end
|
|
166
247
|
|
|
167
|
-
|
|
168
|
-
result = process_tag(result, "button") do |attrs, inner|
|
|
248
|
+
result = process_tag(result, 'button') do |attrs, inner|
|
|
169
249
|
process_button(attrs, inner)
|
|
170
250
|
end
|
|
171
251
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
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']
|
|
179
258
|
|
|
180
259
|
style = extract_all_style_attributes(attrs)
|
|
181
|
-
style[
|
|
182
|
-
style[
|
|
183
|
-
style["text-decoration"] ||= "none"
|
|
260
|
+
style['vertical-align'] ||= 'middle'
|
|
261
|
+
style['max-width'] ||= '100%'
|
|
184
262
|
|
|
185
263
|
style_str = style_to_string(style)
|
|
186
|
-
|
|
187
|
-
|
|
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}")
|
|
188
268
|
|
|
189
269
|
%(<img src="#{src}" alt="#{alt}"#{width_attr}#{height_attr} style="#{style_str}" />)
|
|
190
270
|
end
|
|
191
271
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
attrs = parse_attributes(Regexp.last_match(1) || "")
|
|
272
|
+
result = result.gsub(%r{<divider([^>]*)/?>}i) do
|
|
273
|
+
attrs = parse_attributes(Regexp.last_match(1) || '')
|
|
195
274
|
style = extract_all_style_attributes(attrs)
|
|
196
275
|
style_str = style_to_string(style)
|
|
197
|
-
|
|
198
|
-
class_str = class_attr ? %( class="#{class_attr}") : ""
|
|
276
|
+
class_str = class_attribute(attrs)
|
|
199
277
|
%(<hr style="#{style_str}"#{class_str} />)
|
|
200
278
|
end
|
|
201
279
|
|
|
202
|
-
|
|
203
|
-
result =
|
|
204
|
-
|
|
205
|
-
|
|
280
|
+
result = result.gsub(%r{</divider>}i, '')
|
|
281
|
+
result = result.gsub(%r{</image>}i, '')
|
|
282
|
+
|
|
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']
|
|
206
286
|
style = extract_all_style_attributes(attrs)
|
|
207
287
|
style_str = style_to_string(style)
|
|
208
288
|
%(<a href="#{href}" target="#{target}" style="#{style_str}">#{inner}</a>)
|
|
209
289
|
end
|
|
210
290
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
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'
|
|
215
294
|
style = extract_all_style_attributes(attrs)
|
|
216
|
-
style[
|
|
295
|
+
style['margin'] ||= '0'
|
|
296
|
+
style['list-style-type'] = attrs['list-style-type'] if attrs['list-style-type']
|
|
217
297
|
style_str = style_to_string(style)
|
|
218
|
-
|
|
219
|
-
class_str = class_attr ? %( class="#{class_attr}") : ""
|
|
298
|
+
class_str = class_attribute(attrs)
|
|
220
299
|
%(<#{tag} style="#{style_str}"#{class_str}>#{inner}</#{tag}>)
|
|
221
300
|
end
|
|
222
301
|
|
|
223
|
-
|
|
224
|
-
result = process_tag(result, "li") do |attrs, inner|
|
|
302
|
+
result = process_tag(result, 'li') do |attrs, inner|
|
|
225
303
|
style = extract_all_style_attributes(attrs)
|
|
226
304
|
style_str = style_to_string(style)
|
|
227
|
-
|
|
228
|
-
class_str = class_attr ? %( class="#{class_attr}") : ""
|
|
305
|
+
class_str = class_attribute(attrs)
|
|
229
306
|
%(<li style="#{style_str}"#{class_str}>#{inner}</li>)
|
|
230
307
|
end
|
|
231
308
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
%(<pre style="#{style_str}"><code>#{escaped}</code></pre>)
|
|
309
|
+
result = process_tag(result, 'codeblock') do |attrs, inner|
|
|
310
|
+
process_codeblock(attrs, inner)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
stray_closing_tags = %w[container section row column heading paragraph text button sevk-link]
|
|
314
|
+
stray_closing_tags.each do |tag|
|
|
315
|
+
result = result.gsub(%r{</#{tag}>}i, '')
|
|
240
316
|
end
|
|
241
317
|
|
|
242
|
-
# Clean up wrapper tags
|
|
243
318
|
wrapper_patterns = [
|
|
244
|
-
/<sevk-email[^>]*>/i,
|
|
245
|
-
/<sevk-body[^>]*>/i,
|
|
246
|
-
/<email[^>]*>/i,
|
|
247
|
-
/<mail[^>]*>/i,
|
|
248
|
-
/<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
|
|
249
324
|
]
|
|
250
325
|
wrapper_patterns.each do |pattern|
|
|
251
|
-
result = result.gsub(pattern,
|
|
326
|
+
result = result.gsub(pattern, '')
|
|
252
327
|
end
|
|
253
328
|
|
|
254
329
|
result.strip
|
|
255
330
|
end
|
|
256
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.
|
|
257
336
|
def process_button(attrs, inner)
|
|
258
|
-
href = attrs[
|
|
337
|
+
href = attrs['href'].to_s.empty? ? '#' : attrs['href']
|
|
259
338
|
style = extract_all_style_attributes(attrs)
|
|
260
339
|
|
|
261
|
-
# Parse padding
|
|
262
340
|
padding_top, padding_right, padding_bottom, padding_left = parse_padding(style)
|
|
263
341
|
|
|
264
342
|
y = padding_top + padding_bottom
|
|
@@ -268,247 +346,100 @@ module Sevk
|
|
|
268
346
|
pr_font_width, pr_space_count = compute_font_width_and_space_count(padding_right)
|
|
269
347
|
|
|
270
348
|
button_style = {
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
349
|
+
'line-height' => '100%',
|
|
350
|
+
'text-decoration' => 'none',
|
|
351
|
+
'display' => 'inline-block',
|
|
352
|
+
'max-width' => '100%',
|
|
353
|
+
'mso-padding-alt' => '0px'
|
|
276
354
|
}
|
|
277
355
|
|
|
278
|
-
# Merge with extracted styles
|
|
279
356
|
button_style.merge!(style)
|
|
280
357
|
|
|
281
|
-
|
|
282
|
-
button_style[
|
|
283
|
-
button_style[
|
|
284
|
-
button_style[
|
|
285
|
-
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"
|
|
286
362
|
|
|
287
363
|
style_str = style_to_string(button_style)
|
|
288
364
|
|
|
289
|
-
left_mso_spaces =
|
|
290
|
-
right_mso_spaces =
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
[val, val, val, val]
|
|
302
|
-
when 2
|
|
303
|
-
vertical = parse_px(parts[0])
|
|
304
|
-
horizontal = parse_px(parts[1])
|
|
305
|
-
[vertical, horizontal, vertical, horizontal]
|
|
306
|
-
when 4
|
|
307
|
-
[parse_px(parts[0]), parse_px(parts[1]), parse_px(parts[2]), parse_px(parts[3])]
|
|
308
|
-
else
|
|
309
|
-
[0, 0, 0, 0]
|
|
310
|
-
end
|
|
311
|
-
else
|
|
312
|
-
[
|
|
313
|
-
parse_px(style["padding-top"] || "0"),
|
|
314
|
-
parse_px(style["padding-right"] || "0"),
|
|
315
|
-
parse_px(style["padding-bottom"] || "0"),
|
|
316
|
-
parse_px(style["padding-left"] || "0")
|
|
317
|
-
]
|
|
318
|
-
end
|
|
319
|
-
end
|
|
320
|
-
|
|
321
|
-
def parse_px(s)
|
|
322
|
-
s.to_s.gsub("px", "").to_i
|
|
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
|
|
323
377
|
end
|
|
324
378
|
|
|
325
|
-
def
|
|
326
|
-
(px * 3) / 4
|
|
327
|
-
end
|
|
328
|
-
|
|
329
|
-
def compute_font_width_and_space_count(expected_width)
|
|
330
|
-
return [0, 0] if expected_width == 0
|
|
331
|
-
|
|
332
|
-
smallest_space_count = 0
|
|
333
|
-
max_font_width = 5.0
|
|
334
|
-
|
|
335
|
-
loop do
|
|
336
|
-
required_font_width = if smallest_space_count > 0
|
|
337
|
-
expected_width.to_f / smallest_space_count / 2.0
|
|
338
|
-
else
|
|
339
|
-
Float::INFINITY
|
|
340
|
-
end
|
|
341
|
-
|
|
342
|
-
return [required_font_width, smallest_space_count] if required_font_width <= max_font_width
|
|
343
|
-
|
|
344
|
-
smallest_space_count += 1
|
|
345
|
-
end
|
|
346
|
-
end
|
|
347
|
-
|
|
348
|
-
def process_tag(content, tag_name)
|
|
379
|
+
def process_tag(content, tag_name, &processor)
|
|
349
380
|
result = content
|
|
350
|
-
|
|
351
|
-
close_tag = "</#{tag_name}>"
|
|
352
|
-
open_tag_start = "<#{tag_name}"
|
|
353
|
-
|
|
354
|
-
max_iterations = 10_000
|
|
355
|
-
iterations = 0
|
|
381
|
+
patterns = tag_patterns(tag_name)
|
|
356
382
|
|
|
357
|
-
|
|
358
|
-
|
|
383
|
+
MAX_TAG_ITERATIONS.times do
|
|
384
|
+
charge_scan_work(result.bytesize)
|
|
385
|
+
replacements = collect_replacements(result, patterns, &processor)
|
|
386
|
+
break if replacements.empty?
|
|
359
387
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
break if matches.empty?
|
|
388
|
+
rebuilt = rebuild_result(result, replacements)
|
|
389
|
+
break if rebuilt == result
|
|
363
390
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
# Find the innermost tag (one that has no nested same tags)
|
|
367
|
-
matches.reverse_each do |match|
|
|
368
|
-
start = match.begin(0)
|
|
369
|
-
inner_start = match.end(0)
|
|
370
|
-
attrs_str = match[1]
|
|
371
|
-
|
|
372
|
-
# Find the next close tag after this opening tag
|
|
373
|
-
close_pos = result.downcase.index(close_tag.downcase, inner_start)
|
|
374
|
-
next unless close_pos
|
|
375
|
-
|
|
376
|
-
inner = result[inner_start...close_pos]
|
|
377
|
-
|
|
378
|
-
# Check if there's another opening tag inside
|
|
379
|
-
next if inner.downcase.include?(open_tag_start.downcase)
|
|
380
|
-
|
|
381
|
-
# This is an innermost tag, process it
|
|
382
|
-
attrs = parse_attributes(attrs_str)
|
|
383
|
-
replacement = yield(attrs, inner)
|
|
384
|
-
end_pos = close_pos + close_tag.length
|
|
385
|
-
|
|
386
|
-
result = result[0...start] + replacement + result[end_pos..]
|
|
387
|
-
processed = true
|
|
388
|
-
break
|
|
389
|
-
end
|
|
390
|
-
|
|
391
|
-
break unless processed
|
|
391
|
+
result = rebuilt
|
|
392
392
|
end
|
|
393
393
|
|
|
394
394
|
result
|
|
395
395
|
end
|
|
396
396
|
|
|
397
|
-
def
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
end
|
|
402
|
-
attrs
|
|
403
|
-
end
|
|
404
|
-
|
|
405
|
-
def extract_all_style_attributes(attrs)
|
|
406
|
-
style = {}
|
|
397
|
+
def collect_replacements(result, patterns, &processor)
|
|
398
|
+
scanner = StringScanner.new(result)
|
|
399
|
+
replacements = []
|
|
400
|
+
consumed_until = 0
|
|
407
401
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
elsif attrs["color"]
|
|
412
|
-
style["color"] = attrs["color"]
|
|
413
|
-
end
|
|
414
|
-
style["background-color"] = attrs["background-color"] if attrs["background-color"]
|
|
415
|
-
style["font-size"] = attrs["font-size"] if attrs["font-size"]
|
|
416
|
-
style["font-family"] = attrs["font-family"] if attrs["font-family"]
|
|
417
|
-
style["font-weight"] = attrs["font-weight"] if attrs["font-weight"]
|
|
418
|
-
style["line-height"] = attrs["line-height"] if attrs["line-height"]
|
|
419
|
-
style["text-align"] = attrs["text-align"] if attrs["text-align"]
|
|
420
|
-
style["text-decoration"] = attrs["text-decoration"] if attrs["text-decoration"]
|
|
421
|
-
|
|
422
|
-
# Dimensions
|
|
423
|
-
style["width"] = attrs["width"] if attrs["width"]
|
|
424
|
-
style["height"] = attrs["height"] if attrs["height"]
|
|
425
|
-
style["max-width"] = attrs["max-width"] if attrs["max-width"]
|
|
426
|
-
style["min-height"] = attrs["min-height"] if attrs["min-height"]
|
|
427
|
-
|
|
428
|
-
# Spacing - Padding
|
|
429
|
-
if attrs["padding"]
|
|
430
|
-
style["padding"] = attrs["padding"]
|
|
431
|
-
else
|
|
432
|
-
style["padding-top"] = attrs["padding-top"] if attrs["padding-top"]
|
|
433
|
-
style["padding-right"] = attrs["padding-right"] if attrs["padding-right"]
|
|
434
|
-
style["padding-bottom"] = attrs["padding-bottom"] if attrs["padding-bottom"]
|
|
435
|
-
style["padding-left"] = attrs["padding-left"] if attrs["padding-left"]
|
|
436
|
-
end
|
|
402
|
+
while scanner.skip_until(patterns.open)
|
|
403
|
+
start = scanner.pos - scanner.matched_size
|
|
404
|
+
next if start < consumed_until
|
|
437
405
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
style["margin-right"] = attrs["margin-right"] if attrs["margin-right"]
|
|
444
|
-
style["margin-bottom"] = attrs["margin-bottom"] if attrs["margin-bottom"]
|
|
445
|
-
style["margin-left"] = attrs["margin-left"] if attrs["margin-left"]
|
|
446
|
-
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
|
|
447
411
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
style["border"] = attrs["border"]
|
|
451
|
-
else
|
|
452
|
-
style["border-top"] = attrs["border-top"] if attrs["border-top"]
|
|
453
|
-
style["border-right"] = attrs["border-right"] if attrs["border-right"]
|
|
454
|
-
style["border-bottom"] = attrs["border-bottom"] if attrs["border-bottom"]
|
|
455
|
-
style["border-left"] = attrs["border-left"] if attrs["border-left"]
|
|
456
|
-
style["border-color"] = attrs["border-color"] if attrs["border-color"]
|
|
457
|
-
style["border-width"] = attrs["border-width"] if attrs["border-width"]
|
|
458
|
-
style["border-style"] = attrs["border-style"] if attrs["border-style"]
|
|
459
|
-
end
|
|
412
|
+
inner = result.byteslice(inner_start, close_pos - inner_start)
|
|
413
|
+
next if inner.match?(patterns.nested)
|
|
460
414
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
style["border-radius"] = attrs["border-radius"]
|
|
464
|
-
else
|
|
465
|
-
style["border-top-left-radius"] = attrs["border-top-left-radius"] if attrs["border-top-left-radius"]
|
|
466
|
-
style["border-top-right-radius"] = attrs["border-top-right-radius"] if attrs["border-top-right-radius"]
|
|
467
|
-
style["border-bottom-left-radius"] = attrs["border-bottom-left-radius"] if attrs["border-bottom-left-radius"]
|
|
468
|
-
style["border-bottom-right-radius"] = attrs["border-bottom-right-radius"] if attrs["border-bottom-right-radius"]
|
|
415
|
+
consumed_until = close_pos + patterns.close_length
|
|
416
|
+
replacements << [start, consumed_until, processor.call(parse_attributes(attrs_str), inner)]
|
|
469
417
|
end
|
|
470
418
|
|
|
471
|
-
|
|
419
|
+
replacements
|
|
472
420
|
end
|
|
473
421
|
|
|
474
|
-
def
|
|
475
|
-
|
|
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
|
|
476
426
|
end
|
|
477
427
|
|
|
478
|
-
def
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
preview_text = ""
|
|
488
|
-
unless @head_settings.preview_text.empty?
|
|
489
|
-
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
|
|
490
437
|
end
|
|
491
|
-
|
|
492
|
-
<<~HTML
|
|
493
|
-
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
|
494
|
-
<html lang="en" dir="ltr">
|
|
495
|
-
<head>
|
|
496
|
-
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/>
|
|
497
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
|
498
|
-
#{title}
|
|
499
|
-
#{font_links}
|
|
500
|
-
#{styles}
|
|
501
|
-
</head>
|
|
502
|
-
<body style="margin:0;padding:0;font-family:ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;background-color:#ffffff">
|
|
503
|
-
#{preview_text}
|
|
504
|
-
#{content}
|
|
505
|
-
</body>
|
|
506
|
-
</html>
|
|
507
|
-
HTML
|
|
438
|
+
rebuilt << result.byteslice(cursor, result.bytesize - cursor)
|
|
508
439
|
end
|
|
509
440
|
end
|
|
510
441
|
|
|
511
|
-
#
|
|
442
|
+
# Converts sevk markup into complete, email-compatible HTML.
|
|
512
443
|
def self.render(markup)
|
|
513
444
|
Renderer.new.render(markup)
|
|
514
445
|
end
|