@rip-lang/ui 0.1.3 → 0.3.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/README.md CHANGED
@@ -1,877 +1,316 @@
1
- <img src="https://raw.githubusercontent.com/shreeve/rip-lang/main/docs/rip.svg" style="width:50px" /> <br>
1
+ <img src="https://raw.githubusercontent.com/shreeve/rip-lang/main/docs/rip.png" style="width:50px" /> <br>
2
2
 
3
3
  # Rip UI - @rip-lang/ui
4
4
 
5
- > **A zero-build reactive web framework ship the compiler to the browser, compile on demand, render with fine-grained reactivity**
5
+ > **Zero-build reactive web framework for the Rip language.**
6
6
 
7
- Rip UI inverts the traditional web development model. Instead of building,
8
- bundling, and shipping static JavaScript artifacts to the browser, it ships the
9
- 40KB Rip compiler itself. Components are delivered as source files, stored in a
10
- browser-local Virtual File System, compiled on demand, and rendered with
11
- fine-grained DOM updates powered by Rip's built-in reactivity. No build step.
12
- No bundler. No configuration files.
7
+ Load the Rip compiler in the browser. Write inline Rip. Launch your app.
8
+ No build step, no bundler, no configuration.
13
9
 
14
- The component model adds exactly **two keywords** to the Rip language —
15
- `component` and `render` — and reuses everything else (classes, reactivity,
16
- functions, methods) that Rip already provides.
17
-
18
- ## Architecture
19
-
20
- ```
21
- ┌─────────────────────────────────────────┐
22
- │ Server (Bun + @rip-lang/api) │
23
- │ │
24
- │ serve.rip (ripUI middleware) │
25
- │ ├── /rip-ui/*.js → framework files │
26
- │ ├── /rip-ui/manifest.json → all pages │
27
- │ ├── /rip-ui/watch → SSE hot-reload │
28
- │ └── /pages/*.rip → individual pages │
29
- └──────────────┬──────────────────────────┘
30
-
31
- ┌──────────────────────────┼──────────────────────────┐
32
- │ │ │
33
- ▼ ▼ ▼
34
- rip.browser.js (40KB) @rip-lang/ui (~8KB) SSE EventSource
35
- (Rip compiler) (framework modules) (hot-reload channel)
36
- │ │ │
37
- │ ┌────────────────┼────────────────┐ │
38
- │ │ │ │ │
39
- │ Reactive Stash Virtual FS Router │
40
- │ (app state) (file storage) (URL → VFS) │
41
- │ │ │ │ │
42
- │ └────────────────┼────────────────┘ │
43
- │ │ │
44
- └─────────────────► Renderer ◄────────────────────┘
45
- (compiles + mounts)
46
-
47
- DOM
48
- ```
49
-
50
- | Module | Size | Role |
51
- |--------|------|------|
52
- | `ui.js` | ~175 lines | `createApp` entry point with `loadBundle`, `watch`, re-exports |
53
- | `stash.js` | ~400 lines | Deep reactive state tree with path-based navigation |
54
- | `vfs.js` | ~200 lines | Browser-local Virtual File System with watchers |
55
- | `router.js` | ~300 lines | File-based router (URL ↔ VFS paths, History API) |
56
- | `renderer.js` | ~250 lines | Component lifecycle, layouts, transitions, `remount` |
57
- | `serve.rip` | ~140 lines | Server middleware: framework files, manifest, SSE hot-reload |
58
-
59
- ## The Idea
60
-
61
- Modern web frameworks — React, Vue, Svelte, Solid — all share the same
62
- fundamental assumption: code must be compiled and bundled on the developer's
63
- machine, then shipped as static artifacts. This creates an entire ecosystem of
64
- build tools (Vite, Webpack, Turbopack, esbuild), configuration files, dev
65
- servers, hot module replacement protocols, and deployment pipelines. The
66
- developer experience is powerful, but the machinery is enormous.
67
-
68
- Rip UI asks: **what if the compiler ran in the browser?**
69
-
70
- At 40KB, the Rip compiler is small enough to ship alongside your application.
71
- Components arrive as `.rip` source files — plain text — and are compiled to
72
- JavaScript on the client's machine. This eliminates the build step entirely.
73
- There is no `dist/` folder, no source maps, no chunk splitting, no tree
74
- shaking, no CI build minutes. You write a `.rip` file, the browser compiles it,
75
- and it runs.
76
-
77
- This is not a toy or a limitation. The compiler produces the same output it
78
- would on a server. The reactive system is the same signal-based engine that
79
- powers server-side Rip. The component model compiles to anonymous ES6 classes
80
- with fine-grained DOM manipulation — no virtual DOM diffing.
81
-
82
- ### How It Differs from Existing Frameworks
83
-
84
- | | React/Vue/Svelte | Rip UI |
85
- |---|---|---|
86
- | **Build step** | Required (Vite, Webpack, etc.) | None — compiler runs in browser |
87
- | **Bundle size** | 40-100KB+ framework + app bundle | 40KB compiler + ~8KB framework + raw source |
88
- | **HMR** | Dev server ↔ browser WebSocket | SSE notify + VFS invalidation + recompile |
89
- | **Deployment** | Build artifacts (`dist/`) | Source files served as-is |
90
- | **Component format** | JSX, SFC, templates | Rip source (`.rip` files) |
91
- | **Reactivity** | Library-specific (hooks, refs, signals) | Language-native (`:=`, `~=`, `~>`) |
92
- | **DOM updates** | Virtual DOM diff or compiled transforms | Fine-grained effects, direct DOM mutation |
93
- | **Routing** | Framework plugin (react-router, etc.) | Built-in file-based router over VFS |
94
- | **State management** | External library (Redux, Pinia, etc.) | Built-in reactive stash with deep tracking |
95
-
96
- ## Component Model
97
-
98
- The component system adds two keywords to Rip: `component` and `render`. Everything
99
- else — reactive state (`:=`), computed values (`~=`), effects (`~>`), methods,
100
- lifecycle — uses standard Rip syntax. A component is an expression that evaluates
101
- to an anonymous ES6 class.
102
-
103
- ### Defining a Component
104
-
105
- ```coffee
106
- Counter = component
107
- @count := 0 # reactive state (signal) — parent can override via props
108
- @step = 1 # plain prop — parent can set, not reactive
109
-
110
- doubled ~= @count * 2 # computed (derived, read-only)
111
-
112
- increment: -> @count += @step
113
- decrement: -> @count -= @step
114
-
115
- mounted: ->
116
- console.log "Counter mounted"
117
-
118
- render
119
- div.counter
120
- h1 "Count: #{@count}"
121
- p "Doubled: #{doubled}"
122
- button @click: @increment, "+#{@step}"
123
- button @click: @decrement, "-#{@step}"
124
- ```
125
-
126
- This compiles to an anonymous ES6 class expression:
127
-
128
- ```javascript
129
- Counter = class {
130
- constructor(props = {}) {
131
- this.count = isSignal(props.count) ? props.count : __state(props.count ?? 0);
132
- this.step = props.step ?? 1;
133
- this.doubled = __computed(() => this.count.value * 2);
134
- }
135
- increment() { return this.count.value += this.step; }
136
- decrement() { return this.count.value -= this.step; }
137
- mounted() { return console.log("Counter mounted"); }
138
- _create() { /* fine-grained DOM creation */ }
139
- _setup() { /* reactive effect bindings */ }
140
- mount(target) { /* ... */ }
141
- unmount() { /* ... */ }
142
- }
143
- ```
144
-
145
- ### The Two Keywords
146
-
147
- **`component`** — Declares an anonymous class with component semantics:
148
-
149
- - `@` properties become instance variables that the parent can set via props
150
- - `:=` assignments create reactive signals (`__state`)
151
- - `~=` assignments create computed values (`__computed`)
152
- - `~>` assignments create effects (`__effect`)
153
- - Plain `=` assignments with function values become methods
154
- - `mounted`, `unmounted`, `updated` are lifecycle hooks called by the runtime
155
- - Everything else is standard Rip class behavior
156
-
157
- **`render`** — Defines the component's template using a Pug/Jade-like DSL:
158
-
159
- - Tags are bare identifiers: `div`, `h1`, `button`, `span`
160
- - Classes use dot notation: `div.card.active`, `button.btn.btn-primary`
161
- - Dynamic classes use dot-parens: `div.("active" if @selected)` (CLSX-like)
162
- - Attributes use object syntax: `input type: "text", placeholder: "..."`
163
- - Events use `@` prefix: `button @click: @handleClick`
164
- - Text content is a string argument: `h1 "Hello"`
165
- - Interpolation works: `p "Count: #{@count}"`
166
- - Children are indented below their parent
167
- - `if`/`else` and `for...in` work inside templates
168
-
169
- That's it. No special attribute syntax, no directive system, no template
170
- compiler — just Rip's existing syntax applied to DOM construction.
171
-
172
- ### Props
173
-
174
- Every `@` property on a component is a prop that the parent can set. The child
175
- owns the property; the parent can provide an initial value or pass a reactive
176
- signal:
177
-
178
- ```coffee
179
- # Parent passes plain value — child wraps it in a signal
180
- Counter {count: 10}
181
-
182
- # Parent passes its own signal — child uses it directly (shared state)
183
- Counter {count: parentCount}
184
- ```
185
-
186
- The `isSignal` check in the constructor handles this automatically:
187
-
188
- ```javascript
189
- this.count = isSignal(props.count) ? props.count : __state(props.count ?? 0);
190
- ```
191
-
192
- If you don't want the parent to override a prop, don't use `@`:
193
-
194
- ```coffee
195
- MyComponent = component
196
- active := false # internal state — not a prop, parent can't set it
197
- @count := 0 # prop — parent can set or share a signal
198
- ```
199
-
200
- ### Required and Optional Props
201
-
202
- ```coffee
203
- UserCard = component
204
- @name # required — no default, error if missing
205
- @avatar = "/default.png" # optional — has a default
206
- @bio? := "" # optional reactive — ? makes it explicitly optional
207
- ```
208
-
209
- ### Computed Values and Effects
210
-
211
- ```coffee
212
- TodoList = component
213
- @todos := []
214
- remaining ~= @todos.filter((t) -> not t.done).length
215
- total ~= @todos.length
216
-
217
- ~> console.log "#{remaining} of #{total} remaining"
218
-
219
- render
220
- div
221
- h2 "Todos (#{remaining}/#{total})"
222
- # ...
223
- ```
224
-
225
- ### Lifecycle
10
+ ## Quick Start
226
11
 
