@rip-lang/ui 0.3.2 → 0.3.4

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 (4) hide show
  1. package/README.md +105 -2
  2. package/package.json +1 -1
  3. package/serve.rip +33 -100
  4. 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: 'pages', includes: ['ui'], watch: true, title: 'My App'
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: 'pages', includes: ['ui'], watch: true, title: 'My App'
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rip-lang/ui",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "Zero-build reactive web framework — rip.js + ui.rip + launch(url)",
5
5
  "type": "module",
6
6
  "main": "ui.rip",
package/serve.rip CHANGED
@@ -2,27 +2,25 @@
2
2
  # @rip-lang/ui/serve — Rip UI Server Middleware
3
3
  # ==============================================================================
4
4
  #
5
- # Serves the Rip UI runtime, auto-generated app bundles, and optional
6
- # SSE hot-reload.
5
+ # Serves the Rip UI runtime, auto-generated app bundles, and component files.
6
+ # Hot-reload SSE is handled by rip-server; this middleware registers watch dirs.
7
7
  #
8
8
  # Usage:
9
9
  # import { ripUI } from '@rip-lang/ui/serve'
10
- # use ripUI dir: dir, components: 'pages', includes: ['ui'], watch: true, title: 'My App'
10
+ # use ripUI dir: dir, components: 'routes', includes: ['ui'], watch: true, title: 'My App'
11
11
  #
12
12
  # Options:
13
13
  # app: string — URL mount point (default: '')
14
14
  # dir: string — app directory on disk (default: '.')
15
15
  # components: string — directory for page components, relative to dir (default: 'components')
16
16
  # includes: array — directories for shared components, relative to dir (default: [])
17
- # watch: boolean — enable SSE hot-reload endpoint (default: false)
18
- # debounce: number — ms to batch filesystem events (default: 250)
17
+ # watch: boolean — enable hot-reload (registers watch dirs with rip-server)
19
18
  # state: object — initial app state passed via bundle
20
19
  # title: string — document title
21
20
  #
22
21
  # ==============================================================================
23
22
 
24
23
  import { get } from '@rip-lang/api'
25
- import { watch as fsWatch } from 'node:fs'
26
24
 
27
25
  export ripUI = (opts = {}) ->
28
26
  prefix = opts.app or ''
@@ -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
@@ -51,109 +48,45 @@ export ripUI = (opts = {}) ->
51
48
  ripUI._registered = true
52
49
 
53
50
  # ----------------------------------------------------------------------------
54
- # Route: {prefix}/components/* — individual .rip component files (for hot-reload)
51
+ # Route: {prefix}/components/* — individual .rip component files
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) ->
60
56
  name = c.req.path.slice("#{prefix}/components/".length)
61
57
  c.send "#{componentsDir}/#{name}", 'text/plain; charset=UTF-8'
62
58
 
63
- bundleCache = null
64
- bundleDirty = true
65
-
66
- # Invalidate bundle cache when components change
67
- if enableWatch
68
- fsWatch componentsDir, { recursive: true }, (event, filename) ->
69
- bundleDirty = true if filename?.endsWith('.rip')
70
- for incDir in includeDirs
71
- fsWatch incDir, { recursive: true }, (event, filename) ->
72
- bundleDirty = true if filename?.endsWith('.rip')
73
-
74
59
  get "#{prefix}/bundle", (c) ->
75
- if bundleDirty or not bundleCache
76
- glob = new Bun.Glob("**/*.rip")
77
- components = {}
78
- paths = Array.from(glob.scanSync(componentsDir))
79
- for path in paths
80
- components["components/#{path}"] = Bun.file("#{componentsDir}/#{path}").text!
81
-
82
- # Merge external include directories into components/_lib/
83
- for dir in includeDirs
84
- incPaths = Array.from(glob.scanSync(dir))
85
- for path in incPaths
86
- key = "components/_lib/#{path}"
87
- components[key] = Bun.file("#{dir}/#{path}").text! unless components[key]
88
-
89
- data = {}
90
- data.title = appTitle if appTitle
91
- data.watch = enableWatch
92
- if appState
93
- data[k] = v for k, v of appState
94
-
95
- bundleCache = JSON.stringify({ components, data })
96
- bundleDirty = false
97
-
98
- new Response bundleCache, headers: { 'Content-Type': 'application/json' }
99
-
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 = []
60
+ glob = new Bun.Glob("**/*.rip")
61
+ components = {}
62
+ paths = Array.from(glob.scanSync(componentsDir))
63
+ for path in paths
64
+ components["components/#{path}"] = Bun.file("#{componentsDir}/#{path}").text!
65
+
66
+ for dir in includeDirs
67
+ incPaths = Array.from(glob.scanSync(dir))
68
+ for path in incPaths
69
+ key = "components/_lib/#{path}"
70
+ components[key] = Bun.file("#{dir}/#{path}").text! unless components[key]
71
+
72
+ data = {}
73
+ data.title = appTitle if appTitle
74
+ data.watch = enableWatch
75
+ if appState
76
+ data[k] = v for k, v of appState
77
+
78
+ new Response JSON.stringify({ components, data }), headers: { 'Content-Type': 'application/json' }
109
79
 
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/'
80
+ # ----------------------------------------------------------------------------
81
+ # Register watch directories with rip-server via control socket
82
+ # ----------------------------------------------------------------------------
150
83
 
151
- cancel: -> cleanup()
152
- ),
153
- headers:
154
- 'Content-Type': 'text/event-stream'
155
- 'Cache-Control': 'no-cache'
156
- 'Connection': 'keep-alive'
84
+ if enableWatch and process.env.SOCKET_PREFIX
85
+ ctl = "/tmp/#{process.env.SOCKET_PREFIX}.ctl.sock"
86
+ dirs = [componentsDir, ...includeDirs]
87
+ body = JSON.stringify({ op: 'watch', prefix, dirs })
88
+ fetch('http://localhost/watch', { method: 'POST', body, headers: { 'content-type': 'application/json' }, unix: ctl }).catch (e) ->
89
+ console.warn "rip-ui: watch registration failed: #{e.message}"
157
90
 
158
91
  # Return pass-through middleware
159
92
  (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 appComponents, router, renderer, "#{appBase}/watch", appBase
909
+ connectWatch "#{appBase}/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 = (components, router, renderer, url, base = '') ->
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 # reset backoff on successful connection
936
+ retryDelay = 1000
937
937
  console.log '[Rip] Hot reload connected'
938
938
 
939
- es.addEventListener 'changed', (e) ->
940
- { paths } = JSON.parse(e.data)
941
- components.del(path) for path in paths
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