@rip-lang/ui 0.3.67 → 0.4.2
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 +352 -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
package/email/dom.rip
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
# ==============================================================================
|
|
2
|
+
# DOM Shim — server-side DOM for rendering Rip components to email HTML
|
|
3
|
+
# ==============================================================================
|
|
4
|
+
|
|
5
|
+
# --- Node base class ---
|
|
6
|
+
|
|
7
|
+
export class Node
|
|
8
|
+
constructor: ->
|
|
9
|
+
@parentNode = null
|
|
10
|
+
@childNodes = []
|
|
11
|
+
@_next = null
|
|
12
|
+
@_prev = null
|
|
13
|
+
@_innerHTML = null
|
|
14
|
+
|
|
15
|
+
appendChild: (child) ->
|
|
16
|
+
child.remove() if child.parentNode
|
|
17
|
+
if child instanceof Fragment
|
|
18
|
+
for c in [...child.childNodes]
|
|
19
|
+
@appendChild(c)
|
|
20
|
+
return child
|
|
21
|
+
last = @childNodes[@childNodes.length - 1]
|
|
22
|
+
last._next = child if last
|
|
23
|
+
child._prev = last or null
|
|
24
|
+
child._next = null
|
|
25
|
+
child.parentNode = this
|
|
26
|
+
@childNodes.push(child)
|
|
27
|
+
child
|
|
28
|
+
|
|
29
|
+
insertBefore: (child, ref) ->
|
|
30
|
+
return @appendChild(child) unless ref
|
|
31
|
+
child.remove() if child.parentNode
|
|
32
|
+
if child instanceof Fragment
|
|
33
|
+
kids = [...child.childNodes]
|
|
34
|
+
for c in kids
|
|
35
|
+
@insertBefore(c, ref)
|
|
36
|
+
return child
|
|
37
|
+
idx = @childNodes.indexOf(ref)
|
|
38
|
+
return @appendChild(child) if idx is -1
|
|
39
|
+
prev = ref._prev
|
|
40
|
+
prev._next = child if prev
|
|
41
|
+
child._prev = prev
|
|
42
|
+
child._next = ref
|
|
43
|
+
ref._prev = child
|
|
44
|
+
child.parentNode = this
|
|
45
|
+
@childNodes.splice(idx, 0, child)
|
|
46
|
+
child
|
|
47
|
+
|
|
48
|
+
removeChild: (child) ->
|
|
49
|
+
idx = @childNodes.indexOf(child)
|
|
50
|
+
return child if idx is -1
|
|
51
|
+
prev = child._prev
|
|
52
|
+
nxt = child._next
|
|
53
|
+
prev._next = nxt if prev
|
|
54
|
+
nxt._prev = prev if nxt
|
|
55
|
+
child._prev = null
|
|
56
|
+
child._next = null
|
|
57
|
+
child.parentNode = null
|
|
58
|
+
@childNodes.splice(idx, 1)
|
|
59
|
+
child
|
|
60
|
+
|
|
61
|
+
remove: ->
|
|
62
|
+
@parentNode?.removeChild(this)
|
|
63
|
+
|
|
64
|
+
Object.defineProperty Node.prototype, 'nextSibling',
|
|
65
|
+
get: -> @_next
|
|
66
|
+
enumerable: true
|
|
67
|
+
|
|
68
|
+
Object.defineProperty Node.prototype, 'previousSibling',
|
|
69
|
+
get: -> @_prev
|
|
70
|
+
enumerable: true
|
|
71
|
+
|
|
72
|
+
Object.defineProperty Node.prototype, 'textContent',
|
|
73
|
+
get: ->
|
|
74
|
+
(@childNodes.map (c) ->
|
|
75
|
+
if c instanceof Text then c.data
|
|
76
|
+
else if c.textContent? then c.textContent
|
|
77
|
+
else ''
|
|
78
|
+
).join('')
|
|
79
|
+
set: (val) ->
|
|
80
|
+
@childNodes = []
|
|
81
|
+
if val? and val isnt ''
|
|
82
|
+
t = Text.new(String(val))
|
|
83
|
+
t.parentNode = this
|
|
84
|
+
@childNodes.push(t)
|
|
85
|
+
enumerable: true
|
|
86
|
+
|
|
87
|
+
# --- Element ---
|
|
88
|
+
|
|
89
|
+
export class Element extends Node
|
|
90
|
+
constructor: (@tag) ->
|
|
91
|
+
super(@tag)
|
|
92
|
+
@attributes = new Map()
|
|
93
|
+
@style = {}
|
|
94
|
+
|
|
95
|
+
setAttribute: (key, val) ->
|
|
96
|
+
if key is 'style'
|
|
97
|
+
if typeof val is 'object' and val isnt null
|
|
98
|
+
Object.assign(@style, val)
|
|
99
|
+
else
|
|
100
|
+
@_parseStyleString(String(val))
|
|
101
|
+
else
|
|
102
|
+
@attributes.set(key, String(val))
|
|
103
|
+
|
|
104
|
+
removeAttribute: (key) -> @attributes.delete(key)
|
|
105
|
+
|
|
106
|
+
toggleAttribute: (key, force) ->
|
|
107
|
+
if force is undefined
|
|
108
|
+
if @attributes.has(key) then @attributes.delete(key) else @attributes.set(key, '')
|
|
109
|
+
else
|
|
110
|
+
if force then @attributes.set(key, '') else @attributes.delete(key)
|
|
111
|
+
|
|
112
|
+
addEventListener: -> undefined
|
|
113
|
+
removeEventListener: -> undefined
|
|
114
|
+
dispatchEvent: -> undefined
|
|
115
|
+
|
|
116
|
+
_parseStyleString: (str) ->
|
|
117
|
+
for raw in str.split(';')
|
|
118
|
+
s = raw.trim()
|
|
119
|
+
continue unless s
|
|
120
|
+
colon = s.indexOf(':')
|
|
121
|
+
continue if colon is -1
|
|
122
|
+
prop = s.slice(0, colon).trim()
|
|
123
|
+
val = s.slice(colon + 1).trim()
|
|
124
|
+
camel = prop.replace /-([a-z])/g, (_, c) -> c.toUpperCase()
|
|
125
|
+
@style[camel] = val
|
|
126
|
+
|
|
127
|
+
Object.defineProperty Element.prototype, 'id',
|
|
128
|
+
get: -> @attributes.get('id') ?? ''
|
|
129
|
+
set: (v) -> @attributes.set('id', v)
|
|
130
|
+
enumerable: true
|
|
131
|
+
|
|
132
|
+
Object.defineProperty Element.prototype, 'className',
|
|
133
|
+
get: -> @attributes.get('class') ?? ''
|
|
134
|
+
set: (v) -> if v then @attributes.set('class', v) else @attributes.delete('class')
|
|
135
|
+
enumerable: true
|
|
136
|
+
|
|
137
|
+
Object.defineProperty Element.prototype, 'innerHTML',
|
|
138
|
+
get: -> @_innerHTML
|
|
139
|
+
set: (v) -> @_innerHTML = v
|
|
140
|
+
enumerable: true
|
|
141
|
+
|
|
142
|
+
Object.defineProperty Element.prototype, 'innerText',
|
|
143
|
+
get: -> @textContent
|
|
144
|
+
set: (v) -> @textContent = v
|
|
145
|
+
enumerable: true
|
|
146
|
+
|
|
147
|
+
Object.defineProperty Element.prototype, 'value',
|
|
148
|
+
get: -> @attributes.get('value')
|
|
149
|
+
set: (v) -> @attributes.set('value', v)
|
|
150
|
+
enumerable: true
|
|
151
|
+
|
|
152
|
+
Object.defineProperty Element.prototype, 'checked',
|
|
153
|
+
get: -> @attributes.has('checked')
|
|
154
|
+
set: (v) -> if v then @attributes.set('checked', '') else @attributes.delete('checked')
|
|
155
|
+
enumerable: true
|
|
156
|
+
|
|
157
|
+
# --- Text ---
|
|
158
|
+
|
|
159
|
+
export class Text extends Node
|
|
160
|
+
constructor: (@data = '') ->
|
|
161
|
+
super(@data)
|
|
162
|
+
|
|
163
|
+
# --- Comment ---
|
|
164
|
+
|
|
165
|
+
export class Comment extends Node
|
|
166
|
+
constructor: (@data = '') ->
|
|
167
|
+
super(@data)
|
|
168
|
+
|
|
169
|
+
# --- Fragment ---
|
|
170
|
+
|
|
171
|
+
export class Fragment extends Node
|
|
172
|
+
constructor: (@_fragId = 0) ->
|
|
173
|
+
super(@_fragId)
|
|
174
|
+
|
|
175
|
+
# --- Document ---
|
|
176
|
+
|
|
177
|
+
export class Document
|
|
178
|
+
createElement: (tag) -> Element.new(tag)
|
|
179
|
+
createElementNS: (ns, tag) -> Element.new(tag)
|
|
180
|
+
createTextNode: (text) -> Text.new(String(text))
|
|
181
|
+
createComment: (text) -> Comment.new(String(text))
|
|
182
|
+
createDocumentFragment: -> Fragment.new()
|
|
183
|
+
|
|
184
|
+
# ==============================================================================
|
|
185
|
+
# Serializer
|
|
186
|
+
# ==============================================================================
|
|
187
|
+
|
|
188
|
+
VOID_TAGS =! new Set(%w[area base br col embed hr img input link meta source track wbr])
|
|
189
|
+
|
|
190
|
+
escapeHtml = (str) ->
|
|
191
|
+
String(str)
|
|
192
|
+
.replace(/&/g, '&')
|
|
193
|
+
.replace(/</g, '<')
|
|
194
|
+
.replace(/>/g, '>')
|
|
195
|
+
.replace(/"/g, '"')
|
|
196
|
+
|
|
197
|
+
escapeAttr = (str) ->
|
|
198
|
+
String(str)
|
|
199
|
+
.replace(/&/g, '&')
|
|
200
|
+
.replace(/"/g, '"')
|
|
201
|
+
|
|
202
|
+
_styleToString = (obj) ->
|
|
203
|
+
parts = []
|
|
204
|
+
for key, val of obj
|
|
205
|
+
continue unless val?
|
|
206
|
+
kebab = key.replace /[A-Z]/g, (m) -> "-#{m.toLowerCase()}"
|
|
207
|
+
parts.push "#{kebab}:#{val}"
|
|
208
|
+
parts.join(';')
|
|
209
|
+
|
|
210
|
+
export serialize = (node) ->
|
|
211
|
+
if node instanceof Text
|
|
212
|
+
return escapeHtml(node.data)
|
|
213
|
+
|
|
214
|
+
if node instanceof Comment
|
|
215
|
+
d = node.data
|
|
216
|
+
return '' if d is '' or d is 'if' or d is 'for' or d is 'unknown'
|
|
217
|
+
return "<!--#{d}-->"
|
|
218
|
+
|
|
219
|
+
if node instanceof Fragment
|
|
220
|
+
return serializeChildren(node)
|
|
221
|
+
|
|
222
|
+
if node instanceof Element
|
|
223
|
+
return '' if node.tag is '__root'
|
|
224
|
+
tag = node.tag
|
|
225
|
+
|
|
226
|
+
if node._innerHTML?
|
|
227
|
+
attrs = _serializeAttrs(node)
|
|
228
|
+
return "<#{tag}#{attrs}>#{node._innerHTML}</#{tag}>"
|
|
229
|
+
|
|
230
|
+
attrs = _serializeAttrs(node)
|
|
231
|
+
|
|
232
|
+
if VOID_TAGS.has(tag)
|
|
233
|
+
return "<#{tag}#{attrs} />"
|
|
234
|
+
|
|
235
|
+
inner = serializeChildren(node)
|
|
236
|
+
return "<#{tag}#{attrs}>#{inner}</#{tag}>"
|
|
237
|
+
|
|
238
|
+
''
|
|
239
|
+
|
|
240
|
+
_serializeAttrs = (el) ->
|
|
241
|
+
parts = []
|
|
242
|
+
|
|
243
|
+
parts.push " id=\"#{escapeAttr(el.id)}\"" if el.attributes.has('id')
|
|
244
|
+
|
|
245
|
+
cls = el.attributes.get('class')
|
|
246
|
+
parts.push " class=\"#{escapeAttr(cls)}\"" if cls
|
|
247
|
+
|
|
248
|
+
styleStr = _styleToString(el.style)
|
|
249
|
+
parts.push " style=\"#{escapeAttr(styleStr)}\"" if styleStr
|
|
250
|
+
|
|
251
|
+
for [key, val] in el.attributes
|
|
252
|
+
continue if key is 'id' or key is 'class'
|
|
253
|
+
if val is ''
|
|
254
|
+
parts.push " #{key}"
|
|
255
|
+
else
|
|
256
|
+
parts.push " #{key}=\"#{escapeAttr(val)}\""
|
|
257
|
+
|
|
258
|
+
parts.join('')
|
|
259
|
+
|
|
260
|
+
export serializeChildren = (node) ->
|
|
261
|
+
(node.childNodes.map (c) -> serialize(c)).join('')
|
|
262
|
+
|
|
263
|
+
# ==============================================================================
|
|
264
|
+
# Plain text
|
|
265
|
+
# ==============================================================================
|
|
266
|
+
|
|
267
|
+
BLOCK_TAGS =! new Set(%w[p div td li dt dd blockquote figcaption address])
|
|
268
|
+
HEADING_TAGS =! new Set(%w[h1 h2 h3 h4 h5 h6])
|
|
269
|
+
SKIP_TAGS =! new Set(%w[style script head])
|
|
270
|
+
|
|
271
|
+
export toPlainText = (node) ->
|
|
272
|
+
lines = []
|
|
273
|
+
_walkText(node, lines)
|
|
274
|
+
lines.join('').replace(/\n{3,}/g, '\n\n').trim()
|
|
275
|
+
|
|
276
|
+
_walkText = (node, lines) ->
|
|
277
|
+
if node instanceof Text
|
|
278
|
+
lines.push(node.data)
|
|
279
|
+
return
|
|
280
|
+
|
|
281
|
+
return if node instanceof Comment
|
|
282
|
+
|
|
283
|
+
if node instanceof Fragment
|
|
284
|
+
_walkText(c, lines) for c in node.childNodes
|
|
285
|
+
return
|
|
286
|
+
|
|
287
|
+
return unless node instanceof Element
|
|
288
|
+
|
|
289
|
+
tag = node.tag
|
|
290
|
+
return if SKIP_TAGS.has(tag)
|
|
291
|
+
return if node.attributes.get('data-skip-in-text') is 'true'
|
|
292
|
+
return if node._innerHTML?
|
|
293
|
+
|
|
294
|
+
if tag is 'br'
|
|
295
|
+
lines.push('\n')
|
|
296
|
+
return
|
|
297
|
+
|
|
298
|
+
if tag is 'hr'
|
|
299
|
+
lines.push('\n---\n')
|
|
300
|
+
return
|
|
301
|
+
|
|
302
|
+
if tag is 'img'
|
|
303
|
+
alt = node.attributes.get('alt')
|
|
304
|
+
lines.push(alt) if alt
|
|
305
|
+
return
|
|
306
|
+
|
|
307
|
+
if tag is 'a'
|
|
308
|
+
href = node.attributes.get('href')
|
|
309
|
+
text = _textContent(node)
|
|
310
|
+
if href and text and href isnt text
|
|
311
|
+
lines.push("#{text} (#{href})")
|
|
312
|
+
else
|
|
313
|
+
lines.push(text)
|
|
314
|
+
return
|
|
315
|
+
|
|
316
|
+
isHeading = HEADING_TAGS.has(tag)
|
|
317
|
+
isBlock = BLOCK_TAGS.has(tag) or isHeading
|
|
318
|
+
|
|
319
|
+
lines.push('\n\n') if isHeading
|
|
320
|
+
lines.push('\n') if isBlock and not isHeading
|
|
321
|
+
|
|
322
|
+
_walkText(c, lines) for c in node.childNodes
|
|
323
|
+
|
|
324
|
+
lines.push('\n\n') if isHeading
|
|
325
|
+
lines.push('\n') if isBlock and not isHeading
|
|
326
|
+
|
|
327
|
+
_textContent = (node) ->
|
|
328
|
+
return node.data if node instanceof Text
|
|
329
|
+
return '' if node instanceof Comment
|
|
330
|
+
(node.childNodes.map (c) -> _textContent(c)).join('')
|
package/email/email.rip
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# ==============================================================================
|
|
2
|
+
# @rip-lang/ui/email — curated email components for Rip
|
|
3
|
+
#
|
|
4
|
+
# Usage:
|
|
5
|
+
# import { toHTML, Email, Head, Body, Container, Heading, Text, Button } from '@rip-lang/ui/email'
|
|
6
|
+
# html = toHTML(MyEmail, name: "Alice")
|
|
7
|
+
# ==============================================================================
|
|
8
|
+
|
|
9
|
+
export { toHTML, toText, renderEmail } from './render.rip'
|
|
10
|
+
export { Email, Head, Body, Preview, Font, Container, Section, Row, Column, Heading, Text, Link, Image, Divider, Button, Markdown, CodeBlock, CodeInline, Tailwind } from './components.rip'
|
package/email/render.rip
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# ==============================================================================
|
|
2
|
+
# Render — instantiate a Rip component and produce email HTML + plain text
|
|
3
|
+
#
|
|
4
|
+
# Usage:
|
|
5
|
+
# import { toHTML, toText, renderEmail } from '@rip-lang/ui/email'
|
|
6
|
+
#
|
|
7
|
+
# html = toHTML(MyEmail, name: "Alice")
|
|
8
|
+
# text = toText(MyEmail, name: "Alice")
|
|
9
|
+
# { html, text } = renderEmail(MyEmail, name: "Alice")
|
|
10
|
+
#
|
|
11
|
+
# Notes:
|
|
12
|
+
# - rendering is synchronous and swaps in a temporary global DOM shim
|
|
13
|
+
# - do not add awaits or async work inside this render path
|
|
14
|
+
# - custom Tailwind configs must be prepared ahead of time via
|
|
15
|
+
# `@rip-lang/ui/tailwind.prepareConfig(config)` before sync rendering
|
|
16
|
+
# ==============================================================================
|
|
17
|
+
|
|
18
|
+
import { Node, Document, serializeChildren, toPlainText } from './dom.rip'
|
|
19
|
+
import { inlineEmailTree, takeEmailTailwindRoots, configKey } from '../tailwind/tailwind.rip'
|
|
20
|
+
|
|
21
|
+
XHTML_DOCTYPE =! '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">'
|
|
22
|
+
|
|
23
|
+
_resolveTailwindConfig = (entries = []) ->
|
|
24
|
+
configs = []
|
|
25
|
+
for entry in entries
|
|
26
|
+
continue unless entry?.config?
|
|
27
|
+
configs.push(entry.config)
|
|
28
|
+
|
|
29
|
+
return {} unless configs.length
|
|
30
|
+
|
|
31
|
+
first = configs[0]
|
|
32
|
+
firstKey = configKey(first)
|
|
33
|
+
|
|
34
|
+
for config, idx in configs
|
|
35
|
+
continue unless idx
|
|
36
|
+
continue if configKey(config) is firstKey
|
|
37
|
+
raise 'Tailwind: multiple different Tailwind configs were registered during one email render. Use a single shared config per render and call prepareConfig(config) before synchronous rendering.'
|
|
38
|
+
|
|
39
|
+
first
|
|
40
|
+
|
|
41
|
+
_renderComponent = (ComponentClass, props = {}) ->
|
|
42
|
+
prevDoc = globalThis.document
|
|
43
|
+
prevNode = globalThis.Node
|
|
44
|
+
prevSVG = globalThis.SVGElement
|
|
45
|
+
|
|
46
|
+
doc = Document.new()
|
|
47
|
+
globalThis.document = doc
|
|
48
|
+
globalThis.Node = Node
|
|
49
|
+
globalThis.SVGElement = class SVGStub extends Node
|
|
50
|
+
|
|
51
|
+
try
|
|
52
|
+
root = doc.createElement('__root')
|
|
53
|
+
instance = ComponentClass.new(props)
|
|
54
|
+
el = instance._create()
|
|
55
|
+
root.appendChild(el)
|
|
56
|
+
instance._setup() if instance._setup
|
|
57
|
+
instance.mounted() if instance.mounted
|
|
58
|
+
|
|
59
|
+
entries = takeEmailTailwindRoots()
|
|
60
|
+
if entries.length
|
|
61
|
+
result = inlineEmailTree(root, _resolveTailwindConfig(entries))
|
|
62
|
+
warn "Tailwind: unsupported classes ignored: #{result.unsupported.join(' ')}" if result.unsupported?.length
|
|
63
|
+
|
|
64
|
+
root
|
|
65
|
+
finally
|
|
66
|
+
globalThis.document = prevDoc
|
|
67
|
+
globalThis.Node = prevNode
|
|
68
|
+
globalThis.SVGElement = prevSVG
|
|
69
|
+
|
|
70
|
+
export renderEmail = (ComponentClass, props = {}) ->
|
|
71
|
+
root = _renderComponent(ComponentClass, props)
|
|
72
|
+
html = serializeChildren(root)
|
|
73
|
+
text = toPlainText(root)
|
|
74
|
+
{ html: "#{XHTML_DOCTYPE}#{html}", text }
|
|
75
|
+
|
|
76
|
+
export toHTML = (ComponentClass, props = {}) ->
|
|
77
|
+
root = _renderComponent(ComponentClass, props)
|
|
78
|
+
"#{XHTML_DOCTYPE}#{serializeChildren(root)}"
|
|
79
|
+
|
|
80
|
+
export toText = (ComponentClass, props = {}) ->
|
|
81
|
+
root = _renderComponent(ComponentClass, props)
|
|
82
|
+
toPlainText(root)
|
package/package.json
CHANGED
|
@@ -1,51 +1,41 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rip-lang/ui",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
7
|
-
"
|
|
8
|
-
"
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
"
|
|
3
|
+
"version": "0.4.2",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Unified UI system for Rip — browser widgets, email components, and Tailwind integration",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./ui.rip",
|
|
8
|
+
"./browser": "./browser/browser.rip",
|
|
9
|
+
"./browser/*": "./browser/components/*.rip",
|
|
10
|
+
"./email": "./email/email.rip",
|
|
11
|
+
"./email/*": "./email/*.rip",
|
|
12
|
+
"./shared": "./shared/styles.rip",
|
|
13
|
+
"./tailwind": "./tailwind/tailwind.rip"
|
|
12
14
|
},
|
|
13
|
-
"description": "Headless, accessible UI components written in Rip — zero CSS, zero dependencies",
|
|
14
15
|
"files": [
|
|
15
|
-
"
|
|
16
|
-
"
|
|
16
|
+
"browser/",
|
|
17
|
+
"email/",
|
|
18
|
+
"shared/",
|
|
19
|
+
"tailwind/",
|
|
20
|
+
"README.md",
|
|
21
|
+
"AGENTS.md",
|
|
22
|
+
"ui.rip"
|
|
17
23
|
],
|
|
18
|
-
"homepage": "https://github.com/shreeve/rip-lang/tree/main/packages/ui#readme",
|
|
19
|
-
"keywords": [
|
|
20
|
-
"ui",
|
|
21
|
-
"headless",
|
|
22
|
-
"accessible",
|
|
23
|
-
"components",
|
|
24
|
-
"widgets",
|
|
25
|
-
"aria",
|
|
26
|
-
"wai-aria",
|
|
27
|
-
"select",
|
|
28
|
-
"dialog",
|
|
29
|
-
"grid",
|
|
30
|
-
"combobox",
|
|
31
|
-
"tabs",
|
|
32
|
-
"rip",
|
|
33
|
-
"rip-lang"
|
|
34
|
-
],
|
|
35
|
-
"license": "MIT",
|
|
36
|
-
"type": "module",
|
|
37
|
-
"scripts": {
|
|
38
|
-
"test:e2e": "bunx playwright test -c playwright.config.mjs",
|
|
39
|
-
"test:e2e:chromium": "bunx playwright test -c playwright.config.mjs --project=chromium",
|
|
40
|
-
"test:e2e:headed": "bunx playwright test -c playwright.config.mjs --headed --project=chromium",
|
|
41
|
-
"test:e2e:axe": "UI_AXE=1 bunx playwright test -c playwright.config.mjs --project=chromium"
|
|
42
|
-
},
|
|
43
24
|
"dependencies": {
|
|
44
|
-
"rip-lang": ">=3.13.
|
|
25
|
+
"rip-lang": ">=3.13.122",
|
|
26
|
+
"tailwindcss": "^4",
|
|
27
|
+
"css-tree": "^3"
|
|
45
28
|
},
|
|
46
29
|
"devDependencies": {
|
|
47
30
|
"@axe-core/playwright": "4.11.1",
|
|
48
|
-
"@rip-lang/server": "1.3.
|
|
31
|
+
"@rip-lang/server": "1.3.116",
|
|
49
32
|
"playwright": "1.58.2"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"bench:email": "../../bin/rip email/benchmarks/benchmark.rip",
|
|
36
|
+
"test:e2e": "bunx playwright test -c browser/playwright.config.mjs",
|
|
37
|
+
"test:e2e:chromium": "bunx playwright test -c browser/playwright.config.mjs --project=chromium",
|
|
38
|
+
"test:e2e:headed": "bunx playwright test -c browser/playwright.config.mjs --headed --project=chromium",
|
|
39
|
+
"test:e2e:axe": "UI_AXE=1 bunx playwright test -c browser/playwright.config.mjs --project=chromium"
|
|
50
40
|
}
|
|
51
41
|
}
|
package/shared/README.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Shared style helpers for @rip-lang/ui
|
|
2
|
+
|
|
3
|
+
export joinStyles = (...styles) ->
|
|
4
|
+
(s for s in styles when s).join(';')
|
|
5
|
+
|
|
6
|
+
export withMargin = (opts = {}) ->
|
|
7
|
+
parts = []
|
|
8
|
+
parts.push("margin:#{opts.m}") if opts.m?
|
|
9
|
+
parts.push("margin-left:#{opts.mx}") if opts.mx?
|
|
10
|
+
parts.push("margin-right:#{opts.mx}") if opts.mx?
|
|
11
|
+
parts.push("margin-top:#{opts.my}") if opts.my?
|
|
12
|
+
parts.push("margin-bottom:#{opts.my}") if opts.my?
|
|
13
|
+
parts.push("margin-top:#{opts.mt}") if opts.mt?
|
|
14
|
+
parts.push("margin-right:#{opts.mr}") if opts.mr?
|
|
15
|
+
parts.push("margin-bottom:#{opts.mb}") if opts.mb?
|
|
16
|
+
parts.push("margin-left:#{opts.ml}") if opts.ml?
|
|
17
|
+
parts.join(';')
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# @rip-lang/ui/tailwind
|
|
2
|
+
|
|
3
|
+
Real Tailwind integration for the unified UI package.
|
|
4
|
+
|
|
5
|
+
Exports:
|
|
6
|
+
- `compile(classes, config?)`
|
|
7
|
+
- `prepareConfig(config?)`
|
|
8
|
+
- `inlineEmailTree(rootNode, config?)`
|
|
9
|
+
- `generateBrowserCss(classes, config?)`
|
|
10
|
+
|
|
11
|
+
This is the only domain that talks directly to `tailwindcss` and `css-tree`.
|
|
12
|
+
|
|
13
|
+
## Prepared configs
|
|
14
|
+
|
|
15
|
+
The default config works out of the box. Custom configs need a one-time async preparation step before they can be used from the synchronous unified-ui render path:
|
|
16
|
+
|
|
17
|
+
```coffee
|
|
18
|
+
import { prepareConfig } from '@rip-lang/ui/tailwind'
|
|
19
|
+
|
|
20
|
+
await prepareConfig
|
|
21
|
+
theme:
|
|
22
|
+
extend:
|
|
23
|
+
colors:
|
|
24
|
+
brand: '#0f172a'
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
After preparation, pass the same config to `compile()`, `generateBrowserCss()`, or the email `Tailwind` component.
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { compile as twCompile } from 'tailwindcss'
|
|
2
|
+
import { parse } from 'css-tree'
|
|
3
|
+
import { readFileSync } from 'node:fs'
|
|
4
|
+
import { dirname, join } from 'node:path'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
|
|
7
|
+
const tailwindDir = dirname(fileURLToPath(import.meta.resolve('tailwindcss/package.json')))
|
|
8
|
+
const tailwindCss = {
|
|
9
|
+
'tailwindcss': readFileSync(join(tailwindDir, 'index.css'), 'utf8'),
|
|
10
|
+
'tailwindcss/preflight.css': readFileSync(join(tailwindDir, 'preflight.css'), 'utf8'),
|
|
11
|
+
'tailwindcss/theme.css': readFileSync(join(tailwindDir, 'theme.css'), 'utf8'),
|
|
12
|
+
'tailwindcss/utilities.css': readFileSync(join(tailwindDir, 'utilities.css'), 'utf8'),
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function baseCssForConfig(config = {}) {
|
|
16
|
+
const baseCss = `
|
|
17
|
+
@layer theme, base, components, utilities;
|
|
18
|
+
@import "tailwindcss/preflight.css" layer(base);
|
|
19
|
+
@import "tailwindcss/theme.css" layer(theme);
|
|
20
|
+
@import "tailwindcss/utilities.css" layer(utilities);
|
|
21
|
+
@config;
|
|
22
|
+
`
|
|
23
|
+
return { baseCss, config }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function createCompiler(config = {}) {
|
|
27
|
+
const { baseCss } = baseCssForConfig(config)
|
|
28
|
+
return twCompile(baseCss, {
|
|
29
|
+
async loadModule(id, base, resourceHint) {
|
|
30
|
+
if (resourceHint === 'config') return { path: id, base, module: config }
|
|
31
|
+
throw new Error(`Unsupported Tailwind resource hint: ${resourceHint}`)
|
|
32
|
+
},
|
|
33
|
+
polyfills: 0,
|
|
34
|
+
async loadStylesheet(id, base) {
|
|
35
|
+
const content = tailwindCss[id]
|
|
36
|
+
if (!content) throw new Error(`Unsupported Tailwind stylesheet: ${id}`)
|
|
37
|
+
return { base, path: id, content }
|
|
38
|
+
},
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function stableSerialize(value, seen = new WeakSet()) {
|
|
43
|
+
if (value === null) return 'null'
|
|
44
|
+
|
|
45
|
+
const kind = typeof value
|
|
46
|
+
if (kind === 'string') return JSON.stringify(value)
|
|
47
|
+
if (kind === 'number' || kind === 'boolean' || kind === 'bigint') return String(value)
|
|
48
|
+
if (kind === 'undefined') return 'undefined'
|
|
49
|
+
if (kind === 'symbol') return value.toString()
|
|
50
|
+
if (kind === 'function') return `[Function:${value.toString()}]`
|
|
51
|
+
|
|
52
|
+
if (Array.isArray(value)) {
|
|
53
|
+
return `[${value.map((entry) => stableSerialize(entry, seen)).join(',')}]`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (kind === 'object') {
|
|
57
|
+
if (seen.has(value)) return '[Circular]'
|
|
58
|
+
seen.add(value)
|
|
59
|
+
|
|
60
|
+
const entries = Object.keys(value)
|
|
61
|
+
.sort()
|
|
62
|
+
.map((key) => `${JSON.stringify(key)}:${stableSerialize(value[key], seen)}`)
|
|
63
|
+
|
|
64
|
+
seen.delete(value)
|
|
65
|
+
return `{${entries.join(',')}}`
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return String(value)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function configCacheKey(config = {}) {
|
|
72
|
+
return stableSerialize(config)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const compilerCache = new Map()
|
|
76
|
+
const compilerPromises = new Map()
|
|
77
|
+
const defaultCompiler = await createCompiler({})
|
|
78
|
+
compilerCache.set(configCacheKey({}), defaultCompiler)
|
|
79
|
+
|
|
80
|
+
export async function prepareConfig(config = {}) {
|
|
81
|
+
const key = configCacheKey(config)
|
|
82
|
+
if (compilerCache.has(key)) return compilerCache.get(key)
|
|
83
|
+
if (compilerPromises.has(key)) return compilerPromises.get(key)
|
|
84
|
+
|
|
85
|
+
const promise = createCompiler(config).then((compiler) => {
|
|
86
|
+
compilerCache.set(key, compiler)
|
|
87
|
+
compilerPromises.delete(key)
|
|
88
|
+
return compiler
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
compilerPromises.set(key, promise)
|
|
92
|
+
return promise
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function compile(classes = [], config = {}) {
|
|
96
|
+
const key = configCacheKey(config)
|
|
97
|
+
const compiler = compilerCache.get(key)
|
|
98
|
+
|
|
99
|
+
if (!compiler) {
|
|
100
|
+
throw new Error(
|
|
101
|
+
'Tailwind config is not prepared for synchronous compile(). Call await prepareConfig(config) before rendering themed email or browser CSS in the synchronous unified-ui path.',
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const css = compiler.build(classes)
|
|
106
|
+
return { css, styleSheet: parse(css), rulesPerClass: new Map() }
|
|
107
|
+
}
|