227
- `mounted`, `unmounted`, and `updated` are just methods. No special syntax. The
228
- runtime calls them at the appropriate times:
12
+ **`index.rip`** the server:
229
13
 
230
14
  ```coffee
231
- Timer = component
232
- @elapsed := 0
233
- @interval = null
234
-
235
- mounted: ->
236
- @interval = setInterval (=> @elapsed += 1), 1000
237
-
238
- unmounted: ->
239
- clearInterval @interval
15
+ import { get, use, start, notFound } from '@rip-lang/api'
16
+ import { ripUI } from '@rip-lang/ui/serve'
240
17
 
241
- render
242
- p "#{@elapsed} seconds"
18
+ dir = import.meta.dir
19
+ use ripUI dir: dir, components: 'pages', includes: ['ui'], watch: true, title: 'My App'
20
+ get '/css/*', -> @send "#{dir}/css/#{@req.path.slice(5)}"
21
+ notFound -> @send "#{dir}/index.html", 'text/html; charset=UTF-8'
22
+ start port: 3000
243
23
  ```
244
24
 
245
- ### Two-Way Binding
246
-
247
- The `<=>` operator creates two-way bindings between form elements and reactive
248
- state:
249
-
250
- ```coffee
251
- SearchBox = component
252
- @query := ""
25
+ **`index.html`** the page:
253
26
 
254
- render
255
- input type: "text", value <=> @query
256
- p "Searching for: #{@query}"
27
+ ```html
28
+ <script type="module" src="/rip/rip-ui.min.js"></script>
29
+ <script type="text/rip">
30
+ { launch } = importRip! 'ui.rip'
31
+ launch()
32
+ </script>
257
33
  ```
258
34
 
259
- ### Child Components
260
-
261
- Components can nest. Props are passed as object arguments:
35
+ **`pages/index.rip`** a page component:
262
36
 
263
37
  ```coffee
