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.
@@ -1,189 +1,150 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "rouge"
4
- require "json"
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: "", name: "", url: "")
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
- # Parse head settings from markup
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
- # Replace <link> with <sevk-link>
81
- if result.include?("<link")
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
- unless result.include?("<sevk-email") || result.include?("<email") || result.include?("<mail")
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
- result = content
79
+ @gap_rules = []
80
+ @codeblock_budget = 0
81
+ @scan_work = 0
82
+ assert_markup_depth(content)
128
83
 
129
- # Convert <link> to <sevk-link>
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
- # Process self-closing block tags
141
- result = result.gsub(/<block([^>]*)\/?\s*>/i) do
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
- # Process section tags
147
- result = process_tag(result, "section") do |attrs, inner|
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
- style_str = style_to_string(style)
150
- %(<table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="#{style_str}">
151
- <tbody>
152
- <tr>
153
- <td>#{inner}</td>
154
- </tr>
155
- </tbody>
156
- </table>)
157
- end
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
- # Process column tags (before rows, so row can count them)
160
- result = process_tag(result, "column") do |attrs, inner|
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["vertical-align"] ||= "top"
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, "row") do |attrs, inner|
170
- gap = attrs["gap"] || "0"
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("gap")
134
+ style.delete('gap')
173
135
  style_str = style_to_string(style)
