@rip-lang/ui 0.3.0 → 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 +236 -25
- package/package.json +3 -3
- package/serve.rip +23 -66
- package/ui.rip +46 -33
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, 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
|
|
@@ -25,47 +25,180 @@ start port: 3000
|
|
|
25
25
|
**`index.html`** — the page:
|
|
26
26
|
|
|
27
27
|
```html
|
|
28
|
-
<script type="module" src="/rip/
|
|
28
|
+
<script type="module" src="/rip/rip-ui.min.js"></script>
|
|
29
29
|
<script type="text/rip">
|
|
30
|
-
{ launch } = importRip! '
|
|
30
|
+
{ launch } = importRip! 'ui.rip'
|
|
31
31
|
launch()
|
|
32
32
|
</script>
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
-
**`
|
|
35
|
+
**`pages/index.rip`** — a page component:
|
|
36
36
|
|
|
37
37
|
```coffee
|
|
38
38
|
export Home = component
|
|
39
39
|
@count := 0
|
|
40
40
|
render
|
|
41
|
-
|
|
41
|
+
.
|
|
42
42
|
h1 "Hello from Rip UI"
|
|
43
43
|
button @click: (-> @count += 1), "Clicked #{@count} times"
|
|
44
44
|
```
|
|
45
45
|
|
|
46
46
|
Run `bun index.rip`, open `http://localhost:3000`.
|
|
47
47
|
|
|
48
|
-
##
|
|
48
|
+
## Component Composition
|
|
49
|
+
|
|
50
|
+
Page components in `pages/` map to routes via file-based routing. Shared
|
|
51
|
+
components in `ui/` (or any `includes` directory) are available by PascalCase
|
|
52
|
+
name. No imports needed:
|
|
53
|
+
|
|
54
|
+
```coffee
|
|
55
|
+
# ui/card.rip
|
|
56
|
+
export Card = component
|
|
57
|
+
title =! ""
|
|
58
|
+
render
|
|
59
|
+
.card
|
|
60
|
+
if title
|
|
61
|
+
h3 "#{title}"
|
|
62
|
+
@children
|
|
63
|
+
|
|
64
|
+
# pages/about.rip
|
|
65
|
+
export About = component
|
|
66
|
+
render
|
|
67
|
+
.
|
|
68
|
+
h1 "About"
|
|
69
|
+
Card title: "The Idea"
|
|
70
|
+
p "Components compose naturally."
|
|
71
|
+
Card title: "Architecture"
|
|
72
|
+
p "PascalCase resolution, signal passthrough, children blocks."
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Reactive props via `:=` signal passthrough. Readonly props via `=!`.
|
|
76
|
+
Children blocks passed as DOM nodes via `@children`.
|
|
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
|
|
49
103
|
|
|
50
|
-
|
|
104
|
+
Attributes can be placed on separate indented lines after the element:
|
|
51
105
|
|
|
52
|
-
|
|
53
|
-
|
|
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
|
+
|
|
157
|
+
## How It Works
|
|
158
|
+
|
|
159
|
+
The browser loads one file — `rip-ui.min.js` (~52KB Brotli) — which bundles the
|
|
160
|
+
Rip compiler and the pre-compiled UI framework. No runtime compilation of the
|
|
161
|
+
framework, no extra network requests.
|
|
54
162
|
|
|
55
163
|
Then `launch()` fetches the app bundle, hydrates the stash, and renders.
|
|
56
164
|
|
|
165
|
+
### Browser Execution Contexts
|
|
166
|
+
|
|
167
|
+
Rip provides full async/await support across every browser context — no other
|
|
168
|
+
compile-to-JS language has this:
|
|
169
|
+
|
|
170
|
+
| Context | How async works | Returns value? |
|
|
171
|
+
|---------|-----------------|----------------|
|
|
172
|
+
| `<script type="text/rip">` | Async IIFE wrapper | No (fire-and-forget) |
|
|
173
|
+
| Playground "Run" button | Async IIFE wrapper | No (use console.log) |
|
|
174
|
+
| `rip()` console REPL | Rip `do ->` block | Yes (sync direct, async via Promise) |
|
|
175
|
+
| `.rip` files via `importRip()` | ES module import | Yes (module exports) |
|
|
176
|
+
|
|
177
|
+
The `!` postfix compiles to `await`. Inline scripts are wrapped in an async IIFE
|
|
178
|
+
automatically. The `rip()` console function wraps user code in a `do ->` block
|
|
179
|
+
so the Rip compiler handles implicit return and auto-async natively.
|
|
180
|
+
|
|
181
|
+
### globalThis Exports
|
|
182
|
+
|
|
183
|
+
When `rip-ui.min.js` loads, it registers these on `globalThis`:
|
|
184
|
+
|
|
185
|
+
| Function | Purpose |
|
|
186
|
+
|----------|---------|
|
|
187
|
+
| `rip(code)` | Console REPL — compile and execute Rip code |
|
|
188
|
+
| `importRip(url)` | Fetch, compile, and import a `.rip` file as an ES module |
|
|
189
|
+
| `compileToJS(code)` | Compile Rip source to JavaScript |
|
|
190
|
+
| `__rip` | Reactive runtime — `__state`, `__computed`, `__effect`, `__batch` |
|
|
191
|
+
| `__ripComponent` | Component runtime — `__Component`, `__clsx`, `__fragment` |
|
|
192
|
+
| `__ripExports` | All compiler exports — `compile`, `formatSExpr`, `VERSION`, etc. |
|
|
193
|
+
|
|
57
194
|
## The Stash
|
|
58
195
|
|
|
59
|
-
|
|
196
|
+
App state lives in one reactive tree:
|
|
60
197
|
|
|
61
198
|
```
|
|
62
199
|
app
|
|
63
|
-
├──
|
|
64
|
-
|
|
65
|
-
│ ├── counter.rip
|
|
66
|
-
│ └── _layout.rip
|
|
67
|
-
├── routes ← navigation state (path, params, query, hash)
|
|
68
|
-
└── data ← reactive app state (title, theme, user, etc.)
|
|
200
|
+
├── routes ← navigation state (path, params, query, hash)
|
|
201
|
+
└── data ← reactive app state (title, theme, user, etc.)
|
|
69
202
|
```
|
|
70
203
|
|
|
71
204
|
Writing to `app.data.theme` updates any component reading it. The stash
|
|
@@ -74,13 +207,14 @@ uses Rip's built-in reactive primitives — the same signals that power
|
|
|
74
207
|
|
|
75
208
|
## The App Bundle
|
|
76
209
|
|
|
77
|
-
The bundle is JSON served at `/{app}/bundle
|
|
210
|
+
The bundle is JSON served at `/{app}/bundle`:
|
|
78
211
|
|
|
79
212
|
```json
|
|
80
213
|
{
|
|
81
214
|
"components": {
|
|
82
215
|
"components/index.rip": "export Home = component...",
|
|
83
|
-
"components/counter.rip": "export Counter = component..."
|
|
216
|
+
"components/counter.rip": "export Counter = component...",
|
|
217
|
+
"components/_lib/card.rip": "export Card = component..."
|
|
84
218
|
},
|
|
85
219
|
"data": {
|
|
86
220
|
"title": "My App",
|
|
@@ -89,20 +223,26 @@ The bundle is JSON served at `/{app}/bundle`. It populates the stash:
|
|
|
89
223
|
}
|
|
90
224
|
```
|
|
91
225
|
|
|
226
|
+
On disk you organize your app into `pages/` and `ui/`. The middleware
|
|
227
|
+
maps them into a flat `components/` namespace in the bundle — pages go
|
|
228
|
+
under `components/`, shared components under `components/_lib/`. The `_`
|
|
229
|
+
prefix tells the router to skip `_lib/` entries when generating routes.
|
|
230
|
+
|
|
92
231
|
## Server Middleware
|
|
93
232
|
|
|
94
233
|
The `ripUI` middleware registers routes for the framework files, the app
|
|
95
234
|
bundle, and optional SSE hot-reload:
|
|
96
235
|
|
|
97
236
|
```coffee
|
|
98
|
-
use ripUI
|
|
237
|
+
use ripUI dir: dir, components: 'routes', includes: ['ui'], watch: true, title: 'My App'
|
|
99
238
|
```
|
|
100
239
|
|
|
101
240
|
| Option | Default | Description |
|
|
102
241
|
|--------|---------|-------------|
|
|
103
242
|
| `app` | `''` | URL mount point |
|
|
104
243
|
| `dir` | `'.'` | App directory on disk |
|
|
105
|
-
| `components` | `'components'` |
|
|
244
|
+
| `components` | `'components'` | Directory for page components (file-based routing) |
|
|
245
|
+
| `includes` | `[]` | Directories for shared components (no routes) |
|
|
106
246
|
| `watch` | `false` | Enable SSE hot-reload |
|
|
107
247
|
| `debounce` | `250` | Milliseconds to batch file change events |
|
|
108
248
|
| `state` | `null` | Initial app state |
|
|
@@ -111,11 +251,10 @@ use ripUI app: '/demo', dir: dir, title: 'My App'
|
|
|
111
251
|
Routes registered:
|
|
112
252
|
|
|
113
253
|
```
|
|
114
|
-
/rip/
|
|
115
|
-
/rip/ui.rip — UI framework
|
|
254
|
+
/rip/rip-ui.min.js — Rip compiler + pre-compiled UI framework
|
|
116
255
|
/{app}/bundle — app bundle (components + data as JSON)
|
|
117
256
|
/{app}/watch — SSE hot-reload stream (when watch: true)
|
|
118
|
-
/{app}/components/*
|
|
257
|
+
/{app}/components/* — individual component files (for hot-reload refetch)
|
|
119
258
|
```
|
|
120
259
|
|
|
121
260
|
## State Preservation (Keep-Alive)
|
|
@@ -184,7 +323,6 @@ get '/', -> Response.redirect('/demo/', 302)
|
|
|
184
323
|
start port: 3002
|
|
185
324
|
```
|
|
186
325
|
|
|
187
|
-
Each app is a directory with `components/`, `css/`, `index.html`, and `index.rip`.
|
|
188
326
|
The `/rip/` namespace is shared — all apps use the same compiler and framework.
|
|
189
327
|
|
|
190
328
|
## File Structure
|
|
@@ -193,16 +331,89 @@ The `/rip/` namespace is shared — all apps use the same compiler and framework
|
|
|
193
331
|
my-app/
|
|
194
332
|
├── index.rip # Server
|
|
195
333
|
├── index.html # HTML page
|
|
196
|
-
├── components
|
|
334
|
+
├── pages/ # Page components (file-based routing)
|
|
197
335
|
│ ├── _layout.rip # Root layout
|
|
198
336
|
│ ├── index.rip # Home → /
|
|
199
337
|
│ ├── about.rip # About → /about
|
|
200
338
|
│ └── users/
|
|
201
339
|
│ └── [id].rip # User profile → /users/:id
|
|
340
|
+
├── ui/ # Shared components (no routes)
|
|
341
|
+
│ └── card.rip # Card → available as Card
|
|
202
342
|
└── css/
|
|
203
343
|
└── styles.css # Styles
|
|
204
344
|
```
|
|
205
345
|
|
|
346
|
+
Files starting with `_` don't generate routes (`_layout.rip` is a layout,
|
|
347
|
+
not a page). Directories starting with `_` are also excluded, which is how
|
|
348
|
+
shared components from `includes` stay out of the router.
|
|
349
|
+
|
|
350
|
+
## Hash Routing
|
|
351
|
+
|
|
352
|
+
For static hosting (GitHub Pages, S3, etc.) where the server can't handle
|
|
353
|
+
SPA fallback routing, use hash-based URLs:
|
|
354
|
+
|
|
355
|
+
```coffee
|
|
356
|
+
launch '/app', hash: true
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
This switches from `/about` to `page.html#/about`. Back/forward navigation,
|
|
360
|
+
direct URL loading, and `href="#/path"` links all work correctly.
|
|
361
|
+
|
|
362
|
+
## Static Deployment — `launch bundle:`
|
|
363
|
+
|
|
364
|
+
Inline all components in a single HTML file for zero-server deployment.
|
|
365
|
+
Use `rip-ui.min.js` (~52KB Brotli) — a combined bundle with the compiler
|
|
366
|
+
and pre-compiled UI framework. No extra network requests, no runtime
|
|
367
|
+
compilation of the framework:
|
|
368
|
+
|
|
369
|
+
```html
|
|
370
|
+
<script type="module" src="dist/rip-ui.min.js"></script>
|
|
371
|
+
<script type="text/rip">
|
|
372
|
+
{ launch } = importRip! 'ui.rip'
|
|
373
|
+
|
|
374
|
+
launch bundle:
|
|
375
|
+
'/': '''
|
|
376
|
+
export Home = component
|
|
377
|
+
render
|
|
378
|
+
h1 "Hello"
|
|
379
|
+
'''
|
|
380
|
+
'/about': '''
|
|
381
|
+
export About = component
|
|
382
|
+
render
|
|
383
|
+
h1 "About"
|
|
384
|
+
'''
|
|
385
|
+
, hash: true
|
|
386
|
+
</script>
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
See `docs/demo.html` for a complete example — the full Rip UI Demo app
|
|
390
|
+
(6 components, router, reactive state, persistence) in 337 lines of
|
|
391
|
+
static HTML.
|
|
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
|
+
|
|
206
417
|
## License
|
|
207
418
|
|
|
208
419
|
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rip-lang/ui",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "Zero-build reactive web framework — rip.js + ui.rip + launch(url)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "ui.rip",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"author": "Steve Shreeve <steve.shreeve@gmail.com>",
|
|
33
33
|
"license": "MIT",
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"rip-lang": "^3.
|
|
35
|
+
"rip-lang": "^3.9.1"
|
|
36
36
|
},
|
|
37
37
|
"files": [
|
|
38
38
|
"ui.rip",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"README.md"
|
|
41
41
|
],
|
|
42
42
|
"peerDependencies": {
|
|
43
|
-
"@rip-lang/api": ">=1.1.
|
|
43
|
+
"@rip-lang/api": ">=1.1.6"
|
|
44
44
|
},
|
|
45
45
|
"peerDependenciesMeta": {
|
|
46
46
|
"@rip-lang/api": {
|
package/serve.rip
CHANGED
|
@@ -2,19 +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
|
|
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
|
-
# components: string — components
|
|
16
|
-
#
|
|
17
|
-
#
|
|
14
|
+
# components: string — directory for page components, relative to dir (default: 'components')
|
|
15
|
+
# includes: array — directories for shared components, relative to dir (default: [])
|
|
16
|
+
# watch: boolean — enable bundle cache invalidation on file changes (default: false)
|
|
18
17
|
# state: object — initial app state passed via bundle
|
|
19
18
|
# title: string — document title
|
|
20
19
|
#
|
|
@@ -26,33 +25,31 @@ import { watch as fsWatch } from 'node:fs'
|
|
|
26
25
|
export ripUI = (opts = {}) ->
|
|
27
26
|
prefix = opts.app or ''
|
|
28
27
|
appDir = opts.dir or '.'
|
|
29
|
-
componentsDir = opts.components or
|
|
28
|
+
componentsDir = "#{appDir}/#{opts.components or 'components'}"
|
|
29
|
+
includeDirs = (opts.includes or []).map (d) -> "#{appDir}/#{d}"
|
|
30
30
|
enableWatch = opts.watch or false
|
|
31
|
-
debounceMs = opts.debounce or 250
|
|
32
31
|
appState = opts.state or null
|
|
33
32
|
appTitle = opts.title or null
|
|
34
33
|
uiDir = import.meta.dir
|
|
35
34
|
|
|
36
|
-
# Resolve
|
|
37
|
-
|
|
35
|
+
# Resolve rip-ui.min.js (compiler + UI framework bundled)
|
|
36
|
+
bundlePath = null
|
|
38
37
|
try
|
|
39
|
-
|
|
38
|
+
bundlePath = Bun.fileURLToPath(import.meta.resolve('rip-lang/docs/dist/rip-ui.min.js'))
|
|
40
39
|
catch
|
|
41
|
-
|
|
40
|
+
bundlePath = "#{uiDir}/../../docs/dist/rip-ui.min.js"
|
|
42
41
|
|
|
43
42
|
# ----------------------------------------------------------------------------
|
|
44
|
-
# Route: /rip/* — framework files
|
|
43
|
+
# Route: /rip/* — framework files, registered once
|
|
45
44
|
# ----------------------------------------------------------------------------
|
|
46
45
|
|
|
47
46
|
unless ripUI._registered
|
|
48
|
-
get "/rip/
|
|
49
|
-
get "/rip/ui.rip", (c) -> c.send "#{uiDir}/ui.rip", 'text/plain; charset=UTF-8'
|
|
47
|
+
get "/rip/rip-ui.min.js", (c) -> c.send bundlePath, 'application/javascript'
|
|
50
48
|
ripUI._registered = true
|
|
51
49
|
|
|
52
50
|
# ----------------------------------------------------------------------------
|
|
53
51
|
# Route: {prefix}/components/* — individual .rip component files (for hot-reload)
|
|
54
52
|
# Route: {prefix}/bundle — app bundle (components + data as JSON)
|
|
55
|
-
# Route: {prefix}/watch — SSE hot-reload stream
|
|
56
53
|
# ----------------------------------------------------------------------------
|
|
57
54
|
|
|
58
55
|
get "#{prefix}/components/*", (c) ->
|
|
@@ -66,6 +63,9 @@ export ripUI = (opts = {}) ->
|
|
|
66
63
|
if enableWatch
|
|
67
64
|
fsWatch componentsDir, { recursive: true }, (event, filename) ->
|
|
68
65
|
bundleDirty = true if filename?.endsWith('.rip')
|
|
66
|
+
for incDir in includeDirs
|
|
67
|
+
fsWatch incDir, { recursive: true }, (event, filename) ->
|
|
68
|
+
bundleDirty = true if filename?.endsWith('.rip')
|
|
69
69
|
|
|
70
70
|
get "#{prefix}/bundle", (c) ->
|
|
71
71
|
if bundleDirty or not bundleCache
|
|
@@ -75,6 +75,13 @@ export ripUI = (opts = {}) ->
|
|
|
75
75
|
for path in paths
|
|
76
76
|
components["components/#{path}"] = Bun.file("#{componentsDir}/#{path}").text!
|
|
77
77
|
|
|
78
|
+
# Merge external include directories into components/_lib/
|
|
79
|
+
for dir in includeDirs
|
|
80
|
+
incPaths = Array.from(glob.scanSync(dir))
|
|
81
|
+
for path in incPaths
|
|
82
|
+
key = "components/_lib/#{path}"
|
|
83
|
+
components[key] = Bun.file("#{dir}/#{path}").text! unless components[key]
|
|
84
|
+
|
|
78
85
|
data = {}
|
|
79
86
|
data.title = appTitle if appTitle
|
|
80
87
|
data.watch = enableWatch
|
|
@@ -86,55 +93,5 @@ export ripUI = (opts = {}) ->
|
|
|
86
93
|
|
|
87
94
|
new Response bundleCache, headers: { 'Content-Type': 'application/json' }
|
|
88
95
|
|
|
89
|
-
if enableWatch
|
|
90
|
-
get "#{prefix}/watch", (c) ->
|
|
91
|
-
encoder = new TextEncoder()
|
|
92
|
-
pending = new Set()
|
|
93
|
-
timer = null
|
|
94
|
-
watcher = null
|
|
95
|
-
heartbeat = null
|
|
96
|
-
|
|
97
|
-
cleanup = ->
|
|
98
|
-
watcher?.close()
|
|
99
|
-
clearTimeout(timer) if timer
|
|
100
|
-
clearInterval(heartbeat) if heartbeat
|
|
101
|
-
watcher = heartbeat = timer = null
|
|
102
|
-
|
|
103
|
-
new Response new ReadableStream(
|
|
104
|
-
start: (controller) ->
|
|
105
|
-
send = (event, data) ->
|
|
106
|
-
try
|
|
107
|
-
controller.enqueue encoder.encode("event: #{event}\ndata: #{JSON.stringify(data)}\n\n")
|
|
108
|
-
catch
|
|
109
|
-
cleanup()
|
|
110
|
-
|
|
111
|
-
send 'connected', { time: Date.now() }
|
|
112
|
-
|
|
113
|
-
heartbeat = setInterval ->
|
|
114
|
-
try
|
|
115
|
-
controller.enqueue encoder.encode(": heartbeat\n\n")
|
|
116
|
-
catch
|
|
117
|
-
cleanup()
|
|
118
|
-
, 5000
|
|
119
|
-
|
|
120
|
-
flush = ->
|
|
121
|
-
paths = Array.from(pending)
|
|
122
|
-
pending.clear()
|
|
123
|
-
timer = null
|
|
124
|
-
send('changed', { paths }) if paths.length > 0
|
|
125
|
-
|
|
126
|
-
watcher = fsWatch componentsDir, { recursive: true }, (event, filename) ->
|
|
127
|
-
return unless filename?.endsWith('.rip')
|
|
128
|
-
pending.add "components/#{filename}"
|
|
129
|
-
clearTimeout(timer) if timer
|
|
130
|
-
timer = setTimeout(flush, debounceMs)
|
|
131
|
-
|
|
132
|
-
cancel: -> cleanup()
|
|
133
|
-
),
|
|
134
|
-
headers:
|
|
135
|
-
'Content-Type': 'text/event-stream'
|
|
136
|
-
'Cache-Control': 'no-cache'
|
|
137
|
-
'Connection': 'keep-alive'
|
|
138
|
-
|
|
139
96
|
# Return pass-through middleware
|
|
140
97
|
(c, next) -> next!()
|
package/ui.rip
CHANGED
|
@@ -360,6 +360,10 @@ buildRoutes = (components, root = 'components') ->
|
|
|
360
360
|
|
|
361
361
|
continue if name.startsWith('_')
|
|
362
362
|
|
|
363
|
+
# Skip files in _-prefixed directories (shared components, not pages)
|
|
364
|
+
segs = rel.split('/')
|
|
365
|
+
continue if segs.length > 1 and segs.some((s, i) -> i < segs.length - 1 and s.startsWith('_'))
|
|
366
|
+
|
|
363
367
|
urlPattern = fileToPattern(rel)
|
|
364
368
|
regex = patternToRegex(urlPattern)
|
|
365
369
|
routes.push { pattern: urlPattern, regex, file: filePath, rel }
|
|
@@ -392,6 +396,7 @@ getLayoutChain = (routeFile, root, layouts) ->
|
|
|
392
396
|
export createRouter = (components, opts = {}) ->
|
|
393
397
|
root = opts.root or 'components'
|
|
394
398
|
base = opts.base or ''
|
|
399
|
+
hashMode = opts.hash or false
|
|
395
400
|
onError = opts.onError or null
|
|
396
401
|
|
|
397
402
|
stripBase = (url) ->
|
|
@@ -400,7 +405,21 @@ export createRouter = (components, opts = {}) ->
|
|
|
400
405
|
addBase = (path) ->
|
|
401
406
|
if base then base + path else path
|
|
402
407
|
|
|
403
|
-
|
|
408
|
+
readUrl = ->
|
|
409
|
+
if hashMode
|
|
410
|
+
h = location.hash.slice(1)
|
|
411
|
+
return '/' unless h
|
|
412
|
+
if h[0] is '/' then h else '/' + h
|
|
413
|
+
else
|
|
414
|
+
location.pathname + location.search + location.hash
|
|
415
|
+
|
|
416
|
+
writeUrl = (path) ->
|
|
417
|
+
if hashMode
|
|
418
|
+
if path is '/' then location.pathname else '#' + path.slice(1)
|
|
419
|
+
else
|
|
420
|
+
addBase(path)
|
|
421
|
+
|
|
422
|
+
_path = __state(stripBase(if hashMode then readUrl() else location.pathname))
|
|
404
423
|
_params = __state({})
|
|
405
424
|
_route = __state(null)
|
|
406
425
|
_layouts = __state([])
|
|
@@ -418,6 +437,7 @@ export createRouter = (components, opts = {}) ->
|
|
|
418
437
|
resolve = (url) ->
|
|
419
438
|
rawPath = url.split('?')[0].split('#')[0]
|
|
420
439
|
path = stripBase(rawPath)
|
|
440
|
+
path = if path[0] is '/' then path else '/' + path
|
|
421
441
|
queryStr = url.split('?')[1]?.split('#')[0] or ''
|
|
422
442
|
hash = if url.includes('#') then url.split('#')[1] else ''
|
|
423
443
|
|
|
@@ -436,7 +456,7 @@ export createRouter = (components, opts = {}) ->
|
|
|
436
456
|
onError({ status: 404, path }) if onError
|
|
437
457
|
false
|
|
438
458
|
|
|
439
|
-
onPopState = -> resolve(
|
|
459
|
+
onPopState = -> resolve(readUrl())
|
|
440
460
|
window.addEventListener 'popstate', onPopState if typeof window isnt 'undefined'
|
|
441
461
|
|
|
442
462
|
onClick = (e) ->
|
|
@@ -448,18 +468,19 @@ export createRouter = (components, opts = {}) ->
|
|
|
448
468
|
return if url.origin isnt location.origin
|
|
449
469
|
return if target.target is '_blank' or target.hasAttribute('data-external')
|
|
450
470
|
e.preventDefault()
|
|
451
|
-
|
|
471
|
+
dest = if hashMode and url.hash then (url.hash.slice(1) or '/') else (url.pathname + url.search + url.hash)
|
|
472
|
+
router.push dest
|
|
452
473
|
|
|
453
474
|
document.addEventListener 'click', onClick if typeof document isnt 'undefined'
|
|
454
475
|
|
|
455
476
|
router =
|
|
456
477
|
push: (url) ->
|
|
457
478
|
if resolve(url)
|
|
458
|
-
history.pushState null, '',
|
|
479
|
+
history.pushState null, '', writeUrl(_path.read())
|
|
459
480
|
|
|
460
481
|
replace: (url) ->
|
|
461
482
|
if resolve(url)
|
|
462
|
-
history.replaceState null, '',
|
|
483
|
+
history.replaceState null, '', writeUrl(_path.read())
|
|
463
484
|
|
|
464
485
|
back: -> history.back()
|
|
465
486
|
forward: -> history.forward()
|
|
@@ -482,7 +503,7 @@ export createRouter = (components, opts = {}) ->
|
|
|
482
503
|
routes: undefined # overridden by getter
|
|
483
504
|
|
|
484
505
|
init: ->
|
|
485
|
-
resolve
|
|
506
|
+
resolve readUrl()
|
|
486
507
|
router
|
|
487
508
|
|
|
488
509
|
destroy: ->
|
|
@@ -794,11 +815,14 @@ export createRenderer = (opts = {}) ->
|
|
|
794
815
|
# ==============================================================================
|
|
795
816
|
|
|
796
817
|
export launch = (appBase = '', opts = {}) ->
|
|
818
|
+
if typeof appBase is 'object'
|
|
819
|
+
opts = appBase
|
|
820
|
+
appBase = ''
|
|
797
821
|
appBase = appBase.replace(/\/+$/, '') # strip trailing slashes
|
|
798
822
|
target = opts.target or '#app'
|
|
799
823
|
compile = opts.compile or null
|
|
800
824
|
persist = opts.persist or false
|
|
801
|
-
|
|
825
|
+
hash = opts.hash or false
|
|
802
826
|
|
|
803
827
|
# Auto-detect compile function from the global rip.js module
|
|
804
828
|
unless compile
|
|
@@ -810,10 +834,14 @@ export launch = (appBase = '', opts = {}) ->
|
|
|
810
834
|
el.id = target.replace(/^#/, '')
|
|
811
835
|
document.body.prepend el
|
|
812
836
|
|
|
813
|
-
#
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
837
|
+
# Get the app bundle — inline, from DOM, or fetch from server
|
|
838
|
+
if opts.bundle
|
|
839
|
+
bundle = opts.bundle
|
|
840
|
+
else
|
|
841
|
+
bundleUrl = "#{appBase}/bundle"
|
|
842
|
+
res = await fetch(bundleUrl)
|
|
843
|
+
throw new Error "launch: #{bundleUrl} (#{res.status})" unless res.ok
|
|
844
|
+
bundle = res.json!
|
|
817
845
|
|
|
818
846
|
# Create the unified stash
|
|
819
847
|
app = stash { components: {}, routes: {}, data: {} }
|
|
@@ -860,6 +888,7 @@ export launch = (appBase = '', opts = {}) ->
|
|
|
860
888
|
router = createRouter appComponents,
|
|
861
889
|
root: 'components'
|
|
862
890
|
base: appBase
|
|
891
|
+
hash: hash
|
|
863
892
|
onError: (err) -> console.error "[Rip] Error #{err.status}: #{err.message or err.path}"
|
|
864
893
|
|
|
865
894
|
# Create renderer
|
|
@@ -877,7 +906,7 @@ export launch = (appBase = '', opts = {}) ->
|
|
|
877
906
|
|
|
878
907
|
# Connect SSE watch if enabled
|
|
879
908
|
if bundle.data?.watch
|
|
880
|
-
connectWatch
|
|
909
|
+
connectWatch '/watch'
|
|
881
910
|
|
|
882
911
|
# Expose for console and dev tools
|
|
883
912
|
if typeof window isnt 'undefined'
|
|
@@ -896,7 +925,7 @@ export launch = (appBase = '', opts = {}) ->
|
|
|
896
925
|
# SSE Watch — hot-reload connection
|
|
897
926
|
# ==============================================================================
|
|
898
927
|
|
|
899
|
-
connectWatch = (
|
|
928
|
+
connectWatch = (url) ->
|
|
900
929
|
retryDelay = 1000
|
|
901
930
|
maxDelay = 30000
|
|
902
931
|
|
|
@@ -904,31 +933,15 @@ connectWatch = (components, router, renderer, url, base = '') ->
|
|
|
904
933
|
es = new EventSource(url)
|
|
905
934
|
|
|
906
935
|
es.addEventListener 'connected', ->
|
|
907
|
-
retryDelay = 1000
|
|
936
|
+
retryDelay = 1000
|
|
908
937
|
console.log '[Rip] Hot reload connected'
|
|
909
938
|
|
|
910
|
-
es.addEventListener '
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
router.rebuild()
|
|
914
|
-
|
|
915
|
-
current = router.current
|
|
916
|
-
toFetch = paths.filter (p) ->
|
|
917
|
-
p is current.route?.file or current.layouts?.includes(p)
|
|
918
|
-
|
|
919
|
-
if toFetch.length > 0
|
|
920
|
-
results = await Promise.allSettled(toFetch.map (path) ->
|
|
921
|
-
res = await fetch(base + '/' + path)
|
|
922
|
-
content = res.text!
|
|
923
|
-
components.write path, content
|
|
924
|
-
)
|
|
925
|
-
failed = results.filter (r) -> r.status is 'rejected'
|
|
926
|
-
console.error '[Rip] Hot reload fetch error:', r.reason for r in failed
|
|
927
|
-
renderer.remount()
|
|
939
|
+
es.addEventListener 'reload', ->
|
|
940
|
+
console.log '[Rip] Reloading...'
|
|
941
|
+
location.reload()
|
|
928
942
|
|
|
929
943
|
es.onerror = ->
|
|
930
944
|
es.close()
|
|
931
|
-
console.log "[Rip] Hot reload reconnecting in #{retryDelay / 1000}s..."
|
|
932
945
|
setTimeout connect, retryDelay
|
|
933
946
|
retryDelay = Math.min(retryDelay * 2, maxDelay)
|
|
934
947
|
|