264
- App = component
265
- @user := { name: "Alice" }
266
-
38
+ export Home = component
39
+ @count := 0
267
40
  render
268
- div
269
- Header {title: "My App"}
270
- UserCard {name: @user.name, avatar: "/alice.png"}
271
- Footer
41
+ .
42
+ h1 "Hello from Rip UI"
43
+ button @click: (-> @count += 1), "Clicked #{@count} times"
272
44
  ```
273
45
 
274
- ### Context
46
+ Run `bun index.rip`, open `http://localhost:3000`.
275
47
 
276
- Components can share state down the tree without passing props at every level:
48
+ ## Component Composition
277
49
 
278
- ```coffee
279
- # In a parent component's constructor:
280
- setContext 'theme', @theme
281
-
282
- # In any descendant component:
283
- theme = getContext 'theme'
284
- ```
285
-
286
- ### Multiple Components Per File
287
-
288
- Because `component` is an expression (not a declaration), multiple components
289
- can live in one file:
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:
290
53
 
291
54
  ```coffee
292
- Button = component
293
- @label = "Click"
294
- @onClick = null
55
+ # ui/card.rip
56
+ export Card = component
57
+ title =! ""
295
58
  render
296
- button.btn @click: @onClick, @label
59
+ .card
60
+ if title
61
+ h3 "#{title}"
62
+ @children
297
63
 
298
- Card = component
299
- @title = ""
64
+ # pages/about.rip
65
+ export About = component
300
66
  render
301
- div.card
302
- h3 @title
303
- div.card-body
304
- slot
305
-
306
- Page = component
307
- render
308
- div
309
- Card {title: "Welcome"}
310
- Button {label: "Get Started", onClick: -> alert "Go!"}
311
- ```
312
-
313
- ## Virtual File System
314
-
315
- The VFS is a browser-local file storage layer. Components are delivered as
316
- source text and stored in memory. The compiler reads from the VFS, not from
317
- disk.
318
-
319
- ```javascript
320
- import { vfs } from '@rip-lang/ui/vfs'
321
-
322
- const fs = vfs()
323
-
324
- // Write source files
325
- fs.write('pages/index.rip', 'component Home\n render\n h1 "Hello"')
326
- fs.write('pages/users/[id].rip', '...')
327
-
328
- // Read
329
- fs.read('pages/index.rip') // source string
330
- fs.exists('pages/index.rip') // true
331
- fs.list('pages/') // ['index.rip', 'users/']
332
- fs.listAll('pages/') // all files recursively
333
-
334
- // Watch for changes (triggers recompilation)
335
- fs.watch('pages/', ({ event, path }) => {
336
- console.log(`${event}: ${path}`)
337
- })
338
-
339
- // Fetch from server
340
- await fs.fetch('pages/index.rip', '/api/pages/index.rip')
341
- await fs.fetchManifest([
342
- 'pages/index.rip',
343
- 'pages/about.rip',
344
- 'pages/counter.rip'
345
- ])
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."
346
73
  ```
347
74
 
348
- ### Why a VFS?
75
+ Reactive props via `:=` signal passthrough. Readonly props via `=!`.
76
+ Children blocks passed as DOM nodes via `@children`.
349
77
 
350
- Traditional frameworks read from the server's file system during the build step
351
- and produce static bundles. Rip UI has no build step, so it needs somewhere to
352
- store source files in the browser. The VFS provides:
78
+ ## How It Works
353
79
 
354
- - **Addressable storage**components are referenced by path, just like files
355
- - **File watching** the renderer re-compiles when a file changes
356
- - **Lazy loading** pages can be fetched on demand as the user navigates
357
- - **Hot update** — write a new version of a file and the component re-renders
358
- - **Manifest loading** — bulk-fetch an app's files in one call
80
+ The browser loads one file `rip-ui.min.js` (~52KB Brotli) which bundles the
81
+ Rip compiler and the pre-compiled UI framework. No runtime compilation of the
82
+ framework, no extra network requests.
359
83
 
