@rip-lang/ui 0.3.67 → 0.4.1
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.
- package/AGENTS.md +93 -0
- package/README.md +22 -625
- package/browser/AGENTS.md +213 -0
- package/browser/CONTRIBUTING.md +375 -0
- package/browser/README.md +11 -0
- package/browser/TESTING.md +59 -0
- package/browser/browser.rip +56 -0
- package/{components → browser/components}/accordion.rip +1 -1
- package/{components → browser/components}/alert-dialog.rip +6 -3
- package/{components → browser/components}/autocomplete.rip +27 -21
- package/{components → browser/components}/avatar.rip +3 -3
- package/{components → browser/components}/badge.rip +1 -1
- package/{components → browser/components}/breadcrumb.rip +2 -2
- package/{components → browser/components}/button-group.rip +3 -3
- package/{components → browser/components}/button.rip +2 -2
- package/{components → browser/components}/card.rip +1 -1
- package/{components → browser/components}/carousel.rip +5 -5
- package/{components → browser/components}/checkbox-group.rip +40 -11
- package/{components → browser/components}/checkbox.rip +4 -4
- package/{components → browser/components}/collapsible.rip +2 -2
- package/{components → browser/components}/combobox.rip +36 -23
- package/{components → browser/components}/context-menu.rip +1 -1
- package/{components → browser/components}/date-picker.rip +5 -5
- package/{components → browser/components}/dialog.rip +8 -4
- package/{components → browser/components}/drawer.rip +8 -4
- package/{components → browser/components}/editable-value.rip +7 -1
- package/{components → browser/components}/field.rip +5 -5
- package/{components → browser/components}/fieldset.rip +2 -2
- package/{components → browser/components}/form.rip +1 -1
- package/{components → browser/components}/grid.rip +8 -8
- package/{components → browser/components}/input-group.rip +1 -1
- package/{components → browser/components}/input.rip +6 -6
- package/{components → browser/components}/label.rip +2 -2
- package/{components → browser/components}/menu.rip +17 -10
- package/{components → browser/components}/menubar.rip +1 -1
- package/{components → browser/components}/meter.rip +7 -7
- package/{components → browser/components}/multi-select.rip +76 -33
- package/{components → browser/components}/native-select.rip +3 -3
- package/{components → browser/components}/nav-menu.rip +3 -3
- package/{components → browser/components}/number-field.rip +11 -11
- package/{components → browser/components}/otp-field.rip +4 -4
- package/{components → browser/components}/pagination.rip +4 -4
- package/{components → browser/components}/popover.rip +11 -24
- package/{components → browser/components}/preview-card.rip +7 -11
- package/{components → browser/components}/progress.rip +3 -3
- package/{components → browser/components}/radio-group.rip +4 -4
- package/{components → browser/components}/resizable.rip +3 -3
- package/{components → browser/components}/scroll-area.rip +1 -1
- package/{components → browser/components}/select.rip +55 -27
- package/{components → browser/components}/separator.rip +2 -2
- package/{components → browser/components}/skeleton.rip +4 -4
- package/{components → browser/components}/slider.rip +15 -10
- package/{components → browser/components}/spinner.rip +2 -2
- package/{components → browser/components}/table.rip +2 -2
- package/{components → browser/components}/tabs.rip +12 -7
- package/{components → browser/components}/textarea.rip +8 -8
- package/{components → browser/components}/toast.rip +3 -3
- package/{components → browser/components}/toggle-group.rip +42 -11
- package/{components → browser/components}/toggle.rip +2 -2
- package/{components → browser/components}/toolbar.rip +2 -2
- package/{components → browser/components}/tooltip.rip +19 -23
- package/browser/hljs-rip.js +209 -0
- package/browser/playwright.config.mjs +31 -0
- package/browser/tests/overlays.js +349 -0
- package/email/AGENTS.md +16 -0
- package/email/README.md +55 -0
- package/email/benchmarks/benchmark.rip +94 -0
- package/email/benchmarks/samples.rip +104 -0
- package/email/compat.rip +129 -0
- package/email/components.rip +371 -0
- package/email/dom.rip +330 -0
- package/email/email.rip +10 -0
- package/email/render.rip +82 -0
- package/package.json +29 -39
- package/shared/README.md +3 -0
- package/shared/styles.rip +17 -0
- package/tailwind/AGENTS.md +3 -0
- package/tailwind/README.md +27 -0
- package/tailwind/engine.js +107 -0
- package/tailwind/inline.js +215 -0
- package/tailwind/serve.js +6 -0
- package/tailwind/tailwind.rip +13 -0
- package/ui.rip +3 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { renderEmail, toHTML } from '../email.rip'
|
|
2
|
+
import { prepareConfig } from '../../tailwind/tailwind.rip'
|
|
3
|
+
import { PlainBenchEmail, TailwindBenchEmail, benchTailwindConfig } from './samples.rip'
|
|
4
|
+
|
|
5
|
+
iterations = Number(process.argv[2] ?? 250)
|
|
6
|
+
warmupIterations = Number(process.argv[3] ?? 40)
|
|
7
|
+
|
|
8
|
+
percentile = (sortedValues, fraction) ->
|
|
9
|
+
return 0 unless sortedValues.length
|
|
10
|
+
index = Math.min(sortedValues.length - 1, Math.floor(sortedValues.length * fraction))
|
|
11
|
+
sortedValues[index]
|
|
12
|
+
|
|
13
|
+
formatMs = (value) -> "#{value.toFixed(2)}ms"
|
|
14
|
+
|
|
15
|
+
summarize = (name, samples) ->
|
|
16
|
+
sorted = [...samples]
|
|
17
|
+
sorted.sort (a, b) -> a - b
|
|
18
|
+
|
|
19
|
+
total = 0
|
|
20
|
+
total += value for value in sorted
|
|
21
|
+
average = total / sorted.length
|
|
22
|
+
ops = 1000 / average
|
|
23
|
+
|
|
24
|
+
{
|
|
25
|
+
name: name
|
|
26
|
+
average: average
|
|
27
|
+
p50: percentile(sorted, 0.5)
|
|
28
|
+
p95: percentile(sorted, 0.95)
|
|
29
|
+
min: sorted[0]
|
|
30
|
+
max: sorted[-1]
|
|
31
|
+
ops: ops
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
runCase = (name, fn) ->
|
|
35
|
+
for idx in [0...warmupIterations]
|
|
36
|
+
fn()
|
|
37
|
+
|
|
38
|
+
samples = []
|
|
39
|
+
for idx in [0...iterations]
|
|
40
|
+
start = performance.now()
|
|
41
|
+
fn()
|
|
42
|
+
samples.push(performance.now() - start)
|
|
43
|
+
|
|
44
|
+
summarize(name, samples)
|
|
45
|
+
|
|
46
|
+
await prepareConfig(benchTailwindConfig)
|
|
47
|
+
|
|
48
|
+
cases = [
|
|
49
|
+
{
|
|
50
|
+
name: 'toHTML plain'
|
|
51
|
+
run: -> toHTML(PlainBenchEmail)
|
|
52
|
+
}
|
|
53
|
+
{
|
|
54
|
+
name: 'renderEmail plain'
|
|
55
|
+
run: -> renderEmail(PlainBenchEmail)
|
|
56
|
+
}
|
|
57
|
+
{
|
|
58
|
+
name: 'toHTML tailwind'
|
|
59
|
+
run: -> toHTML(TailwindBenchEmail)
|
|
60
|
+
}
|
|
61
|
+
{
|
|
62
|
+
name: 'renderEmail tailwind'
|
|
63
|
+
run: -> renderEmail(TailwindBenchEmail)
|
|
64
|
+
}
|
|
65
|
+
{
|
|
66
|
+
name: 'toHTML tailwind themed'
|
|
67
|
+
run: ->
|
|
68
|
+
toHTML TailwindBenchEmail,
|
|
69
|
+
config: benchTailwindConfig
|
|
70
|
+
buttonClass: 'rounded-md bg-brand px-4 py-3 text-sm font-semibold text-white'
|
|
71
|
+
linkClass: 'text-accent'
|
|
72
|
+
}
|
|
73
|
+
{
|
|
74
|
+
name: 'renderEmail tailwind themed'
|
|
75
|
+
run: ->
|
|
76
|
+
renderEmail TailwindBenchEmail,
|
|
77
|
+
config: benchTailwindConfig
|
|
78
|
+
buttonClass: 'rounded-md bg-brand px-4 py-3 text-sm font-semibold text-white'
|
|
79
|
+
linkClass: 'text-accent'
|
|
80
|
+
}
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
p "Rip email benchmark (#{iterations} iterations, #{warmupIterations} warmup)"
|
|
84
|
+
for testCase in cases
|
|
85
|
+
result = runCase(testCase.name, testCase.run)
|
|
86
|
+
p [
|
|
87
|
+
result.name.padEnd(28)
|
|
88
|
+
"avg #{formatMs(result.average)}".padEnd(14)
|
|
89
|
+
"p50 #{formatMs(result.p50)}".padEnd(14)
|
|
90
|
+
"p95 #{formatMs(result.p95)}".padEnd(14)
|
|
91
|
+
"min #{formatMs(result.min)}".padEnd(14)
|
|
92
|
+
"max #{formatMs(result.max)}".padEnd(14)
|
|
93
|
+
"#{result.ops.toFixed(1)} ops/s"
|
|
94
|
+
].join(' ')
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { Email, Head, Body, Preview, Container, Section, Row, Column, Heading, Text, Link, Button, Divider, Tailwind } from '../email.rip'
|
|
2
|
+
|
|
3
|
+
benchLinks = [
|
|
4
|
+
{
|
|
5
|
+
title: 'Deployment summary'
|
|
6
|
+
body: 'Builds are green, release notes are drafted, and the staging environment is ready for sign-off.'
|
|
7
|
+
href: 'https://example.com/deployments'
|
|
8
|
+
}
|
|
9
|
+
{
|
|
10
|
+
title: 'Customer feedback'
|
|
11
|
+
body: 'Three enterprise customers requested tighter audit logs and clearer seat-management flows.'
|
|
12
|
+
href: 'https://example.com/feedback'
|
|
13
|
+
}
|
|
14
|
+
{
|
|
15
|
+
title: 'Usage analytics'
|
|
16
|
+
body: 'Activation is up 8% week over week, with strong retention from trial accounts created this month.'
|
|
17
|
+
href: 'https://example.com/analytics'
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
export benchTailwindConfig =
|
|
22
|
+
theme:
|
|
23
|
+
extend:
|
|
24
|
+
colors:
|
|
25
|
+
brand: '#0f172a'
|
|
26
|
+
accent: '#0f766e'
|
|
27
|
+
|
|
28
|
+
export PlainBenchEmail = component
|
|
29
|
+
@title:: string := 'Rip UI Weekly Digest'
|
|
30
|
+
|
|
31
|
+
render
|
|
32
|
+
Email
|
|
33
|
+
Head
|
|
34
|
+
Body style: 'background-color:#f8fafc;padding:24px'
|
|
35
|
+
Preview text: 'Rip UI weekly digest with product, customer, and delivery updates.'
|
|
36
|
+
Container style: 'background:#ffffff;padding:32px;border:1px solid #e2e8f0'
|
|
37
|
+
Heading as: 'h1', style: 'font-size:30px;line-height:36px;margin:0 0 16px;color:#0f172a'
|
|
38
|
+
= @title
|
|
39
|
+
|
|
40
|
+
Text style: 'color:#475569'
|
|
41
|
+
= 'A compact benchmark email that exercises the curated Rip email components without Tailwind.'
|
|
42
|
+
|
|
43
|
+
Divider style: 'margin:24px 0;border-top:1px solid #e2e8f0'
|
|
44
|
+
|
|
45
|
+
for entry, idx in benchLinks
|
|
46
|
+
Section style: "padding:20px;border:1px solid #e2e8f0;background:#ffffff;margin:0 0 #{if idx < benchLinks.length - 1 then '16px' else '0'}"
|
|
47
|
+
Row
|
|
48
|
+
Column style: 'width:70%'
|
|
49
|
+
Heading as: 'h2', style: 'font-size:18px;line-height:24px;margin:0 0 8px;color:#111827'
|
|
50
|
+
= entry.title
|
|
51
|
+
Text style: 'margin:0;color:#475569'
|
|
52
|
+
= entry.body
|
|
53
|
+
Column style: 'width:30%;text-align:right'
|
|
54
|
+
Button href: entry.href, style: 'background:#0f172a;color:#ffffff;border-radius:8px;padding:12px 16px'
|
|
55
|
+
= 'Open report'
|
|
56
|
+
|
|
57
|
+
Divider style: 'margin:24px 0;border-top:1px solid #e2e8f0'
|
|
58
|
+
|
|
59
|
+
Text style: 'margin:0;color:#64748b'
|
|
60
|
+
= 'Need more detail?'
|
|
61
|
+
= ' '
|
|
62
|
+
Link href: 'https://example.com/team', style: 'color:#0f766e'
|
|
63
|
+
= 'Visit the team dashboard'
|
|
64
|
+
|
|
65
|
+
export TailwindBenchEmail = component
|
|
66
|
+
@title:: string := 'Rip UI Weekly Digest'
|
|
67
|
+
@config:: any := undefined
|
|
68
|
+
@buttonClass:: string := 'rounded-md bg-slate-900 px-4 py-3 text-sm font-semibold text-white'
|
|
69
|
+
@linkClass:: string := 'text-teal-700'
|
|
70
|
+
|
|
71
|
+
render
|
|
72
|
+
Email
|
|
73
|
+
Head
|
|
74
|
+
Tailwind config: @config
|
|
75
|
+
Body class: 'bg-slate-50 px-6 py-8'
|
|
76
|
+
Preview text: 'Rip UI weekly digest with product, customer, and delivery updates.'
|
|
77
|
+
Container class: 'mx-auto max-w-2xl border border-slate-200 bg-white p-8'
|
|
78
|
+
Heading as: 'h1', class: 'm-0 text-3xl font-semibold tracking-tight text-slate-900'
|
|
79
|
+
= @title
|
|
80
|
+
|
|
81
|
+
Text class: 'my-4 text-sm leading-6 text-slate-600'
|
|
82
|
+
= 'A compact benchmark email that exercises the Rip Tailwind inliner and themed email path.'
|
|
83
|
+
|
|
84
|
+
Divider class: 'my-6 border-slate-200'
|
|
85
|
+
|
|
86
|
+
for entry, idx in benchLinks
|
|
87
|
+
Section class: "rounded-lg border border-slate-200 bg-white p-5 #{if idx < benchLinks.length - 1 then 'mb-4' else ''}"
|
|
88
|
+
Row
|
|
89
|
+
Column class: 'w-[70%]'
|
|
90
|
+
Heading as: 'h2', class: 'm-0 text-lg font-semibold text-slate-900'
|
|
91
|
+
= entry.title
|
|
92
|
+
Text class: 'mb-0 mt-2 text-sm leading-6 text-slate-600'
|
|
93
|
+
= entry.body
|
|
94
|
+
Column class: 'w-[30%] text-right'
|
|
95
|
+
Button href: entry.href, class: @buttonClass
|
|
96
|
+
= 'Open report'
|
|
97
|
+
|
|
98
|
+
Divider class: 'my-6 border-slate-200'
|
|
99
|
+
|
|
100
|
+
Text class: 'm-0 text-sm text-slate-500'
|
|
101
|
+
= 'Need more detail?'
|
|
102
|
+
= ' '
|
|
103
|
+
Link href: 'https://example.com/team', class: @linkClass
|
|
104
|
+
= 'Visit the team dashboard'
|
package/email/compat.rip
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# ==============================================================================
|
|
2
|
+
# Compat — email-specific helper functions
|
|
3
|
+
#
|
|
4
|
+
# Ported from React Email's utility files, rewritten as idiomatic Rip.
|
|
5
|
+
# Zero dependencies.
|
|
6
|
+
# ==============================================================================
|
|
7
|
+
|
|
8
|
+
# --- Unit conversion ---
|
|
9
|
+
|
|
10
|
+
export pxToPt = (px) ->
|
|
11
|
+
return undefined unless typeof px is 'number' and not Number.isNaN(px)
|
|
12
|
+
px * 3 / 4
|
|
13
|
+
|
|
14
|
+
export convertToPx = (value) ->
|
|
15
|
+
return 0 unless value?
|
|
16
|
+
return value if typeof value is 'number'
|
|
17
|
+
str = String(value)
|
|
18
|
+
if str =~ /^([\d.]+)(px|em|rem|%)$/
|
|
19
|
+
num = parseFloat(_[1])
|
|
20
|
+
switch _[2]
|
|
21
|
+
when 'px' then num
|
|
22
|
+
when 'em', 'rem' then num * 16
|
|
23
|
+
when '%' then num / 100 * 600
|
|
24
|
+
else num
|
|
25
|
+
else 0
|
|
26
|
+
|
|
27
|
+
# --- Padding parser ---
|
|
28
|
+
|
|
29
|
+
styleToObject = (style) ->
|
|
30
|
+
return {} unless style?
|
|
31
|
+
return style if typeof style is 'object'
|
|
32
|
+
|
|
33
|
+
result = {}
|
|
34
|
+
for raw in String(style).split(';')
|
|
35
|
+
s = raw.trim()
|
|
36
|
+
continue unless s
|
|
37
|
+
idx = s.indexOf(':')
|
|
38
|
+
continue if idx is -1
|
|
39
|
+
key = s.slice(0, idx).trim()
|
|
40
|
+
val = s.slice(idx + 1).trim()
|
|
41
|
+
camel = key.replace /-([a-z])/g, (_, c) -> c.toUpperCase()
|
|
42
|
+
result[camel] = val
|
|
43
|
+
result
|
|
44
|
+
|
|
45
|
+
parsePaddingValue = (value) ->
|
|
46
|
+
base = { paddingTop: undefined, paddingRight: undefined, paddingBottom: undefined, paddingLeft: undefined }
|
|
47
|
+
return base unless value?
|
|
48
|
+
|
|
49
|
+
if typeof value is 'number'
|
|
50
|
+
return { paddingTop: value, paddingRight: value, paddingBottom: value, paddingLeft: value }
|
|
51
|
+
|
|
52
|
+
vals = String(value).trim().split(/\s+/)
|
|
53
|
+
switch vals.length
|
|
54
|
+
when 1 then { paddingTop: vals[0], paddingRight: vals[0], paddingBottom: vals[0], paddingLeft: vals[0] }
|
|
55
|
+
when 2 then { paddingTop: vals[0], paddingRight: vals[1], paddingBottom: vals[0], paddingLeft: vals[1] }
|
|
56
|
+
when 3 then { paddingTop: vals[0], paddingRight: vals[1], paddingBottom: vals[2], paddingLeft: vals[1] }
|
|
57
|
+
when 4 then { paddingTop: vals[0], paddingRight: vals[1], paddingBottom: vals[2], paddingLeft: vals[3] }
|
|
58
|
+
else base
|
|
59
|
+
|
|
60
|
+
export parsePadding = (style = {}) ->
|
|
61
|
+
style = styleToObject(style)
|
|
62
|
+
pt = undefined
|
|
63
|
+
pr = undefined
|
|
64
|
+
pb = undefined
|
|
65
|
+
pl = undefined
|
|
66
|
+
|
|
67
|
+
for key, val of style
|
|
68
|
+
if key is 'padding'
|
|
69
|
+
parsed = parsePaddingValue(val)
|
|
70
|
+
pt = parsed.paddingTop
|
|
71
|
+
pr = parsed.paddingRight
|
|
72
|
+
pb = parsed.paddingBottom
|
|
73
|
+
pl = parsed.paddingLeft
|
|
74
|
+
else if key is 'paddingTop' then pt = val
|
|
75
|
+
else if key is 'paddingRight' then pr = val
|
|
76
|
+
else if key is 'paddingBottom' then pb = val
|
|
77
|
+
else if key is 'paddingLeft' then pl = val
|
|
78
|
+
|
|
79
|
+
paddingTop: if pt? then convertToPx(pt) else undefined
|
|
80
|
+
paddingRight: if pr? then convertToPx(pr) else undefined
|
|
81
|
+
paddingBottom: if pb? then convertToPx(pb) else undefined
|
|
82
|
+
paddingLeft: if pl? then convertToPx(pl) else undefined
|
|
83
|
+
|
|
84
|
+
# --- MSO button padding ---
|
|
85
|
+
|
|
86
|
+
MAX_FONT_WIDTH =! 5
|
|
87
|
+
|
|
88
|
+
export computeMsoPadding = (expectedWidth) ->
|
|
89
|
+
return [0, 0] if expectedWidth is 0
|
|
90
|
+
spaceCount = 0
|
|
91
|
+
fontWidth = -> if spaceCount > 0 then expectedWidth / spaceCount / 2 else Infinity
|
|
92
|
+
spaceCount++ while fontWidth() > MAX_FONT_WIDTH
|
|
93
|
+
[fontWidth(), spaceCount]
|
|
94
|
+
|
|
95
|
+
# --- Preview text padding ---
|
|
96
|
+
|
|
97
|
+
WHITESPACE_CHARS =! '\xa0\u200C\u200B\u200D\u200E\u200F\uFEFF'
|
|
98
|
+
PREVIEW_MAX_LENGTH =! 150
|
|
99
|
+
|
|
100
|
+
export previewPadding = (text, maxLen = PREVIEW_MAX_LENGTH) ->
|
|
101
|
+
remaining = maxLen - text.length
|
|
102
|
+
return '' if remaining <= 0
|
|
103
|
+
WHITESPACE_CHARS.repeat(remaining)
|
|
104
|
+
|
|
105
|
+
# --- Style object to CSS string ---
|
|
106
|
+
|
|
107
|
+
export styleToString = (obj) ->
|
|
108
|
+
parts = []
|
|
109
|
+
for key, val of obj
|
|
110
|
+
continue unless val?
|
|
111
|
+
kebab = key.replace /[A-Z]/g, (m) -> "-#{m.toLowerCase()}"
|
|
112
|
+
parts.push "#{kebab}:#{val}"
|
|
113
|
+
parts.join(';')
|
|
114
|
+
|
|
115
|
+
# --- MSO conditional wrapper ---
|
|
116
|
+
|
|
117
|
+
export msoConditional = (html) ->
|
|
118
|
+
"<!--[if mso]>#{html}<![endif]-->"
|
|
119
|
+
|
|
120
|
+
# --- MSO button HTML ---
|
|
121
|
+
|
|
122
|
+
export msoButtonHtml = (paddingLeft, paddingRight, paddingBottom) ->
|
|
123
|
+
[plFontWidth, plSpaceCount] = computeMsoPadding(paddingLeft ?? 0)
|
|
124
|
+
[prFontWidth, prSpaceCount] = computeMsoPadding(paddingRight ?? 0)
|
|
125
|
+
textRaise = pxToPt((paddingBottom ?? 0) * 2) ?? 0
|
|
126
|
+
|
|
127
|
+
left = "<!--[if mso]><i style=\"mso-font-width:#{plFontWidth * 100}%;mso-text-raise:#{textRaise}\" hidden>#{' '.repeat(plSpaceCount)}</i><![endif]-->"
|
|
128
|
+
right = "<!--[if mso]><i style=\"mso-font-width:#{prFontWidth * 100}%\" hidden>#{' '.repeat(prSpaceCount)}​</i><![endif]-->"
|
|
129
|
+
{ left, right }
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
# ==============================================================================
|
|
2
|
+
# Rip Email — curated PascalCase component catalog
|
|
3
|
+
#
|
|
4
|
+
# Each component uses native lowercase HTML tags internally via Rip render
|
|
5
|
+
# blocks. The public API is uniform PascalCase.
|
|
6
|
+
# ==============================================================================
|
|
7
|
+
|
|
8
|
+
import { previewPadding, computeMsoPadding, pxToPt, parsePadding } from './compat.rip'
|
|
9
|
+
import { joinStyles, withMargin } from '../shared/styles.rip'
|
|
10
|
+
import { registerEmailTailwindRoot } from '../tailwind/tailwind.rip'
|
|
11
|
+
|
|
12
|
+
# ==============================================================================
|
|
13
|
+
# Document
|
|
14
|
+
# ==============================================================================
|
|
15
|
+
|
|
16
|
+
export Email = component
|
|
17
|
+
@class:: string := ""
|
|
18
|
+
@lang:: string := "en"
|
|
19
|
+
@dir:: "ltr" | "rtl" := "ltr"
|
|
20
|
+
render
|
|
21
|
+
html class: @class, lang: @lang, dir: @dir
|
|
22
|
+
slot
|
|
23
|
+
|
|
24
|
+
export Head = component
|
|
25
|
+
@class:: string := ""
|
|
26
|
+
render
|
|
27
|
+
head class: @class
|
|
28
|
+
meta content: "text/html; charset=UTF-8", "http-equiv": "Content-Type"
|
|
29
|
+
meta name: "x-apple-disable-message-reformatting"
|
|
30
|
+
slot
|
|
31
|
+
|
|
32
|
+
export Body = component
|
|
33
|
+
@class:: string := ""
|
|
34
|
+
@style:: string := ""
|
|
35
|
+
render
|
|
36
|
+
body class: @class
|
|
37
|
+
table align: "center", width: "100%", border: "0", cellPadding: "0", cellSpacing: "0", role: "presentation"
|
|
38
|
+
tbody
|
|
39
|
+
tr
|
|
40
|
+
td style: @style
|
|
41
|
+
slot
|
|
42
|
+
|
|
43
|
+
export Preview = component
|
|
44
|
+
@class:: string := ""
|
|
45
|
+
@text:: string := ""
|
|
46
|
+
_padding ~= previewPadding(@text)
|
|
47
|
+
render
|
|
48
|
+
div class: @class, style: "display:none;overflow:hidden;line-height:1px;opacity:0;max-height:0;max-width:0", "data-skip-in-text": "true"
|
|
49
|
+
= @text
|
|
50
|
+
div
|
|
51
|
+
= _padding
|
|
52
|
+
|
|
53
|
+
export Font = component
|
|
54
|
+
@fontFamily:: string := ""
|
|
55
|
+
@fallbackFontFamily:: string | string[] := "Arial"
|
|
56
|
+
@fontStyle:: string := "normal"
|
|
57
|
+
@fontWeight:: number := 400
|
|
58
|
+
@webFontUrl:: string := ""
|
|
59
|
+
@webFontFormat:: string := "woff2"
|
|
60
|
+
|
|
61
|
+
_src ~= if @webFontUrl then "src: url(#{@webFontUrl}) format('#{@webFontFormat}');" else ''
|
|
62
|
+
_fallback:: string[] ~= if kind(@fallbackFontFamily) is 'array' then Array.from(@fallbackFontFamily) else [@fallbackFontFamily]
|
|
63
|
+
_msoAlt ~= _fallback[0]
|
|
64
|
+
_fallbackStr:: string ~= _fallback.join(', ')
|
|
65
|
+
|
|
66
|
+
_css ~= """
|
|
67
|
+
@font-face {
|
|
68
|
+
font-family: '#{@fontFamily}';
|
|
69
|
+
font-style: #{@fontStyle};
|
|
70
|
+
font-weight: #{@fontWeight};
|
|
71
|
+
mso-font-alt: '#{_msoAlt}';
|
|
72
|
+
#{_src}
|
|
73
|
+
}
|
|
74
|
+
* {
|
|
75
|
+
font-family: '#{@fontFamily}', #{_fallbackStr};
|
|
76
|
+
}
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
render
|
|
80
|
+
if @fontFamily
|
|
81
|
+
style innerHTML: _css
|
|
82
|
+
|
|
83
|
+
# ==============================================================================
|
|
84
|
+
# Layout
|
|
85
|
+
# ==============================================================================
|
|
86
|
+
|
|
87
|
+
export Container = component
|
|
88
|
+
@class:: string := ""
|
|
89
|
+
@style:: string := ""
|
|
90
|
+
_style ~= joinStyles("max-width:37.5em", @style)
|
|
91
|
+
render
|
|
92
|
+
table class: @class, align: "center", width: "100%", border: "0", cellPadding: "0", cellSpacing: "0", role: "presentation", style: _style
|
|
93
|
+
tbody
|
|
94
|
+
tr style: "width:100%"
|
|
95
|
+
td
|
|
96
|
+
slot
|
|
97
|
+
|
|
98
|
+
export Section = component
|
|
99
|
+
@class:: string := ""
|
|
100
|
+
@style:: string := ""
|
|
101
|
+
render
|
|
102
|
+
table class: @class, align: "center", width: "100%", border: "0", cellPadding: "0", cellSpacing: "0", role: "presentation", style: @style
|
|
103
|
+
tbody
|
|
104
|
+
tr
|
|
105
|
+
td
|
|
106
|
+
slot
|
|
107
|
+
|
|
108
|
+
export Row = component
|
|
109
|
+
@class:: string := ""
|
|
110
|
+
@style:: string := ""
|
|
111
|
+
render
|
|
112
|
+
table class: @class, align: "center", width: "100%", border: "0", cellPadding: "0", cellSpacing: "0", role: "presentation", style: @style
|
|
113
|
+
tbody
|
|
114
|
+
tr style: "width:100%"
|
|
115
|
+
slot
|
|
116
|
+
|
|
117
|
+
export Column = component
|
|
118
|
+
@class:: string := ""
|
|
119
|
+
@style:: string := ""
|
|
120
|
+
render
|
|
121
|
+
td class: @class, style: @style
|
|
122
|
+
slot
|
|
123
|
+
|
|
124
|
+
# ==============================================================================
|
|
125
|
+
# Content
|
|
126
|
+
# ==============================================================================
|
|
127
|
+
|
|
128
|
+
export Heading = component
|
|
129
|
+
@as:: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" := "h1"
|
|
130
|
+
@class:: string := ""
|
|
131
|
+
@style:: string := ""
|
|
132
|
+
@m:: string | undefined := undefined
|
|
133
|
+
@mx:: string | undefined := undefined
|
|
134
|
+
@my:: string | undefined := undefined
|
|
135
|
+
@mt:: string | undefined := undefined
|
|
136
|
+
@mr:: string | undefined := undefined
|
|
137
|
+
@mb:: string | undefined := undefined
|
|
138
|
+
@ml:: string | undefined := undefined
|
|
139
|
+
_marginStyle ~= withMargin {
|
|
140
|
+
m: @m
|
|
141
|
+
mx: @mx
|
|
142
|
+
my: @my
|
|
143
|
+
mt: @mt
|
|
144
|
+
mr: @mr
|
|
145
|
+
mb: @mb
|
|
146
|
+
ml: @ml
|
|
147
|
+
}
|
|
148
|
+
_style ~= joinStyles(_marginStyle, @style)
|
|
149
|
+
render
|
|
150
|
+
switch @as
|
|
151
|
+
when 'h1' then h1 class: @class, style: _style, slot
|
|
152
|
+
when 'h2' then h2 class: @class, style: _style, slot
|
|
153
|
+
when 'h3' then h3 class: @class, style: _style, slot
|
|
154
|
+
when 'h4' then h4 class: @class, style: _style, slot
|
|
155
|
+
when 'h5' then h5 class: @class, style: _style, slot
|
|
156
|
+
when 'h6' then h6 class: @class, style: _style, slot
|
|
157
|
+
else h1 class: @class, style: _style, slot
|
|
158
|
+
|
|
159
|
+
export Text = component
|
|
160
|
+
@class:: string := ""
|
|
161
|
+
@style:: string := ""
|
|
162
|
+
_style ~= joinStyles("font-size:14px;line-height:24px;margin:16px 0", @style)
|
|
163
|
+
render
|
|
164
|
+
p class: @class, style: _style
|
|
165
|
+
slot
|
|
166
|
+
|
|
167
|
+
export Link = component
|
|
168
|
+
@class:: string := ""
|
|
169
|
+
@href:: string := ""
|
|
170
|
+
@target:: string := "_blank"
|
|
171
|
+
@style:: string := ""
|
|
172
|
+
_style ~= joinStyles("color:#067df7;text-decoration:none", @style)
|
|
173
|
+
render
|
|
174
|
+
a class: @class, href: @href, target: @target, style: _style
|
|
175
|
+
slot
|
|
176
|
+
|
|
177
|
+
export Image = component
|
|
178
|
+
@class:: string := ""
|
|
179
|
+
@src:: string := ""
|
|
180
|
+
@alt:: string := ""
|
|
181
|
+
@width:: number | undefined := undefined
|
|
182
|
+
@height:: number | undefined := undefined
|
|
183
|
+
@style:: string := ""
|
|
184
|
+
_style ~= joinStyles("display:block;outline:none;border:none;text-decoration:none", @style)
|
|
185
|
+
render
|
|
186
|
+
img class: @class, src: @src, alt: @alt, width: @width, height: @height, style: _style
|
|
187
|
+
|
|
188
|
+
export Divider = component
|
|
189
|
+
@class:: string := ""
|
|
190
|
+
@style:: string := ""
|
|
191
|
+
_style ~= joinStyles("width:100%;border:none;border-top:1px solid #eaeaea", @style)
|
|
192
|
+
render
|
|
193
|
+
hr class: @class, style: _style
|
|
194
|
+
|
|
195
|
+
export Button = component
|
|
196
|
+
@class:: string := ""
|
|
197
|
+
@href:: string := ""
|
|
198
|
+
@target:: string := "_blank"
|
|
199
|
+
@style:: string := ""
|
|
200
|
+
|
|
201
|
+
_padding ~= parsePadding(@style)
|
|
202
|
+
_y ~= (_padding.paddingTop ?? 0) + (_padding.paddingBottom ?? 0)
|
|
203
|
+
_textRaise ~= pxToPt(_y) ?? 0
|
|
204
|
+
_plResult ~= computeMsoPadding(_padding.paddingLeft ?? 0)
|
|
205
|
+
_prResult ~= computeMsoPadding(_padding.paddingRight ?? 0)
|
|
206
|
+
_msoLeft ~= "<!--[if mso]><i style=\"mso-font-width:#{_plResult[0] * 100}%;mso-text-raise:#{_textRaise}\" hidden>#{' '.repeat(_plResult[1])}</i><![endif]-->"
|
|
207
|
+
_msoRight ~= "<!--[if mso]><i style=\"mso-font-width:#{_prResult[0] * 100}%\" hidden>#{' '.repeat(_prResult[1])}​</i><![endif]-->"
|
|
208
|
+
|
|
209
|
+
_linkStyle ~= joinStyles("line-height:100%;text-decoration:none;display:inline-block;max-width:100%;mso-padding-alt:0px", @style)
|
|
210
|
+
|
|
211
|
+
render
|
|
212
|
+
a class: @class, href: @href, target: @target, style: _linkStyle
|
|
213
|
+
span innerHTML: _msoLeft
|
|
214
|
+
span style: "max-width:100%;display:inline-block;line-height:120%;mso-padding-alt:0px"
|
|
215
|
+
slot
|
|
216
|
+
span innerHTML: _msoRight
|
|
217
|
+
|
|
218
|
+
# ==============================================================================
|
|
219
|
+
# Rich content
|
|
220
|
+
# ==============================================================================
|
|
221
|
+
|
|
222
|
+
_escapeHtml = (text:: string) ->
|
|
223
|
+
String(text)
|
|
224
|
+
.replace(/&/g, '&')
|
|
225
|
+
.replace(/</g, '<')
|
|
226
|
+
.replace(/>/g, '>')
|
|
227
|
+
.replace(/"/g, '"')
|
|
228
|
+
|
|
229
|
+
_escapeAttr = (text:: string) ->
|
|
230
|
+
String(text)
|
|
231
|
+
.replace(/&/g, '&')
|
|
232
|
+
.replace(/"/g, '"')
|
|
233
|
+
|
|
234
|
+
_safeHref = (href:: string) ->
|
|
235
|
+
value = String(href or '').trim()
|
|
236
|
+
return '' unless value
|
|
237
|
+
|
|
238
|
+
lower = value.toLowerCase()
|
|
239
|
+
return value if lower.startsWith('http://')
|
|
240
|
+
return value if lower.startsWith('https://')
|
|
241
|
+
return value if lower.startsWith('mailto:')
|
|
242
|
+
return value if lower.startsWith('tel:')
|
|
243
|
+
return value if lower.startsWith('/')
|
|
244
|
+
return value if lower.startsWith('#')
|
|
245
|
+
'#'
|
|
246
|
+
|
|
247
|
+
_inlineFormat = (text:: string) ->
|
|
248
|
+
escaped = _escapeHtml(text)
|
|
249
|
+
|
|
250
|
+
escaped
|
|
251
|
+
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
|
252
|
+
.replace(/__(.+?)__/g, '<strong>$1</strong>')
|
|
253
|
+
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
|
254
|
+
.replace(/_(.+?)_/g, '<em>$1</em>')
|
|
255
|
+
.replace(/`(.+?)`/g, '<code>$1</code>')
|
|
256
|
+
.replace /\[(.+?)\]\((.+?)\)/g, (_, label, href) ->
|
|
257
|
+
safeHref = _escapeAttr(_safeHref(href))
|
|
258
|
+
"<a href=\"#{safeHref}\" target=\"_blank\">#{label}</a>"
|
|
259
|
+
|
|
260
|
+
_parseMarkdown = (src:: string) ->
|
|
261
|
+
return '' unless src
|
|
262
|
+
lines = src.split('\n')
|
|
263
|
+
out = []
|
|
264
|
+
inList = null
|
|
265
|
+
|
|
266
|
+
for line in lines
|
|
267
|
+
stripped = line.trim()
|
|
268
|
+
|
|
269
|
+
if stripped is ''
|
|
270
|
+
if inList
|
|
271
|
+
out.push("</#{inList}>")
|
|
272
|
+
inList = null
|
|
273
|
+
continue
|
|
274
|
+
|
|
275
|
+
if stripped[0] is '#'
|
|
276
|
+
level = 0
|
|
277
|
+
level++ while stripped[level] is '#' and level < 6
|
|
278
|
+
text = stripped.slice(level).trim()
|
|
279
|
+
out.push("<h#{level}>#{_inlineFormat(text)}</h#{level}>")
|
|
280
|
+
continue
|
|
281
|
+
|
|
282
|
+
if (stripped[0] is '-' or stripped[0] is '*') and stripped[1] is ' '
|
|
283
|
+
unless inList is 'ul'
|
|
284
|
+
out.push("</#{inList}>") if inList
|
|
285
|
+
out.push('<ul>')
|
|
286
|
+
inList = 'ul'
|
|
287
|
+
out.push("<li>#{_inlineFormat(stripped.slice(2))}</li>")
|
|
288
|
+
continue
|
|
289
|
+
|
|
290
|
+
if stripped =~ /^\d+\.\s+(.*)/
|
|
291
|
+
unless inList is 'ol'
|
|
292
|
+
out.push("</#{inList}>") if inList
|
|
293
|
+
out.push('<ol>')
|
|
294
|
+
inList = 'ol'
|
|
295
|
+
out.push("<li>#{_inlineFormat(_[1])}</li>")
|
|
296
|
+
continue
|
|
297
|
+
|
|
298
|
+
if stripped is '---' or stripped is '***' or stripped is '___'
|
|
299
|
+
out.push('<hr />')
|
|
300
|
+
continue
|
|
301
|
+
|
|
302
|
+
if stripped[0] is '>'
|
|
303
|
+
out.push("<blockquote>#{_inlineFormat(stripped.slice(1).trim())}</blockquote>")
|
|
304
|
+
continue
|
|
305
|
+
|
|
306
|
+
out.push("<p>#{_inlineFormat(stripped)}</p>")
|
|
307
|
+
|
|
308
|
+
out.push("</#{inList}>") if inList
|
|
309
|
+
out.join('\n')
|
|
310
|
+
|
|
311
|
+
export Markdown = component
|
|
312
|
+
@class:: string := ""
|
|
313
|
+
@text:: string := ""
|
|
314
|
+
@style:: string := ""
|
|
315
|
+
_html ~= _parseMarkdown(@text)
|
|
316
|
+
render
|
|
317
|
+
# Raw HTML from markdown input is escaped; only this curated subset renders as tags.
|
|
318
|
+
div class: @class, style: @style, innerHTML: _html
|
|
319
|
+
|
|
320
|
+
export CodeBlock = component
|
|
321
|
+
@class:: string := ""
|
|
322
|
+
@code:: string := ""
|
|
323
|
+
@lineNumbers:: boolean := false
|
|
324
|
+
@style:: string := ""
|
|
325
|
+
_baseStyle =! "background:#f4f4f4;padding:16px;border-radius:4px;font-family:monospace;font-size:13px;overflow-x:auto;white-space:pre"
|
|
326
|
+
_style ~= "#{_baseStyle};#{@style}"
|
|
327
|
+
|
|
328
|
+
_html ~=
|
|
329
|
+
lines = @code.split('\n')
|
|
330
|
+
result = []
|
|
331
|
+
for line, i in lines
|
|
332
|
+
escaped = line
|
|
333
|
+
.replace(/&/g, '&')
|
|
334
|
+
.replace(/</g, '<')
|
|
335
|
+
.replace(/>/g, '>')
|
|
336
|
+
if @lineNumbers
|
|
337
|
+
num = "<span style=\"display:inline-block;width:2em;color:#999;user-select:none\">#{i + 1}</span>"
|
|
338
|
+
result.push("#{num}#{escaped}")
|
|
339
|
+
else
|
|
340
|
+
result.push(escaped)
|
|
341
|
+
result.join('\n')
|
|
342
|
+
|
|
343
|
+
render
|
|
344
|
+
pre class: @class, style: _style
|
|
345
|
+
code innerHTML: _html
|
|
346
|
+
|
|
347
|
+
export CodeInline = component
|
|
348
|
+
@class:: string := ""
|
|
349
|
+
@style:: string := ""
|
|
350
|
+
_codeStyle ~= "background:#f4f4f4;padding:2px 4px;border-radius:3px;font-family:monospace;font-size:0.9em;#{@style}"
|
|
351
|
+
_orangeCss =! '''
|
|
352
|
+
meta ~ .cino { display: none !important; opacity: 0 !important; }
|
|
353
|
+
meta ~ .cio { display: block !important; }
|
|
354
|
+
'''
|
|
355
|
+
render
|
|
356
|
+
style innerHTML: _orangeCss
|
|
357
|
+
code class: joinStyles(@class, "cino"), style: _codeStyle
|
|
358
|
+
slot
|
|
359
|
+
span class: joinStyles(@class, "cio"), style: "display:none;#{_codeStyle}"
|
|
360
|
+
slot
|
|
361
|
+
|
|
362
|
+
# ==============================================================================
|
|
363
|
+
# Styling
|
|
364
|
+
# ==============================================================================
|
|
365
|
+
|
|
366
|
+
export Tailwind = component
|
|
367
|
+
@config:: any := undefined
|
|
368
|
+
mounted: ->
|
|
369
|
+
registerEmailTailwindRoot(this, @config)
|
|
370
|
+
render
|
|
371
|
+
slot
|