@rip-lang/ui 0.3.2 → 0.3.3
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/README.md +105 -2
- package/package.json +1 -1
- package/serve.rip +3 -65
- package/ui.rip +6 -22
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@ import { get, use, start, notFound } from '@rip-lang/api'
|
|
|
16
16
|
import { ripUI } from '@rip-lang/ui/serve'
|
|
17
17
|
|
|
18
18
|
dir = import.meta.dir
|
|
19
|
-
use ripUI dir: dir, components: '
|
|
19
|
+
use ripUI dir: dir, components: 'routes', includes: ['ui'], watch: true, title: 'My App'
|
|
20
20
|
get '/css/*', -> @send "#{dir}/css/#{@req.path.slice(5)}"
|
|
21
21
|
notFound -> @send "#{dir}/index.html", 'text/html; charset=UTF-8'
|
|
22
22
|
start port: 3000
|
|
@@ -75,6 +75,85 @@ export About = component
|
|
|
75
75
|
Reactive props via `:=` signal passthrough. Readonly props via `=!`.
|
|
76
76
|
Children blocks passed as DOM nodes via `@children`.
|
|
77
77
|
|
|
78
|
+
## Render Block Syntax
|
|
79
|
+
|
|
80
|
+
Inside a `render` block, elements are declared by tag name. Classes, attributes,
|
|
81
|
+
and children can be expressed inline or across multiple indented lines.
|
|
82
|
+
|
|
83
|
+
### Classes with `.(...)`
|
|
84
|
+
|
|
85
|
+
The `.()` helper applies classes using CLSX semantics — strings are included
|
|
86
|
+
directly, and object keys are conditionally included based on their values:
|
|
87
|
+
|
|
88
|
+
```coffee
|
|
89
|
+
button.('px-4 py-2 rounded-full') "Click"
|
|
90
|
+
button.('px-4 py-2', active: isActive) "Click"
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Arguments can span multiple lines, just like a normal function call:
|
|
94
|
+
|
|
95
|
+
```coffee
|
|
96
|
+
input.(
|
|
97
|
+
'block w-full rounded-lg border border-primary',
|
|
98
|
+
'text-sm-plus text-tertiary shadow-xs'
|
|
99
|
+
)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### Indented Attributes
|
|
103
|
+
|
|
104
|
+
Attributes can be placed on separate indented lines after the element:
|
|
105
|
+
|
|
106
|
+
```coffee
|
|
107
|
+
input.('rounded-lg border px-3.5 py-2.5')
|
|
108
|
+
type: "email"
|
|
109
|
+
value: user.email
|
|
110
|
+
disabled: true
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
This is equivalent to the inline form:
|
|
114
|
+
|
|
115
|
+
```coffee
|
|
116
|
+
input.('rounded-lg border px-3.5 py-2.5') type: "email", value: user.email, disabled: true
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### The `class:` Attribute
|
|
120
|
+
|
|
121
|
+
The `class:` attribute works like `.()` and merges cumulatively with any
|
|
122
|
+
existing `.()` classes on the same element:
|
|
123
|
+
|
|
124
|
+
```coffee
|
|
125
|
+
input.('block w-full rounded-lg')
|
|
126
|
+
class: 'text-sm text-tertiary'
|
|
127
|
+
type: "email"
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
This produces a single combined class expression: `block w-full rounded-lg text-sm text-tertiary`.
|
|
131
|
+
|
|
132
|
+
The `class:` value also supports `.()` syntax for conditional classes:
|
|
133
|
+
|
|
134
|
+
```coffee
|
|
135
|
+
div.('mt-4 p-4')
|
|
136
|
+
class: .('ring-1', highlighted: isHighlighted)
|
|
137
|
+
span "Content"
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### Attributes and Children Together
|
|
141
|
+
|
|
142
|
+
Attributes and children can coexist at the same indentation level. Attributes
|
|
143
|
+
(key-value pairs) are listed first, followed by child elements:
|
|
144
|
+
|
|
145
|
+
```coffee
|
|
146
|
+
button.('flex items-center rounded-lg')
|
|
147
|
+
type: "submit"
|
|
148
|
+
disabled: saving
|
|
149
|
+
|
|
150
|
+
span.('font-bold') "Submit"
|
|
151
|
+
span.('text-sm text-secondary') "or press Enter"
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Blank lines between attributes and children are fine — they don't break the
|
|
155
|
+
structure.
|
|
156
|
+
|
|
78
157
|
## How It Works
|
|
79
158
|
|
|
80
159
|
The browser loads one file — `rip-ui.min.js` (~52KB Brotli) — which bundles the
|
|
@@ -155,7 +234,7 @@ The `ripUI` middleware registers routes for the framework files, the app
|
|
|
155
234
|
bundle, and optional SSE hot-reload:
|
|
156
235
|
|
|
157
236
|
```coffee
|
|
158
|
-
use ripUI dir: dir, components: '
|
|
237
|
+
use ripUI dir: dir, components: 'routes', includes: ['ui'], watch: true, title: 'My App'
|
|
159
238
|
```
|
|
160
239
|
|
|
161
240
|
| Option | Default | Description |
|
|
@@ -311,6 +390,30 @@ See `docs/demo.html` for a complete example — the full Rip UI Demo app
|
|
|
311
390
|
(6 components, router, reactive state, persistence) in 337 lines of
|
|
312
391
|
static HTML.
|
|
313
392
|
|
|
393
|
+
## Tailwind CSS Autocompletion
|
|
394
|
+
|
|
395
|
+
To get Tailwind class autocompletion inside `.()` CLSX helpers in render
|
|
396
|
+
templates, install the
|
|
397
|
+
[Tailwind CSS IntelliSense](https://marketplace.visualstudio.com/items?itemName=bradlc.vscode-tailwindcss)
|
|
398
|
+
extension and add these to your VS Code / Cursor settings:
|
|
399
|
+
|
|
400
|
+
```json
|
|
401
|
+
{
|
|
402
|
+
"tailwindCSS.includeLanguages": { "rip": "html" },
|
|
403
|
+
"tailwindCSS.experimental.classRegex": [
|
|
404
|
+
["\\.\\(([\\s\\S]*?)\\)", "'([^']*)'"]
|
|
405
|
+
]
|
|
406
|
+
}
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
This gives you autocompletion, hover previews, and linting for Tailwind
|
|
410
|
+
classes in expressions like:
|
|
411
|
+
|
|
412
|
+
```coffee
|
|
413
|
+
h1.('text-3xl font-semibold') "Hello"
|
|
414
|
+
button.('flex items-center px-4 py-2 rounded-full') "Click"
|
|
415
|
+
```
|
|
416
|
+
|
|
314
417
|
## License
|
|
315
418
|
|
|
316
419
|
MIT
|
package/package.json
CHANGED
package/serve.rip
CHANGED
|
@@ -2,20 +2,18 @@
|
|
|
2
2
|
# @rip-lang/ui/serve — Rip UI Server Middleware
|
|
3
3
|
# ==============================================================================
|
|
4
4
|
#
|
|
5
|
-
# Serves the Rip UI runtime
|
|
6
|
-
# SSE hot-reload.
|
|
5
|
+
# Serves the Rip UI runtime and auto-generated app bundles.
|
|
7
6
|
#
|
|
8
7
|
# Usage:
|
|
9
8
|
# import { ripUI } from '@rip-lang/ui/serve'
|
|
10
|
-
# use ripUI dir: dir, components: '
|
|
9
|
+
# use ripUI dir: dir, components: 'routes', includes: ['ui'], watch: true, title: 'My App'
|
|
11
10
|
#
|
|
12
11
|
# Options:
|
|
13
12
|
# app: string — URL mount point (default: '')
|
|
14
13
|
# dir: string — app directory on disk (default: '.')
|
|
15
14
|
# components: string — directory for page components, relative to dir (default: 'components')
|
|
16
15
|
# includes: array — directories for shared components, relative to dir (default: [])
|
|
17
|
-
# watch: boolean — enable
|
|
18
|
-
# debounce: number — ms to batch filesystem events (default: 250)
|
|
16
|
+
# watch: boolean — enable bundle cache invalidation on file changes (default: false)
|
|
19
17
|
# state: object — initial app state passed via bundle
|
|
20
18
|
# title: string — document title
|
|
21
19
|
#
|
|
@@ -30,7 +28,6 @@ export ripUI = (opts = {}) ->
|
|
|
30
28
|
componentsDir = "#{appDir}/#{opts.components or 'components'}"
|
|
31
29
|
includeDirs = (opts.includes or []).map (d) -> "#{appDir}/#{d}"
|
|
32
30
|
enableWatch = opts.watch or false
|
|
33
|
-
debounceMs = opts.debounce or 250
|
|
34
31
|
appState = opts.state or null
|
|
35
32
|
appTitle = opts.title or null
|
|
36
33
|
uiDir = import.meta.dir
|
|
@@ -53,7 +50,6 @@ export ripUI = (opts = {}) ->
|
|
|
53
50
|
# ----------------------------------------------------------------------------
|
|
54
51
|
# Route: {prefix}/components/* — individual .rip component files (for hot-reload)
|
|
55
52
|
# Route: {prefix}/bundle — app bundle (components + data as JSON)
|
|
56
|
-
# Route: {prefix}/watch — SSE hot-reload stream
|
|
57
53
|
# ----------------------------------------------------------------------------
|
|
58
54
|
|
|
59
55
|
get "#{prefix}/components/*", (c) ->
|
|
@@ -97,63 +93,5 @@ export ripUI = (opts = {}) ->
|
|
|
97
93
|
|
|
98
94
|
new Response bundleCache, headers: { 'Content-Type': 'application/json' }
|
|
99
95
|
|
|
100
|
-
if enableWatch
|
|
101
|
-
get "#{prefix}/watch", (c) ->
|
|
102
|
-
encoder = new TextEncoder()
|
|
103
|
-
pending = new Set()
|
|
104
|
-
timer = null
|
|
105
|
-
watcher = null
|
|
106
|
-
heartbeat = null
|
|
107
|
-
|
|
108
|
-
watchers = []
|
|
109
|
-
|
|
110
|
-
cleanup = ->
|
|
111
|
-
w.close() for w in watchers
|
|
112
|
-
clearTimeout(timer) if timer
|
|
113
|
-
clearInterval(heartbeat) if heartbeat
|
|
114
|
-
watchers = []
|
|
115
|
-
heartbeat = timer = null
|
|
116
|
-
|
|
117
|
-
new Response new ReadableStream(
|
|
118
|
-
start: (controller) ->
|
|
119
|
-
send = (event, data) ->
|
|
120
|
-
try
|
|
121
|
-
controller.enqueue encoder.encode("event: #{event}\ndata: #{JSON.stringify(data)}\n\n")
|
|
122
|
-
catch
|
|
123
|
-
cleanup()
|
|
124
|
-
|
|
125
|
-
send 'connected', { time: Date.now() }
|
|
126
|
-
|
|
127
|
-
heartbeat = setInterval ->
|
|
128
|
-
try
|
|
129
|
-
controller.enqueue encoder.encode(": heartbeat\n\n")
|
|
130
|
-
catch
|
|
131
|
-
cleanup()
|
|
132
|
-
, 5000
|
|
133
|
-
|
|
134
|
-
flush = ->
|
|
135
|
-
paths = Array.from(pending)
|
|
136
|
-
pending.clear()
|
|
137
|
-
timer = null
|
|
138
|
-
send('changed', { paths }) if paths.length > 0
|
|
139
|
-
|
|
140
|
-
addWatcher = (dir, keyPrefix) ->
|
|
141
|
-
watchers.push fsWatch dir, { recursive: true }, (event, filename) ->
|
|
142
|
-
return unless filename?.endsWith('.rip')
|
|
143
|
-
pending.add "#{keyPrefix}#{filename}"
|
|
144
|
-
clearTimeout(timer) if timer
|
|
145
|
-
timer = setTimeout(flush, debounceMs)
|
|
146
|
-
|
|
147
|
-
addWatcher componentsDir, 'components/'
|
|
148
|
-
for incDir in includeDirs
|
|
149
|
-
addWatcher incDir, 'components/_lib/'
|
|
150
|
-
|
|
151
|
-
cancel: -> cleanup()
|
|
152
|
-
),
|
|
153
|
-
headers:
|
|
154
|
-
'Content-Type': 'text/event-stream'
|
|
155
|
-
'Cache-Control': 'no-cache'
|
|
156
|
-
'Connection': 'keep-alive'
|
|
157
|
-
|
|
158
96
|
# Return pass-through middleware
|
|
159
97
|
(c, next) -> next!()
|
package/ui.rip
CHANGED
|
@@ -906,7 +906,7 @@ export launch = (appBase = '', opts = {}) ->
|
|
|
906
906
|
|
|
907
907
|
# Connect SSE watch if enabled
|
|
908
908
|
if bundle.data?.watch
|
|
909
|
-
connectWatch
|
|
909
|
+
connectWatch '/watch'
|
|
910
910
|
|
|
911
911
|
# Expose for console and dev tools
|
|
912
912
|
if typeof window isnt 'undefined'
|
|
@@ -925,7 +925,7 @@ export launch = (appBase = '', opts = {}) ->
|
|
|
925
925
|
# SSE Watch — hot-reload connection
|
|
926
926
|
# ==============================================================================
|
|
927
927
|
|
|
928
|
-
connectWatch = (
|
|
928
|
+
connectWatch = (url) ->
|
|
929
929
|
retryDelay = 1000
|
|
930
930
|
maxDelay = 30000
|
|
931
931
|
|
|
@@ -933,31 +933,15 @@ connectWatch = (components, router, renderer, url, base = '') ->
|
|
|
933
933
|
es = new EventSource(url)
|
|
934
934
|
|
|
935
935
|
es.addEventListener 'connected', ->
|
|
936
|
-
retryDelay = 1000
|
|
936
|
+
retryDelay = 1000
|
|
937
937
|
console.log '[Rip] Hot reload connected'
|
|
938
938
|
|
|
939
|
-
es.addEventListener '
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
router.rebuild()
|
|
943
|
-
|
|
944
|
-
current = router.current
|
|
945
|
-
toFetch = paths.filter (p) ->
|
|
946
|
-
p is current.route?.file or current.layouts?.includes(p)
|
|
947
|
-
|
|
948
|
-
if toFetch.length > 0
|
|
949
|
-
results = await Promise.allSettled(toFetch.map (path) ->
|
|
950
|
-
res = await fetch(base + '/' + path)
|
|
951
|
-
content = res.text!
|
|
952
|
-
components.write path, content
|
|
953
|
-
)
|
|
954
|
-
failed = results.filter (r) -> r.status is 'rejected'
|
|
955
|
-
console.error '[Rip] Hot reload fetch error:', r.reason for r in failed
|
|
956
|
-
renderer.remount()
|
|
939
|
+
es.addEventListener 'reload', ->
|
|
940
|
+
console.log '[Rip] Reloading...'
|
|
941
|
+
location.reload()
|
|
957
942
|
|
|
958
943
|
es.onerror = ->
|
|
959
944
|
es.close()
|
|
960
|
-
console.log "[Rip] Hot reload reconnecting in #{retryDelay / 1000}s..."
|
|
961
945
|
setTimeout connect, retryDelay
|
|
962
946
|
retryDelay = Math.min(retryDelay * 2, maxDelay)
|
|
963
947
|
|