360
- The VFS is not IndexedDB or localStorage it's a plain in-memory Map. Fast,
361
- simple, ephemeral. For persistence, the server delivers files on page load.
84
+ Then `launch()` fetches the app bundle, hydrates the stash, and renders.
362
85
 
363
- ## File-Based Router
86
+ ### Browser Execution Contexts
364
87
 
365
- URLs map to VFS paths. The routing conventions match Next.js / SvelteKit:
88
+ Rip provides full async/await support across every browser context no other
89
+ compile-to-JS language has this:
366
90
 
367
- ```javascript
368
- import { createRouter } from '@rip-lang/ui/router'
91
+ | Context | How async works | Returns value? |
92
+ |---------|-----------------|----------------|
93
+ | `<script type="text/rip">` | Async IIFE wrapper | No (fire-and-forget) |
94
+ | Playground "Run" button | Async IIFE wrapper | No (use console.log) |
95
+ | `rip()` console REPL | Rip `do ->` block | Yes (sync direct, async via Promise) |
96
+ | `.rip` files via `importRip()` | ES module import | Yes (module exports) |
369
97
 
370
- const router = createRouter(fs, { root: 'pages' })
371
- ```
372
-
373
- | VFS Path | URL Pattern | Example |
374
- |----------|-------------|---------|
375
- | `pages/index.rip` | `/` | Home page |
376
- | `pages/about.rip` | `/about` | Static page |
377
- | `pages/users/index.rip` | `/users` | User list |
378
- | `pages/users/[id].rip` | `/users/:id` | Dynamic segment |
379
- | `pages/blog/[...slug].rip` | `/blog/*slug` | Catch-all |
380
- | `pages/_layout.rip` | — | Root layout (wraps all pages) |
381
- | `pages/users/_layout.rip` | — | Nested layout (wraps `/users/*`) |
382
-
383
- ```javascript
384
- // Navigate
385
- router.push('/users/42')
386
- router.replace('/login')
387
- router.back()
388
-
389
- // Reactive route state
390
- effect(() => {
391
- console.log(router.path) // '/users/42'
392
- console.log(router.params) // { id: '42' }
393
- })
394
- ```
98
+ The `!` postfix compiles to `await`. Inline scripts are wrapped in an async IIFE
99
+ automatically. The `rip()` console function wraps user code in a `do ->` block
100
+ so the Rip compiler handles implicit return and auto-async natively.
395
101
 
396
- The router intercepts `<a>` clicks automatically for SPA navigation. External
397
- links and modified clicks (ctrl+click, etc.) pass through normally.
102
+ ### globalThis Exports
398
103
 
399
- ## Reactive Stash
104
+ When `rip-ui.min.js` loads, it registers these on `globalThis`:
400
105
 
401
- Deep reactive state tree with path-based navigation. Every nested property is
402
- automatically tracked — changing any value triggers fine-grained updates.
106
+ | Function | Purpose |
107
+ |----------|---------|
108
+ | `rip(code)` | Console REPL — compile and execute Rip code |
109
+ | `importRip(url)` | Fetch, compile, and import a `.rip` file as an ES module |
110
+ | `compileToJS(code)` | Compile Rip source to JavaScript |
111
+ | `__rip` | Reactive runtime — `__state`, `__computed`, `__effect`, `__batch` |
112
+ | `__ripComponent` | Component runtime — `__Component`, `__clsx`, `__fragment` |
113
+ | `__ripExports` | All compiler exports — `compile`, `formatSExpr`, `VERSION`, etc. |
403
114
 
404
- ```javascript
405
- import { stash, effect } from '@rip-lang/ui/stash'
115
+ ## The Stash
406
116
 
407
- const app = stash({
408
- user: { name: 'Alice', prefs: { theme: 'dark' } },
409
- cart: { items: [], total: 0 }
410
- })
117
+ App state lives in one reactive tree:
411
118
 
412
- // Direct property access (tracked)
413
- app.user.name // 'Alice'
414
- app.user.prefs.theme = 'light' // triggers updates
415
-
416
- // Path-based access
417
- app.get('user.prefs.theme') // 'light'
418
- app.set('cart.items[0]', { id: 1 }) // deep set with auto-creation
419
- app.has('user.name') // true
420
- app.del('cart.items[0]') // delete
421
- app.inc('cart.total', 9.99) // increment
422
- app.merge({ user: { role: 'admin' }}) // shallow merge
423
- app.keys('user') // ['name', 'prefs', 'role']
424
-
425
- // Reactive effects
426
- effect(() => {
427
- console.log(`Theme: ${app.user.prefs.theme}`)
428
- // Re-runs whenever theme changes
429
- })
430
119
  ```
