@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.
Files changed (83) hide show
  1. package/AGENTS.md +93 -0
  2. package/README.md +22 -625
  3. package/browser/AGENTS.md +213 -0
  4. package/browser/CONTRIBUTING.md +375 -0
  5. package/browser/README.md +11 -0
  6. package/browser/TESTING.md +59 -0
  7. package/browser/browser.rip +56 -0
  8. package/{components → browser/components}/accordion.rip +1 -1
  9. package/{components → browser/components}/alert-dialog.rip +6 -3
  10. package/{components → browser/components}/autocomplete.rip +27 -21
  11. package/{components → browser/components}/avatar.rip +3 -3
  12. package/{components → browser/components}/badge.rip +1 -1
  13. package/{components → browser/components}/breadcrumb.rip +2 -2
  14. package/{components → browser/components}/button-group.rip +3 -3
  15. package/{components → browser/components}/button.rip +2 -2
  16. package/{components → browser/components}/card.rip +1 -1
  17. package/{components → browser/components}/carousel.rip +5 -5
  18. package/{components → browser/components}/checkbox-group.rip +40 -11
  19. package/{components → browser/components}/checkbox.rip +4 -4
  20. package/{components → browser/components}/collapsible.rip +2 -2
  21. package/{components → browser/components}/combobox.rip +36 -23
  22. package/{components → browser/components}/context-menu.rip +1 -1
  23. package/{components → browser/components}/date-picker.rip +5 -5
  24. package/{components → browser/components}/dialog.rip +8 -4
  25. package/{components → browser/components}/drawer.rip +8 -4
  26. package/{components → browser/components}/editable-value.rip +7 -1
  27. package/{components → browser/components}/field.rip +5 -5
  28. package/{components → browser/components}/fieldset.rip +2 -2
  29. package/{components → browser/components}/form.rip +1 -1
  30. package/{components → browser/components}/grid.rip +8 -8
  31. package/{components → browser/components}/input-group.rip +1 -1
  32. package/{components → browser/components}/input.rip +6 -6
  33. package/{components → browser/components}/label.rip +2 -2
  34. package/{components → browser/components}/menu.rip +17 -10
  35. package/{components → browser/components}/menubar.rip +1 -1
  36. package/{components → browser/components}/meter.rip +7 -7
  37. package/{components → browser/components}/multi-select.rip +76 -33
  38. package/{components → browser/components}/native-select.rip +3 -3
  39. package/{components → browser/components}/nav-menu.rip +3 -3
  40. package/{components → browser/components}/number-field.rip +11 -11
  41. package/{components → browser/components}/otp-field.rip +4 -4
  42. package/{components → browser/components}/pagination.rip +4 -4
  43. package/{components → browser/components}/popover.rip +11 -24
  44. package/{components → browser/components}/preview-card.rip +7 -11
  45. package/{components → browser/components}/progress.rip +3 -3
  46. package/{components → browser/components}/radio-group.rip +4 -4
  47. package/{components → browser/components}/resizable.rip +3 -3
  48. package/{components → browser/components}/scroll-area.rip +1 -1
  49. package/{components → browser/components}/select.rip +55 -27
  50. package/{components → browser/components}/separator.rip +2 -2
  51. package/{components → browser/components}/skeleton.rip +4 -4
  52. package/{components → browser/components}/slider.rip +15 -10
  53. package/{components → browser/components}/spinner.rip +2 -2
  54. package/{components → browser/components}/table.rip +2 -2
  55. package/{components → browser/components}/tabs.rip +12 -7
  56. package/{components → browser/components}/textarea.rip +8 -8
  57. package/{components → browser/components}/toast.rip +3 -3
  58. package/{components → browser/components}/toggle-group.rip +42 -11
  59. package/{components → browser/components}/toggle.rip +2 -2
  60. package/{components → browser/components}/toolbar.rip +2 -2
  61. package/{components → browser/components}/tooltip.rip +19 -23
  62. package/browser/hljs-rip.js +209 -0
  63. package/browser/playwright.config.mjs +31 -0
  64. package/browser/tests/overlays.js +349 -0
  65. package/email/AGENTS.md +16 -0
  66. package/email/README.md +55 -0
  67. package/email/benchmarks/benchmark.rip +94 -0
  68. package/email/benchmarks/samples.rip +104 -0
  69. package/email/compat.rip +129 -0
  70. package/email/components.rip +371 -0
  71. package/email/dom.rip +330 -0
  72. package/email/email.rip +10 -0
  73. package/email/render.rip +82 -0
  74. package/package.json +29 -39
  75. package/shared/README.md +3 -0
  76. package/shared/styles.rip +17 -0
  77. package/tailwind/AGENTS.md +3 -0
  78. package/tailwind/README.md +27 -0
  79. package/tailwind/engine.js +107 -0
  80. package/tailwind/inline.js +215 -0
  81. package/tailwind/serve.js +6 -0
  82. package/tailwind/tailwind.rip +13 -0
  83. 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'
@@ -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>#{'&#8202;'.repeat(plSpaceCount)}</i><![endif]-->"
128
+ right = "<!--[if mso]><i style=\"mso-font-width:#{prFontWidth * 100}%\" hidden>#{'&#8202;'.repeat(prSpaceCount)}&#8203;</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>#{'&#8202;'.repeat(_plResult[1])}</i><![endif]-->"
207
+ _msoRight ~= "<!--[if mso]><i style=\"mso-font-width:#{_prResult[0] * 100}%\" hidden>#{'&#8202;'.repeat(_prResult[1])}&#8203;</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, '&amp;')
225
+ .replace(/</g, '&lt;')
226
+ .replace(/>/g, '&gt;')
227
+ .replace(/"/g, '&quot;')
228
+
229
+ _escapeAttr = (text:: string) ->
230
+ String(text)
231
+ .replace(/&/g, '&amp;')
232
+ .replace(/"/g, '&quot;')
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, '&amp;')
334
+ .replace(/</g, '&lt;')
335
+ .replace(/>/g, '&gt;')
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