174
- gap_px = gap.gsub("px", "")
175
- gap_num = gap_px.to_i
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(/class="sevk-column"/).length
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?("width:")
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
- gap_style = gap_num > 0 ? %(<style>@media only screen and (max-width:479px){.#{row_id} > tbody > tr > td{margin-bottom:#{gap_px}px !important;padding-left:0 !important;padding-right:0 !important;}.#{row_id} > tbody > tr > td:last-child{margin-bottom:0 !important;}}</style>) : ""
195
- %(#{gap_style}<table class="sevk-row-table #{row_id}" align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="#{style_str}">
196
- <tbody style="width:100%">
197
- <tr style="width:100%">#{processed_inner}</tr>
198
- </tbody>
199
- </table>)
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
- # Process container tags
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 border-bottom-left-radius border-bottom-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
- # Add border-collapse: separate when border-radius is used
224
- has_border_radius = td_style["border-radius"] || td_style["border-top-left-radius"] ||
225
- td_style["border-top-right-radius"] || td_style["border-bottom-left-radius"] ||
226
- td_style["border-bottom-right-radius"]
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
- # Make fixed widths responsive: width becomes max-width, width set to 100%
230
- if table_style["width"] && table_style["width"] != "100%" && table_style["width"] != "auto"
231
- table_style["max-width"] ||= table_style["width"]
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
- %(<table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="#{table_style_str}">
238
- <tbody>
239
- <tr style="width:100%">
240
- <td style="#{td_style_str}">#{inner}</td>
241
- </tr>
242
- </tbody>
243
- </table>)
244
- end
245
-
246
- # Process heading tags
247
- result = process_tag(result, "heading") do |attrs, inner|
248
- level = attrs["level"] || "1"
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
- # Process paragraph tags
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
- # Process text tags
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
- # Process button tags with MSO compatibility
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
- # Process image tags
276
- result = result.gsub(/<image([^>]*)\/?>/i) do
277
- attrs = parse_attributes(Regexp.last_match(1) || "")
278
- src = attrs["src"] || ""
279
- alt = attrs["alt"] || ""
280
- width = attrs["width"]
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["vertical-align"] ||= "middle"
285
- style["max-width"] ||= "100%"
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
- width_attr = width ? %( width="#{width.to_s.gsub('px', '')}") : ""
292
- height_attr = height ? %( height="#{height.to_s.gsub('px', '')}") : ""
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
- # Process divider tags
298
- result = result.gsub(/<divider([^>]*)\/?>/i) do
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
- class_attr = attrs["class"] || attrs["className"]
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
- # Remove stray </divider> closing tags
308
- result = result.gsub(%r{</divider>}i, "")
280
+ result = result.gsub(%r{</divider>}i, '')
281
+ result = result.gsub(%r{</image>}i, '')
309
282
 
310
- # Process link tags
311
- result = process_tag(result, "sevk-link") do |attrs, inner|
312
- href = attrs["href"] || "#"
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
- # Process list tags
320
- result = process_tag(result, "list") do |attrs, inner|
321
- list_type = attrs["type"] || "unordered"
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["list-style-type"] = attrs["list-style-type"] if attrs["list-style-type"]
296
+ style['list-style-type'] = attrs['list-style-type'] if attrs['list-style-type']
326
297
  style_str = style_to_string(style)
327
- class_attr = attrs["class"] || attrs["className"]
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
- # Process list item tags
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
- class_attr = attrs["class"] || attrs["className"]
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
- # Process codeblock tags
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, /<\/sevk-email>/i,
355
- /<sevk-body[^>]*>/i, /<\/sevk-body>/i,
356
- /<email[^>]*>/i, /<\/email>/i,
357
- /<mail[^>]*>/i, /<\/mail>/i,
358
- /<body[^>]*>/i, /<\/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["href"] || "#"
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
- "line-height" => "100%",
382
- "text-decoration" => "none",
383
- "display" => "inline-block",
384
- "max-width" => "100%",
385
- "mso-padding-alt" => "0px"
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
- # Override padding with parsed values
392
- button_style["padding-top"] = "#{padding_top}px"
393
- button_style["padding-right"] = "#{padding_right}px"
394
- button_style["padding-bottom"] = "#{padding_bottom}px"
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 = "&#8202;" * pl_space_count
400
- right_mso_spaces = "&#8202;" * pr_space_count
401
-
402
- %(<a href="#{href}" target="_blank" style="#{style_str}"><!--[if mso]><i style="mso-font-width:#{(pl_font_width * 100).round}%;mso-text-raise:#{text_raise}" hidden>#{left_mso_spaces}</i><![endif]--><span style="max-width:100%;display:inline-block;line-height:120%;mso-padding-alt:0px;mso-text-raise:#{px_to_pt(padding_bottom)}">#{inner}</span><!--[if mso]><i style="mso-font-width:#{(pr_font_width * 100).round}%" hidden>#{right_mso_spaces}&#8203;</i><![endif]--></a>)
365
+ left_mso_spaces = '&#8202;' * pl_space_count
366
+ right_mso_spaces = '&#8202;' * 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}&#8203;</i><![endif]--></a>)
376
+ ].join
403
377
  end
404
378
 
405
- # One Dark theme token-to-inline-style mapping
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("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
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("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
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('&quot;', '"').gsub('&amp;', '&')
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
- open_pattern = /<#{tag_name}([^>]*)>/i
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
- close_pos = close_match.begin(0)
761
- inner = result[inner_start...close_pos]
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
- # Check if there's another opening tag inside (case-insensitive)
764
- next if inner.match?(Regexp.new("<#{tag_name}", Regexp::IGNORECASE))
388
+ rebuilt = rebuild_result(result, replacements)
389
+ break if rebuilt == result
765
390
 
766
- # This is an innermost tag, process it
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 parse_attributes(attrs_str)
783
- attrs = {}
784
- attrs_str.scan(/([\w-]+)=(?:"([^"]*)"|'([^']*)')/) do |key, dq_val, sq_val|
785
- attrs[key] = dq_val.nil? || dq_val.empty? ? (sq_val || '') : dq_val
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
- # Spacing - Margin
826
- if attrs["margin"]
827
- style["margin"] = attrs["margin"]
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
- # Borders
836
- if attrs["border"]
837
- style["border"] = attrs["border"]
838
- else
839
- style["border-top"] = attrs["border-top"] if attrs["border-top"]
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
- # Border Radius
849
- if attrs["border-radius"]
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
- # Background image
859
- if attrs["background-image"]
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
- style
419
+ replacements
871
420
  end
872
421
 
873
- def style_to_string(style)
874
- style.map { |k, v| "#{k}:#{v}" }.join(";")
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 generate_html(content)
878
- lang = @head_settings.lang.empty? ? "en" : @head_settings.lang
879
- dir = @head_settings.dir.empty? ? "ltr" : @head_settings.dir
880
-
881
- title = @head_settings.title.empty? ? "" : "<title>#{@head_settings.title}</title>"
882
-
883
- font_links = @head_settings.fonts.map do |font|
884
- %(<link href="#{CGI.escapeHTML(font.url)}" rel="stylesheet" type="text/css" />)
885
- end.join("\n")
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
- # Module-level render method
442
+ # Converts sevk markup into complete, email-compatible HTML.
940
443
  def self.render(markup)
941
444
  Renderer.new.render(markup)
942
445
  end