431
-
432
- ### State Tiers
433
-
434
- Three levels of reactive state, each scoped appropriately:
435
-
436
- | Tier | Scope | Lifetime | Access |
437
- |------|-------|----------|--------|
438
- | **App** | Global | Entire session | `app.user`, `app.theme` |
439
- | **Route** | Per-route | Until navigation | `router.params`, `router.query` |
440
- | **Component** | Per-instance | Until unmount | `:=` reactive state |
441
-
442
- App state lives in the stash. Route state lives in the router. Component state
443
- lives in the component instance as signals. All three are reactive — changes at
444
- any level trigger the appropriate DOM updates.
445
-
446
- ## Component Renderer
447
-
448
- The renderer is the bridge between the router and the DOM. When the route
449
- changes, the renderer:
450
-
451
- 1. Resolves the VFS path for the new route
452
- 2. Reads the `.rip` source from the VFS
453
- 3. Compiles it to JavaScript using the Rip compiler
454
- 4. Evaluates the compiled code to get a component class
455
- 5. Instantiates the component, passing route params as props
456
- 6. Wraps it in any applicable layout components
457
- 7. Mounts the result into the DOM target
458
- 8. Runs transition animations (if configured)
459
- 9. Unmounts the previous component
460
-
461
- ```javascript
462
- import { createRenderer } from '@rip-lang/ui/renderer'
463
-
464
- const renderer = createRenderer({
465
- router,
466
- fs,
467
- stash: appState,
468
- compile: compileToJS,
469
- target: '#app',
470
- transition: { duration: 200 }
471
- })
472
-
473
- renderer.start() // Watch for route changes, mount components
474
- renderer.stop() // Unmount everything, clean up
475
- ```
476
-
477
- ### Compilation Cache
478
-
479
- Compiled components are cached by VFS path. A file is only recompiled when it
480
- changes. The VFS watcher triggers cache invalidation, so updating a file in the
481
- VFS automatically causes the next render to use the new version.
482
-
483
- ## Server Integration
484
-
485
- ### `ripUI` Middleware
486
-
487
- The `ripUI` export from `@rip-lang/ui/serve` is a setup function that registers
488
- routes for serving framework files, auto-generated page manifests, and an SSE
489
- hot-reload channel. It works with `@rip-lang/api`'s `use()`:
490
-
491
- ```coffee
492
- import { use } from '@rip-lang/api'
493
- import { ripUI } from '@rip-lang/ui/serve'
494
-
495
- use ripUI pages: 'pages', watch: true
120
+ app
121
+ ├── routes ← navigation state (path, params, query, hash)
122
+ └── data ← reactive app state (title, theme, user, etc.)
496
123
  ```
497
124
 
498
- When called, `ripUI` registers the following routes:
499
-
500
- | Route | Description |
501
- |-------|-------------|
502
- | `GET /rip-ui/ui.js` | Framework entry point |
503
- | `GET /rip-ui/stash.js` | Reactive state module |
504
- | `GET /rip-ui/vfs.js` | Virtual File System |
505
- | `GET /rip-ui/router.js` | File-based router |
506
- | `GET /rip-ui/renderer.js` | Component renderer |
507
- | `GET /rip-ui/compiler.js` | Rip compiler (40KB) |
508
- | `GET /rip-ui/manifest.json` | Auto-generated manifest of all `.rip` pages |
509
- | `GET /rip-ui/watch` | SSE hot-reload endpoint (when `watch: true`) |
510
- | `GET /pages/*` | Individual `.rip` page files (for hot-reload refetch) |
125
+ Writing to `app.data.theme` updates any component reading it. The stash
126
+ uses Rip's built-in reactive primitives — the same signals that power
127
+ `:=` and `~=` in components.
511
128
 
512
- ### Options
129
+ ## The App Bundle
513
130
 
514
- | Option | Type | Default | Description |
515
- |--------|------|---------|-------------|
516
- | `pages` | `string` | `'pages'` | Directory containing `.rip` page files |
517
- | `base` | `string` | `'/rip-ui'` | URL prefix for framework files |
518
- | `watch` | `boolean` | `false` | Enable SSE hot-reload endpoint |
519
- | `debounce` | `number` | `250` | Milliseconds to batch filesystem change events |
520
-
521
- ### Page Manifest
522
-
523
- The manifest endpoint (`/rip-ui/manifest.json`) auto-discovers every `.rip` file
524
- in the `pages` directory and bundles them into a single JSON response:
131
+ The bundle is JSON served at `/{app}/bundle`:
525
132
 
526
133
  ```json
527
134
  {
528
- "pages/index.rip": "Home = component\n render\n h1 \"Hello\"",
529
- "pages/about.rip": "About = component\n render\n h1 \"About\"",
530
- "pages/counter.rip": "..."
135
+ "components": {
136
+ "components/index.rip": "export Home = component...",
137
+ "components/counter.rip": "export Counter = component...",
138
+ "components/_lib/card.rip": "export Card = component..."
139
+ },
140
+ "data": {
141
+ "title": "My App",
142
+ "theme": "light"
143
+ }
531
144
  }
532
145
  ```
533
146
 
534
- The client loads this with `app.loadBundle('/rip-ui/manifest.json')`, which
535
- populates the VFS in a single request no need to list pages manually.
147
+ On disk you organize your app into `pages/` and `ui/`. The middleware
148
+ maps them into a flat `components/` namespace in the bundle pages go
149
+ under `components/`, shared components under `components/_lib/`. The `_`
150
+ prefix tells the router to skip `_lib/` entries when generating routes.
536
151
 
537
- ## Hot Reload
152
+ ## Server Middleware
538
153
 
