@rip-lang/ui 0.3.66 → 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
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, '&lt;')
194
+ .replace(/>/g, '&gt;')
195
+ .replace(/"/g, '&quot;')
196
+
197
+ escapeAttr = (str) ->
198
+ String(str)
199
+ .replace(/&/g, '&amp;')
200
+ .replace(/"/g, '&quot;')
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('')
@@ -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'
@@ -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.3.66",
4
- "author": "Steve Shreeve <steve.shreeve@gmail.com>",
5
- "repository": {
6
- "type": "git",
7
- "url": "git+https://github.com/shreeve/rip-lang.git",
8
- "directory": "packages/ui"
9
- },
10
- "bugs": {
11
- "url": "https://github.com/shreeve/rip-lang/issues"
3
+ "version": "0.4.1",
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
- "components/",
16
- "README.md"
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.119"
25
+ "rip-lang": ">=3.13.121",
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.108",
31
+ "@rip-lang/server": "1.3.115",
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
  }
@@ -0,0 +1,3 @@
1
+ # @rip-lang/ui/shared
2
+
3
+ Cross-domain style and utility helpers shared by browser and email domains.
@@ -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,3 @@
1
+ # Tailwind Domain Guide
2
+
3
+ All Tailwind-specific logic lives here. Browser and email domains consume this API but must not import `tailwindcss` or `css-tree` directly.
@@ -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
+ }