539
- The hot-reload system uses a **notify-only** architecture — the server tells the
540
- browser *which files changed*, then the browser decides what to refetch.
541
-
542
- ### How It Works
543
-
544
- ```
545
- Developer saves a .rip file
546
-
547
-
548
- Server (fs.watch)
549
- ├── Debounce (250ms, batches rapid saves)
550
- └── SSE "changed" event: { paths: ["pages/counter.rip"] }
551
-
552
-
553
- Browser (EventSource)
554
- ├── Invalidate VFS entries (fs.delete)
555
- ├── Rebuild router (router.rebuild)
556
- ├── Smart refetch:
557
- │ ├── Current page file? → fetch immediately
558
- │ ├── Active layout? → fetch immediately
559
- │ ├── Eager file? → fetch immediately
560
- │ └── Other pages? → fetch lazily on next navigation
561
- └── Re-render (renderer.remount)
562
- ```
563
-
564
- ### Server Side
565
-
566
- The `watch` option enables filesystem monitoring with debounced SSE
567
- notifications. A heartbeat every 5 seconds keeps the connection alive:
154
+ The `ripUI` middleware registers routes for the framework files, the app
155
+ bundle, and optional SSE hot-reload:
568
156
 
569
157
  ```coffee
570
- use ripUI pages: "#{dir}/pages", watch: true, debounce: 250
158
+ use ripUI dir: dir, components: 'pages', includes: ['ui'], watch: true, title: 'My App'
571
159
  ```
572
160
 
573
- ### Client Side
161
+ | Option | Default | Description |
162
+ |--------|---------|-------------|
163
+ | `app` | `''` | URL mount point |
164
+ | `dir` | `'.'` | App directory on disk |
165
+ | `components` | `'components'` | Directory for page components (file-based routing) |
166
+ | `includes` | `[]` | Directories for shared components (no routes) |
167
+ | `watch` | `false` | Enable SSE hot-reload |
168
+ | `debounce` | `250` | Milliseconds to batch file change events |
169
+ | `state` | `null` | Initial app state |
170
+ | `title` | `null` | Document title |
574
171
 
575
- Connect to the SSE endpoint after starting the app:
172
+ Routes registered:
576
173
 
577
- ```javascript
578
- app.watch('/rip-ui/watch');
579
174
  ```
580
-
581
- The `watch()` method accepts an optional `eager` array — files that should always
582
- be refetched immediately, even if they're not the current route:
583
-
584
- ```javascript
585
- app.watch('/rip-ui/watch', {
586
- eager: ['pages/_layout.rip']
587
- });
175
+ /rip/rip-ui.min.js — Rip compiler + pre-compiled UI framework
176
+ /{app}/bundle — app bundle (components + data as JSON)
177
+ /{app}/watch — SSE hot-reload stream (when watch: true)
178
+ /{app}/components/* — individual component files (for hot-reload refetch)
588
179
  ```
589
180
 
590
- ### `loadBundle(url)`
181
+ ## State Preservation (Keep-Alive)
591
182
 
592
- Fetches a JSON manifest containing all page sources and loads them into the VFS
593
- in a single request. Returns the app instance (chainable).
183
+ Components are cached when navigating away instead of destroyed. Navigate
184
+ to `/counter`, increment the count, go to `/about`, come back — the count
185
+ is preserved. Configurable via `cacheSize` (default 10).
594
186
 
595
- ```javascript
596
- await app.loadBundle('/rip-ui/manifest.json');
597
- ```
187
+ ## Data Loading
598
188
 
599
- ### `remount()`
189
+ `createResource` manages async data with reactive `loading`, `error`, and
190
+ `data` properties:
600
191
 
601
- The renderer's `remount()` method re-renders the current route. This is called
602
- automatically by `watch()` after refetching changed files, but it can also be
603
- called manually:
192
+ ```coffee
193
+ export UserPage = component
194
+ user := createResource -> fetch!("/api/users/#{@params.id}").json!
604
195
 
605
- ```javascript
606
- app.renderer.remount();
196
+ render
197
+ if user.loading
198
+ p "Loading..."
199
+ else if user.error
200
+ p "Error: #{user.error.message}"
201
+ else
202
+ h1 user.data.name
607
203
  ```
608
204
 
609
- ## Quick Start
610
-
611
- ### With Server Integration (Recommended)
612
-
613
- The fastest way to get started is with `@rip-lang/api` and the `ripUI` middleware.
614
- The middleware serves all framework files, auto-generates a page manifest, and
615
- provides hot-reload — your server is just a few lines:
205
+ ## Error Boundaries
616
206
 
617
- **`index.rip`** The complete server:
207
+ Layouts with an `onError` method catch errors from child components:
618
208
 
619
209
  ```coffee
620
- import { get, use, start, notFound } from '@rip-lang/api'
621
- import { ripUI } from '@rip-lang/ui/serve'
622
-
623
- dir = import.meta.dir
624
-
625
- use ripUI pages: "#{dir}/pages", watch: true
626
-
627
- get '/css/*', -> @send "#{dir}/css/#{@req.path.slice(5)}"
210
+ export Layout = component
211
+ errorMsg := null
628
212
 
629
- notFound -> @send "#{dir}/index.html", 'text/html; charset=UTF-8'
213
+ onError: (err) -> errorMsg = err.message
630
214
 
631
- start port: 3000
215
+ render
216
+ .app-layout
217
+ if errorMsg
218
+ .error-banner "#{errorMsg}"
219
+ #content
632
220
  ```
633
221
 
634
- **`index.html`** The HTML shell:
222
+ ## Navigation Indicator
635
223
 
636
- ```html
637
- <!DOCTYPE html>
638
- <html>
639
- <head><title>My App</title></head>
640
- <body>
641
- <div id="app"></div>
642
- <script type="module">
643
- import { compileToJS } from '/rip-ui/compiler.js';
644
- import { createApp } from '/rip-ui/ui.js';
645
-
646
- const app = createApp({
647
- target: '#app',
648
- compile: compileToJS,
649
- state: { theme: 'light' }
650
- });
651
-
652
- await app.loadBundle('/rip-ui/manifest.json');
653
- app.start();
654
- app.watch('/rip-ui/watch');
655
- </script>
656
- </body>
657
- </html>
224
+ `router.navigating` is a reactive signal — true while a route transition
225
+ is in progress:
226
+
227
+ ```coffee
228
+ if @router.navigating
229
+ span "Loading..."
658
230
  ```
659
231
 
660
- That's it. Run `bun index.rip` and open `http://localhost:3000`. Every `.rip`
661
- file in the `pages/` directory is auto-discovered, served as a manifest, and
662
- hot-reloaded on save.
232
+ ## Multi-App Hosting
663
233
 
664
- ### Standalone (No Server)
234
+ Mount multiple apps under one server:
665
235
 
666
- For static deployments or quick prototyping, you can inline components directly:
236
+ ```coffee
237
+ import { get, start, notFound } from '@rip-lang/api'
238
+ import { mount as demo } from './demo/index.rip'
239
+ import { mount as labs } from './labs/index.rip'
667
240
 
668
- ```html
669
- <script type="module">
670
- import { compileToJS } from './rip.browser.js'
671
- import { createApp } from './ui.js'
672
-
673
- createApp({
674
- target: '#app',
675
- compile: compileToJS,
676
- files: {
677
- 'pages/index.rip': `
678
- Home = component
679
- render
680
- h1 "Hello, World"
681
- p "This was compiled in your browser."
682
- `
683
- }
684
- }).start()
685
- </script>
241
+ demo '/demo'
242
+ labs '/labs'
243
+ get '/', -> Response.redirect('/demo/', 302)
244
+ start port: 3002
686
245
  ```
687
246
 
688
- ### File Structure
247
+ The `/rip/` namespace is shared — all apps use the same compiler and framework.
248
+
249
+ ## File Structure
689
250
 
690
251
  ```
691
252
  my-app/
692
- ├── index.rip # Server (uses @rip-lang/api + ripUI middleware)
693
- ├── index.html # HTML shell
694
- ├── pages/
695
- │ ├── _layout.rip # Root layout (nav, footer)
696
- │ ├── index.rip # Home page → /
697
- │ ├── about.rip # About page → /about
253
+ ├── index.rip # Server
254
+ ├── index.html # HTML page
255
+ ├── pages/ # Page components (file-based routing)
256
+ │ ├── _layout.rip # Root layout
257
+ │ ├── index.rip # Home → /
258
+ │ ├── about.rip # About → /about
698
259
  │ └── users/
699
- ├── _layout.rip # Users layout wraps /users/*
700
- ├── index.rip # User list → /users
701
- └── [id].rip # User profile /users/:id
260
+ └── [id].rip # User profile → /users/:id
261
+ ├── ui/ # Shared components (no routes)
262
+ └── card.rip # Card available as Card
702
263
  └── css/
703
- └── styles.css # Tailwind or plain CSS
264
+ └── styles.css # Styles
704
265
  ```
705
266
 
706
- > **Note:** Framework files (`ui.js`, `stash.js`, `router.js`, etc.) are served
707
- > automatically by the `ripUI` middleware from the installed `@rip-lang/ui`
708
- > package you don't need to copy them into your project.
709
-
710
- ## Render Template Syntax
267
+ Files starting with `_` don't generate routes (`_layout.rip` is a layout,
268
+ not a page). Directories starting with `_` are also excluded, which is how
269
+ shared components from `includes` stay out of the router.
711
270
 
712
- The `render` block uses a concise, indentation-based template syntax:
271
+ ## Hash Routing
713
272
 
714
- ### Tags and Classes
273
+ For static hosting (GitHub Pages, S3, etc.) where the server can't handle
274
+ SPA fallback routing, use hash-based URLs:
715
275
 
716
276
  ```coffee
717
- render
718
- div # <div></div>
719
- div.card # <div class="card"></div>
720
- div.card.active # <div class="card active"></div>
721
- button.btn.btn-primary # <button class="btn btn-primary"></button>
277
+ launch '/app', hash: true
722
278
  ```
723
279
 
724
- ### Dynamic Classes (CLSX)
280
+ This switches from `/about` to `page.html#/about`. Back/forward navigation,
281
+ direct URL loading, and `href="#/path"` links all work correctly.
725
282
 
726
- ```coffee
727
- render
728
- div.("active" if @selected) # conditional class
729
- div.("bg-red" if error, "bg-green" if ok) # multiple conditions
730
- div.card.("highlighted" if @featured) # static + dynamic
731
- ```
283
+ ## Static Deployment — `launch bundle:`
732
284
 
733
- Dynamic class expressions are evaluated at runtime. Falsy values are filtered
734
- out. This provides native CLSX-like behavior without a library.
285
+ Inline all components in a single HTML file for zero-server deployment.
286
+ Use `rip-ui.min.js` (~52KB Brotli) a combined bundle with the compiler
287
+ and pre-compiled UI framework. No extra network requests, no runtime
288
+ compilation of the framework:
735
289
 
736
- ### Attributes and Events
737
-
738
- ```coffee
739
- render
740
- input type: "text", placeholder: "Search..."
741
- button @click: @handleClick, "Submit"
742
- a href: "/about", "About Us"
743
- img src: @imageUrl, alt: "Photo"
744
- ```
745
-
746
- ### Text and Interpolation
747
-
748
- ```coffee
749
- render
750
- h1 "Static text"
751
- p "Hello, #{@name}"
752
- span "Count: #{@count}"
753
- ```
754
-
755
- ### Conditionals
756
-
757
- ```coffee
758
- render
759
- if @loggedIn
760
- p "Welcome back, #{@name}"
761
- else
762
- p "Please log in"
763
- ```
764
-
765
- ### Loops
766
-
767
- ```coffee
768
- render
769
- ul
770
- for item in @items
771
- li item.name
772
- ```
773
-
774
- ### Nesting
775
-
776
- Indentation defines parent-child relationships:
777
-
778
- ```coffee
779
- render
780
- div.app
781
- header.app-header
782
- h1 "My App"
783
- nav
784
- a href: "/", "Home"
785
- a href: "/about", "About"
786
- main.app-body
787
- p "Content goes here"
788
- footer
789
- p "Footer"
290
+ ```html
291
+ <script type="module" src="dist/rip-ui.min.js"></script>
292
+ <script type="text/rip">
293
+ { launch } = importRip! 'ui.rip'
294
+
295
+ launch bundle:
296
+ '/': '''
297
+ export Home = component
298
+ render
299
+ h1 "Hello"
300
+ '''
301
+ '/about': '''
302
+ export About = component
303
+ render
304
+ h1 "About"
305
+ '''
306
+ , hash: true
307
+ </script>
790
308
  ```
791
309
 
792
- ## API Reference
793
-
794
- ### `createApp(options)`
795
-
796
- | Option | Type | Default | Description |
797
- |--------|------|---------|-------------|
798
- | `target` | `string\|Element` | `'#app'` | DOM mount target |
799
- | `state` | `object` | `{}` | Initial app state (becomes reactive stash) |
800
- | `files` | `object` | `{}` | Initial VFS files `{ path: content }` |
801
- | `root` | `string` | `'pages'` | Pages directory in VFS |
802
- | `compile` | `function` | — | Rip compiler (`compileToJS`) |
803
- | `transition` | `object` | — | Route transition `{ duration }` |
804
- | `onError` | `function` | — | Error handler |
805
- | `onNavigate` | `function` | — | Navigation callback |
806
-
807
- Returns an instance with these methods:
808
-
809
- | Method | Description |
810
- |--------|-------------|
811
- | `start()` | Start routing and rendering |
812
- | `stop()` | Unmount components, close SSE connection, clean up |
813
- | `load(paths)` | Fetch `.rip` files from server into VFS |
814
- | `loadBundle(url)` | Fetch a JSON manifest and bulk-load all pages into VFS |
815
- | `watch(url, opts?)` | Connect to SSE hot-reload endpoint |
816
- | `go(path)` | Navigate to a route |
817
- | `addPage(path, source)` | Add a page to the VFS |
818
- | `get(key)` | Get app state value |
819
- | `set(key, value)` | Set app state value |
820
-
821
- Also exposes: `app` (stash), `fs` (VFS), `router`, `renderer`
822
-
823
- ### `stash(data)`
824
-
825
- Creates a deeply reactive proxy around `data`. Every property read is tracked,
826
- every write triggers effects.
827
-
828
- ### `effect(fn)`
829
-
830
- Creates a side effect that re-runs whenever its tracked dependencies change.
831
-
832
- ### `computed(fn)`
833
-
834
- Creates a lazy computed value that caches until dependencies change.
835
-
836
- ### `batch(fn)`
837
-
838
- Groups multiple state updates — effects only fire once at the end.
839
-
840
- ## Design Principles
841
-
842
- **No build step.** The compiler is small enough to ship. Source files are the
843
- deployment artifact.
844
-
845
- **Language-native reactivity.** `:=` for state, `~=` for computed, `~>` for
846
- effects. These are Rip language features, not framework APIs.
847
-
848
- **Fine-grained DOM updates.** No virtual DOM. Each reactive binding creates a
849
- direct effect that updates exactly the DOM nodes it touches.
850
-
851
- **Components are classes.** `component` produces an anonymous ES6 class.
852
- Methods, lifecycle hooks, and state are ordinary class members. No hooks API, no
853
- composition functions, no magic — just a class with a `render` method.
854
-
855
- **Props are instance variables.** `@count := 0` defines a reactive prop. The
856
- parent can set it, ignore it, or share a signal. The child owns it.
857
-
858
- **File-based everything.** Components live in the VFS. Routes map to VFS paths.
859
- Layouts are `_layout.rip` files in the directory tree. The file system is the
860
- API.
310
+ See `docs/demo.html` for a complete example — the full Rip UI Demo app
311
+ (6 components, router, reactive state, persistence) in 337 lines of
312
+ static HTML.
861
313
 
862
314
  ## License
863
315
 
864
316
  MIT
865
-
866
- ## Requirements
867
-
868
- - [Bun](https://bun.sh) runtime
869
- - `@rip-lang/api` ≥ 1.1.4 (for `@send` file serving used by `ripUI` middleware)
870
- - `rip-lang` ≥ 3.1.1 (Rip compiler with browser build)
871
-
872
- ## Links
873
-
874
- - [Rip Language](https://github.com/shreeve/rip-lang)
875
- - [@rip-lang/api](../api/README.md) — API framework (routing, middleware, `@send`)
876
- - [@rip-lang/server](../server/README.md) — Production server (HTTPS, mDNS, multi-worker)
877
- - [Report Issues](https://github.com/shreeve/rip-lang/issues)