rip-lang 3.15.4 → 3.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/README.md +6 -4
  2. package/bin/rip +167 -12
  3. package/docs/AGENTS.md +1 -1
  4. package/docs/RIP-APP.md +808 -0
  5. package/docs/RIP-DUCKDB.md +477 -0
  6. package/docs/RIP-INTRO.md +396 -0
  7. package/docs/RIP-LANG.md +59 -5
  8. package/docs/RIP-SCHEMA.md +191 -8
  9. package/docs/RIP-TYPES.md +74 -103
  10. package/docs/demo/README.md +4 -3
  11. package/docs/dist/rip.js +3627 -1470
  12. package/docs/dist/rip.min.js +671 -244
  13. package/docs/dist/rip.min.js.br +0 -0
  14. package/docs/example/index.json +7 -7
  15. package/docs/example/index.json.br +0 -0
  16. package/docs/extensions/duckdb/manifest.json +1 -1
  17. package/docs/extensions/duckdb/v1.5.2/linux_amd64/ripdb.duckdb_extension.gz +0 -0
  18. package/docs/extensions/duckdb/v1.5.2/osx_arm64/ripdb.duckdb_extension.gz +0 -0
  19. package/docs/extensions/vscode/print/index.html +2 -1
  20. package/docs/extensions/vscode/print/print-1.0.13.vsix +0 -0
  21. package/docs/extensions/vscode/print/print-1.0.14.vsix +0 -0
  22. package/docs/extensions/vscode/print/print-latest.vsix +0 -0
  23. package/docs/extensions/vscode/rip/rip-0.5.15.vsix +0 -0
  24. package/docs/extensions/vscode/rip/rip-latest.vsix +0 -0
  25. package/docs/ui/bundle.json +61 -0
  26. package/docs/ui/bundle.json.br +0 -0
  27. package/docs/ui/hljs-rip.js +0 -7
  28. package/docs/ui/index.css +66 -23
  29. package/docs/ui/index.html +6 -6
  30. package/package.json +9 -3
  31. package/rip-loader.js +64 -2
  32. package/src/AGENTS.md +63 -36
  33. package/src/browser.js +96 -14
  34. package/src/compiler.js +960 -143
  35. package/src/components.js +794 -88
  36. package/src/{types-emit.js → dts.js} +181 -71
  37. package/src/grammar/README.md +1 -1
  38. package/src/grammar/grammar.rip +111 -97
  39. package/src/lexer.js +132 -18
  40. package/src/parser.js +203 -205
  41. package/src/repl.js +74 -6
  42. package/src/schema/runtime-orm.js +168 -4
  43. package/src/schema/runtime-validate.js +146 -2
  44. package/src/schema/runtime.generated.js +314 -6
  45. package/src/schema/schema.js +5 -5
  46. package/src/sourcemaps.js +277 -1
  47. package/src/stdlib.js +253 -0
  48. package/src/typecheck.js +2023 -106
  49. package/src/types.js +127 -7
  50. package/docs/ui/accordion.rip +0 -103
  51. package/docs/ui/alert-dialog.rip +0 -53
  52. package/docs/ui/autocomplete.rip +0 -115
  53. package/docs/ui/avatar.rip +0 -37
  54. package/docs/ui/badge.rip +0 -15
  55. package/docs/ui/breadcrumb.rip +0 -47
  56. package/docs/ui/button-group.rip +0 -26
  57. package/docs/ui/button.rip +0 -23
  58. package/docs/ui/card.rip +0 -25
  59. package/docs/ui/carousel.rip +0 -110
  60. package/docs/ui/checkbox-group.rip +0 -61
  61. package/docs/ui/checkbox.rip +0 -33
  62. package/docs/ui/collapsible.rip +0 -50
  63. package/docs/ui/combobox.rip +0 -130
  64. package/docs/ui/context-menu.rip +0 -88
  65. package/docs/ui/date-picker.rip +0 -206
  66. package/docs/ui/dialog.rip +0 -60
  67. package/docs/ui/drawer.rip +0 -58
  68. package/docs/ui/editable-value.rip +0 -82
  69. package/docs/ui/field.rip +0 -53
  70. package/docs/ui/fieldset.rip +0 -22
  71. package/docs/ui/form.rip +0 -39
  72. package/docs/ui/grid.rip +0 -901
  73. package/docs/ui/input-group.rip +0 -28
  74. package/docs/ui/input.rip +0 -36
  75. package/docs/ui/label.rip +0 -16
  76. package/docs/ui/menu.rip +0 -134
  77. package/docs/ui/menubar.rip +0 -151
  78. package/docs/ui/meter.rip +0 -36
  79. package/docs/ui/multi-select.rip +0 -203
  80. package/docs/ui/native-select.rip +0 -33
  81. package/docs/ui/nav-menu.rip +0 -126
  82. package/docs/ui/number-field.rip +0 -162
  83. package/docs/ui/otp-field.rip +0 -89
  84. package/docs/ui/pagination.rip +0 -123
  85. package/docs/ui/popover.rip +0 -93
  86. package/docs/ui/preview-card.rip +0 -75
  87. package/docs/ui/progress.rip +0 -25
  88. package/docs/ui/radio-group.rip +0 -57
  89. package/docs/ui/resizable.rip +0 -123
  90. package/docs/ui/scroll-area.rip +0 -145
  91. package/docs/ui/select.rip +0 -151
  92. package/docs/ui/separator.rip +0 -17
  93. package/docs/ui/skeleton.rip +0 -22
  94. package/docs/ui/slider.rip +0 -165
  95. package/docs/ui/spinner.rip +0 -17
  96. package/docs/ui/table.rip +0 -27
  97. package/docs/ui/tabs.rip +0 -113
  98. package/docs/ui/textarea.rip +0 -48
  99. package/docs/ui/toast.rip +0 -87
  100. package/docs/ui/toggle-group.rip +0 -71
  101. package/docs/ui/toggle.rip +0 -24
  102. package/docs/ui/toolbar.rip +0 -38
  103. package/docs/ui/tooltip.rip +0 -85
  104. package/src/app.rip +0 -1571
  105. package/src/sourcemap-merge.js +0 -287
  106. /package/docs/demo/{components → routes}/_layout.rip +0 -0
  107. /package/docs/demo/{components → routes}/about.rip +0 -0
  108. /package/docs/demo/{components → routes}/card.rip +0 -0
  109. /package/docs/demo/{components → routes}/counter.rip +0 -0
  110. /package/docs/demo/{components → routes}/index.rip +0 -0
  111. /package/docs/demo/{components → routes}/todos.rip +0 -0
  112. /package/src/schema/{dts-emit.js → dts.js} +0 -0
@@ -0,0 +1,808 @@
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/shreeve/rip-lang/main/docs/assets/rip-schema-social.png" alt="Rip App" width="640">
3
+ </p>
4
+
5
+ # Rip App — Application Framework
6
+
7
+ > **The browser-side framework that ships with rip-lang. Stash, resource,
8
+ > timing, components store, file-based router, fine-grained renderer,
9
+ > orchestrated launch, and shared ARIA primitives — all in one bundle,
10
+ > no build step required.**
11
+
12
+ Rip App is to the browser what `@rip-lang/server` is to HTTP: a
13
+ batteries-included, opinionated application framework written in Rip
14
+ itself. It uses the language's compiler and reactive primitives the
15
+ same way any user's app would — Rip App doesn't extend the language,
16
+ it just exposes a coherent set of pieces (state, async data, routing,
17
+ rendering, lifecycle) on top of them.
18
+
19
+ If you've loaded `<script src="rip.min.js">` and called `app.launch(...)`,
20
+ you've used Rip App. This doc explains what's inside it, why it's
21
+ shaped the way it is, and how to use it well.
22
+
23
+ ---
24
+
25
+ # Contents
26
+
27
+ - [Rip App — Application Framework](#rip-app--application-framework)
28
+ - [Contents](#contents)
29
+ - [1. The four-layer architecture](#1-the-four-layer-architecture)
30
+ - [2. Quick start](#2-quick-start)
31
+ - [Real apps: bundles + file-based routing](#real-apps-bundles--file-based-routing)
32
+ - [3. The subsystems](#3-the-subsystems)
33
+ - [Stash](#stash)
34
+ - [createResource](#createresource)
35
+ - [Timing helpers](#timing-helpers)
36
+ - [Components store](#components-store)
37
+ - [createRouter](#createrouter)
38
+ - [createRenderer](#createrenderer)
39
+ - [launch](#launch)
40
+ - [ARIA helpers](#aria-helpers)
41
+ - [4. Lifecycle invariants](#4-lifecycle-invariants)
42
+ - [Component lifecycle order](#component-lifecycle-order)
43
+ - [User hooks](#user-hooks)
44
+ - [Effect ownership](#effect-ownership)
45
+ - [Effect cleanup-on-rerun](#effect-cleanup-on-rerun)
46
+ - [Parent chain (for context)](#parent-chain-for-context)
47
+ - [Layout and page parentage](#layout-and-page-parentage)
48
+ - [Factory blocks (for/if in render)](#factory-blocks-forif-in-render)
49
+ - [Keyed list reconciliation](#keyed-list-reconciliation)
50
+ - [5. Async effects](#5-async-effects)
51
+ - [6. Gotchas](#6-gotchas)
52
+ - [The bundle boundary matters](#the-bundle-boundary-matters)
53
+ - [Render-template name shadowing (fixed)](#render-template-name-shadowing-fixed)
54
+ - [Nested `for` loops can both name `i` (fixed)](#nested-for-loops-can-both-name-i-fixed)
55
+ - [Snapshot tests are brittle](#snapshot-tests-are-brittle)
56
+ - [No browser e2e tests](#no-browser-e2e-tests)
57
+ - [7. When NOT to use Rip App](#7-when-not-to-use-rip-app)
58
+
59
+ ---
60
+
61
+ ## 1. The four-layer architecture
62
+
63
+ Rip's framework code splits into four layers. Knowing which layer a
64
+ piece of code lives in tells you what it can assume about its
65
+ dependencies and what it owes its callers.
66
+
67
+ ```text
68
+ Layer 0 Language core src/compiler.js, src/lexer.js, src/parser.js,
69
+ (compiler + reactive src/types.js, src/dts.js, src/typecheck.js,
70
+ runtime + types + src/schema/, src/grammar/grammar.rip
71
+ schema runtime)
72
+
73
+ Layer 1 Component runtime src/components.js
74
+ (__Component class, - __pushComponent / __popComponent /
75
+ render-template codegen, __getCurrentComponent (the bridge)
76
+ __reconcile, __lis, - __reconcile (LIS-based keyed list update)
77
+ __transition, context) - render-template → DOM codegen
78
+
79
+ Layer 2 Rip App framework packages/app/index.rip
80
+ (this doc's subject) - Stash, Resource, Timing, Components store,
81
+ Router, Renderer, Launch, ARIA helpers
82
+
83
+ Layer 3 Headless widgets packages/ui/browser/components/*.rip
84
+ (ARIA-driven UI library) - 54+ widgets: Dialog, MultiSelect, DatePicker,
85
+ Combobox, Slider, ScrollArea, etc.
86
+ ```
87
+
88
+ Layer 2 (Rip App) depends on Layers 0 + 1 via two `globalThis`
89
+ bridges:
90
+
91
+ - `globalThis.__rip` — reactive primitives (`__state`, `__computed`,
92
+ `__effect`, `__batch`, `__getEffectSignal`).
93
+ - `globalThis.__ripComponent` — component machinery
94
+ (`__pushComponent`, `__popComponent`, `__getCurrentComponent`,
95
+ `setContext`, `getContext`, `hasContext`, `__Component`,
96
+ `__reconcile`, `__transition`, `__handleComponentError`, `__clsx`,
97
+ `__lis`).
98
+
99
+ The bridges are populated when the runtime is loaded (either via the
100
+ embedded preamble in standalone-compiled output, or via the framework
101
+ bundle's evaluation). Rip App reads them lazily so module load order
102
+ is irrelevant.
103
+
104
+ Rip App **does not extend the compiler**. Anything that compiles
105
+ without Rip App also compiles with Rip App; Rip App's value is purely
106
+ the user-land API it exposes.
107
+
108
+ ---
109
+
110
+ ## 2. Quick start
111
+
112
+ The smallest possible Rip App is one HTML file you can drop into any
113
+ static server (or open straight from disk):
114
+
115
+ ```html
116
+ <!doctype html>
117
+ <html>
118
+ <body>
119
+ <script defer
120
+ src="https://shreeve.github.io/rip-lang/dist/rip.min.js"
121
+ data-src=""
122
+ data-mount="App"></script>
123
+ <script type="text/rip">
124
+ App = component
125
+ @name := "world"
126
+ render
127
+ h1 "Hello, #{@name}!"
128
+ button @click: (=> @name = "Rip"), "Click me"
129
+ </script>
130
+ </body>
131
+ </html>
132
+ ```
133
+
134
+ Save as `index.html`, run `python3 -m http.server` (or any other
135
+ static server, or just double-click the file), and you're live.
136
+
137
+ What each attribute does:
138
+
139
+ - `defer` — wait for the document to parse so the inline
140
+ `<script type="text/rip">` block is in the DOM when the framework
141
+ runs.
142
+ - `src=...` — the framework bundle (compiler + reactive runtime +
143
+ Rip App). Use the GitHub Pages CDN URL above, or self-host a copy
144
+ of `dist/rip.min.js`.
145
+ - `data-src=""` — **explicitly empty**. Without this, the runtime
146
+ defaults to `GET /app` (the auto-bundle endpoint provided by
147
+ `@rip-lang/server`'s `serve` middleware). When you're not running
148
+ a Rip server, the empty string suppresses that fetch.
149
+ - `data-mount="App"` — name of the top-level component to mount.
150
+ Mounted onto `<body>` by default; pass `data-target="#app"` (or
151
+ any selector) to mount somewhere else.
152
+
153
+ The browser loads `rip.min.js`, the compiled framework + compiler
154
+ evaluate, all inline `<script type="text/rip">` blocks share scope
155
+ and compile in-browser, and the runtime calls `App.mount('body')`
156
+ for you. Clicking the button mutates `@name`; reactivity updates
157
+ only the `<h1>`'s text node.
158
+
159
+ A note on the `@` prefix: `@name := "world"` declares `name` as a
160
+ **prop** — the parent can pass `App name: "Rip"` to override the
161
+ default, and the component's own code reads/writes it via `@name`.
162
+ A bare `name := "world"` (no `@`) declares an internal-only state
163
+ that isn't exposed as a prop. The two are not interchangeable; if
164
+ you write `name := "world"` in the declaration but then `@name = ...`
165
+ in a handler, you'll be writing to a different binding than the one
166
+ your render reads. **Use `@` consistently for anything you want
167
+ both reactive and externally settable.**
168
+
169
+ ### Real apps: bundles + file-based routing
170
+
171
+ For multi-page apps you serve a bundle (a JSON map of paths → Rip
172
+ source) and let the runtime route between them. The
173
+ `@rip-lang/server` middleware emits the bundle for you; the browser
174
+ runtime auto-detects and calls `app.launch(...)` internally:
175
+
176
+ ```html
177
+ <script defer
178
+ src="/dist/rip.min.js"
179
+ data-src="/bundle.json"
180
+ data-router
181
+ data-persist="local"></script>
182
+ ```
183
+
184
+ The `data-router` attribute switches the runtime from "mount one
185
+ component" mode to "file-based router with renderer" mode. With a
186
+ bundle in hand, the runtime calls `launch({ bundle, hash, persist })`
187
+ and you get the full Rip App stack — stash, router, renderer, hot
188
+ reload — wired up automatically.
189
+
190
+ Direct `app.launch(opts)` is still public for advanced use (custom
191
+ target, custom error handling, multiple launches over the lifetime
192
+ of the page) but you rarely need it.
193
+
194
+ ---
195
+
196
+ ## 3. The subsystems
197
+
198
+ ### Stash
199
+
200
+ A deep reactive proxy with path navigation. Single-app state,
201
+ JSON-persistable, fine-grained signal subscription per key.
202
+
203
+ ```rip
204
+ app = createStash
205
+ user:
206
+ name: "Alice"
207
+ prefs:
208
+ theme: "dark"
209
+ todos: []
210
+
211
+ # Path-based access
212
+ app['user/prefs/theme'] # → 'dark'
213
+ app['user/prefs/theme'] = 'light'
214
+
215
+ # Reserved methods (not properties — atomic ops)
216
+ app.inc 'todos/length' # increment counter
217
+ app.flip 'user/prefs/expanded' # toggle boolean
218
+ app.has 'user/email' # → false
219
+ app.del 'user/prefs/theme' # remove
220
+ app.keys 'user' # → ['name', 'prefs']
221
+ app.join 'user', email: "..." # shallow merge
222
+
223
+ # Use raw object underneath
224
+ plain = unwrapStash(app) # back to a plain JS object
225
+ ```
226
+
227
+ **Single-stash policy**: Rip App assumes one stash per page (the one
228
+ `launch()` creates as `app.data`). `persistStash` and the
229
+ beforeunload-flush mechanism both rely on this. Apps needing
230
+ isolated state silos should use plain `__state(...)` signals or
231
+ namespace under different keys on `app.data`.
232
+
233
+ ### createResource
234
+
235
+ Async data with race protection, abort support, and reactive loading
236
+ state.
237
+
238
+ ```rip
239
+ user = createResource ->
240
+ signal = getEffectSignal() # capture BEFORE await; aborts on dispose
241
+ res = fetch! "/api/users/#{userId}", { signal }
242
+ res.json!
243
+
244
+ # Reactive consumers see loading/data/error transitions
245
+ ~> if user.loading
246
+ "Loading..."
247
+ else if user.error
248
+ "Error: #{user.error.message}"
249
+ else
250
+ user.data?.name
251
+
252
+ user.refetch! # rejected promises rethrow; awaiters see them
253
+ user.dispose() # cancels in-flight, clears state
254
+ ```
255
+
256
+ Race protection: each refetch increments a generation counter. Old
257
+ responses that resolve after a newer fetch is in flight are dropped.
258
+ AbortController signal is passed to `fn` and aborted on
259
+ dispose/refetch.
260
+
261
+ ### Timing helpers
262
+
263
+ `delay`, `debounce`, `throttle`, `hold` — all reactive, all return a
264
+ disposable signal-like object.
265
+
266
+ ```rip
267
+ showLoading := delay 200 -> loading # truthy waits 200ms, falsy immediate
268
+ debouncedQuery := debounce 300 -> query # propagates 300ms after last change
269
+ smoothScroll := throttle 100 -> scrollY # at most once per 100ms
270
+ showSaved := hold 2000 -> saved # once truthy, stays true ≥ 2000ms
271
+
272
+ # All return objects with .value, .read(), .dispose()
273
+ debouncedQuery.dispose()
274
+ ```
275
+
276
+ The `.dispose()` is auto-called when the enclosing component unmounts
277
+ (via the `__getCurrentComponent` bridge). Manual disposal is only
278
+ needed when used outside a component.
279
+
280
+ ### Components store
281
+
282
+ In-memory virtual filesystem of `.rip` source files with hot-reload
283
+ watchers. The renderer uses it; you rarely touch it directly.
284
+
285
+ ```rip
286
+ store = createComponents()
287
+ store.write 'components/Card.rip', source
288
+ store.read 'components/Card.rip'
289
+ store.list 'components'
290
+ store.watch (event, path) -> ... # 'create' | 'change' | 'delete'
291
+ unwatch = store.watch (...) -> ...
292
+ unwatch() # idempotent disposer
293
+ ```
294
+
295
+ ### createRouter
296
+
297
+ File-system routing with base prefix, query/hash preservation, hash
298
+ mode, error callback, and nav-callback hooks.
299
+
300
+ ```rip
301
+ router = createRouter components,
302
+ root: 'components' # root directory in the store
303
+ base: '/admin' # path prefix (e.g. for sub-app deployment)
304
+ hash: false # hash-mode routing
305
+ onError: (err) ->
306
+ console.error "Routing error: #{err.status} #{err.path}"
307
+
308
+ router.push '/users/42?tab=settings'
309
+ router.replace '/login'
310
+ router.push '/cart', noScroll: true # don't reset scroll on this nav
311
+ router.back()
312
+ router.forward()
313
+
314
+ # Reactive properties (each is its own signal)
315
+ router.path # '/users/42'
316
+ router.params # { id: '42' }
317
+ router.route # { file, regex, pattern }
318
+ router.layouts # ['_layout.rip', 'users/_layout.rip']
319
+ router.query # { tab: 'settings' }
320
+ router.hash # ''
321
+ router.navigating # true while resolve() in flight (200ms delay)
322
+
323
+ # router.current is a single __computed (one subscription, not 6)
324
+ ~> info = router.current
325
+ mountThePage(info) if info.route
326
+
327
+ # Subscribe to nav events explicitly
328
+ unwatch = router.onNavigate (current) -> log current.path
329
+ unwatch()
330
+ ```
331
+
332
+ The renderer uses `router.current` to drive its mount effect. Each
333
+ field is also a separate signal so subscribers can track only what
334
+ they care about.
335
+
336
+ #### Anchor opt-outs and active-link styling
337
+
338
+ The router intercepts plain `<a>` clicks at the document level. Two
339
+ per-anchor attributes adjust that behavior:
340
+
341
+ | Attribute | Effect |
342
+ | ----------------------- | ------------------------------------------------------------------------------------- |
343
+ | `data-router-ignore` | Skip SPA interception entirely. The browser performs a full navigation. |
344
+ | `data-router-noscroll` | Take the SPA navigation, but don't reset scroll to `(0, 0)`. |
345
+
346
+ Anchors with `target="_blank"`, `[download]`, cross-origin hrefs, or
347
+ hrefs outside `base` are also skipped automatically.
348
+
349
+ **Active-link highlighting.** On every navigation the router walks
350
+ in-document anchors and sets `aria-current` on those that match the
351
+ current path:
352
+
353
+ - exact match → `aria-current="page"`
354
+ - prefix match (`/blog` on `/blog/123`) → `aria-current="true"`
355
+ - otherwise → attribute removed (only if the router set it)
356
+
357
+ Style it with attribute selectors — no per-link boilerplate needed:
358
+
359
+ ```css
360
+ nav a[aria-current="page"] { color: red; font-weight: bold; }
361
+ nav a[aria-current="true"] { color: red; }
362
+ ```
363
+
364
+ Setting `aria-current` manually on an anchor wins — the router only
365
+ touches values it set itself.
366
+
367
+ **Scroll restoration.** New navigations (`push` or a link click)
368
+ reset scroll to `(0, 0)`. Back/forward (`popstate`) restores the
369
+ scroll position the page had when you left it. Same-document fragment
370
+ links (`#section`) defer to the browser.
371
+
372
+ **Typed routes (compile-time).** In a typed project (one with
373
+ `rip.strict: true` or `::` annotations), `rip check` synthesizes a
374
+ `__RipRoutes` union from the file tree under `app/routes/` and
375
+ threads it through three places:
376
+
377
+ | Place | Type | Catches |
378
+ | ---------------------------------- | --------------------------------------------------------------------- | ---------------------------------- |
379
+ | `<a href: "...">` in render blocks | `__RipRoutes` for `/`-prefixed literals; any string otherwise | Typos in known routes |
380
+ | `router.push url, opts?` | `__RipRoutes` (replaces base `string`) | Typos in programmatic navigation |
381
+ | `@params` in `routes/[id].rip` | `{ id: string }` (replaces `Record<string, string>`) | Typos like `@params.bogus` |
382
+
383
+ Anchor `href` uses a `const`-generic conditional: a literal starting
384
+ with `/` must satisfy `__RipRoutes`, while external schemes
385
+ (`https://`, `mailto:`, `tel:`), fragments (`#anchor`), and dynamic
386
+ `string` values fall through unchecked. Typos like `<a href: "/crat">`
387
+ produce a single-line error naming the valid routes.
388
+ `router.replace` is deliberately left at `string` — it's commonly
389
+ used to mutate the current URL with query strings, where the built
390
+ value can't satisfy a literal-route union. Catch-all routes
391
+ (`[...rest].rip`) are excluded from `__RipRoutes` — they're runtime
392
+ 404 fallbacks, not navigation targets, so including them as
393
+ `/${string}` would defeat typo-catching for every other route.
394
+
395
+ ### createRenderer
396
+
397
+ The render loop. Subscribes to `router.current`, mounts/unmounts
398
+ page + layout components, manages the lifecycle. You rarely call this
399
+ directly — `launch()` does it for you — but the API is public.
400
+
401
+ ```rip
402
+ renderer = createRenderer
403
+ router: router
404
+ app: app
405
+ components: components
406
+ resolver: resolver
407
+ compile: compile # the rip-lang compileToJS function
408
+ target: '#app'
409
+ onError: (err) -> console.error err
410
+
411
+ renderer.start() # idempotent — second call is a no-op
412
+ renderer.remount() # re-mount the current route
413
+ renderer.remount(true) # force a full unmount + remount (hot reload)
414
+ renderer.stop() # tears down lifecycle, revokes blob URLs
415
+ ```
416
+
417
+ ### launch
418
+
419
+ The orchestrator. Single entry point that wires bundle → stash →
420
+ components → resolver → router → renderer → lifecycle.
421
+
422
+ ```rip
423
+ result = await app.launch
424
+ bundle: { components: {...}, routes: {...}, data: {...} }
425
+ base: '/admin' # optional URL prefix
426
+ target: '#app' # optional mount target
427
+ persist: 'local' # optional: 'local' | 'session' | true | false
428
+ hash: false # optional: hash-mode routing
429
+
430
+ # Returns
431
+ result.app # the stash proxy
432
+ result.components # the components store
433
+ result.router # the router
434
+ result.renderer # the renderer
435
+ result.destroy() # teardown — symmetric to launch
436
+ ```
437
+
438
+ **`launch` is single-arg**: just `launch(opts)`. Earlier polymorphic
439
+ forms like `launch(base, opts)` are gone.
440
+
441
+ `destroy()` is idempotent and cleans up everything: closes the SSE
442
+ hot-reload watch, stops the renderer, destroys the router, disposes
443
+ the persist stash, deletes the resolver classes key from globalThis,
444
+ clears `globalThis.__ripApp` / `__ripLaunched` / `window.app` /
445
+ `window.__RIP__`. After `destroy()`, you can `launch(...)` again with
446
+ a different config.
447
+
448
+ ### ARIA helpers
449
+
450
+ Shared keyboard/popup/focus primitives used by Rip UI widgets.
451
+ Registered on `globalThis.__aria` and `globalThis.ARIA` when the
452
+ framework bundle evaluates. Available globally in any component
453
+ without imports.
454
+
455
+ ```rip
456
+ ARIA.listNav e, h # popup list nav (ArrowDown/Up, Enter, Escape, Home/End, typeahead)
457
+ ARIA.rovingNav e, h, orient # inline composite nav (radiogroup, tabs, toolbar)
458
+ ARIA.popupDismiss open, popup, close, els, repos
459
+ ARIA.popupGuard delay # per-component reopen suppression after pointer-driven closes
460
+ ARIA.bindPopover open, popover, setOpen, source
461
+ ARIA.bindDialog open, dialog, setOpen, dismissable
462
+ ARIA.position trigger, floating, opts # CSS anchor positioning with fallback
463
+ ARIA.positionBelow trigger, popup, gap, setVisible
464
+ ARIA.trapFocus panel # Tab wraps first↔last
465
+ ARIA.wireAria panel, id # auto-label panel from heading + paragraph
466
+ ARIA.lockScroll inst # body scroll lock with stack management
467
+ ARIA.unlockScroll inst
468
+ ARIA.combine ...disposers # fold N disposers into one
469
+ ARIA.hasAnchor() # feature-detect CSS anchor positioning
470
+ ```
471
+
472
+ The `bindPopover`, `bindDialog`, and `popupDismiss` helpers are
473
+ **idempotent at the element level** — calling them repeatedly (as
474
+ happens when an enclosing `~>` effect re-runs) doesn't accumulate
475
+ listeners. Each call removes any prior listener it had attached
476
+ before adding a new one. This was earned the hard way; see
477
+ [Gotchas](#6-gotchas) below.
478
+
479
+ ---
480
+
481
+ ## 4. Lifecycle invariants
482
+
483
+ These are the contracts the framework upholds. Reading these is
484
+ faster than tracing through 13 commits' worth of fixes.
485
+
486
+ ### Component lifecycle order
487
+
488
+ ```text
489
+ constructor (props) ← user code receives initial props
490
+ └─ _init(props) ← @state, @computed, top-level ~> effects all wire here
491
+ __pushComponent(this) wraps this call
492
+ → _parent established (set-once)
493
+ → effects auto-register on this._disposers
494
+
495
+ mount(target) ← only the renderer calls this directly
496
+ └─ __pushComponent(this)
497
+ _create() ← DOM tree construction; reactive bindings + child components
498
+ Per-child push wrap: each child's _create runs with
499
+ child as current, so the child's reactive bindings
500
+ register on child._disposers, not parent's.
501
+ beforeMount() ← user hook; signals/state ready, DOM not yet in tree
502
+ effects created here auto-register on this component
503
+ _setup() ← post-creation effects (rare; most go in _init)
504
+ mounted() ← user hook; DOM is in the tree now
505
+ __popComponent
506
+
507
+ [ ... reactive updates happen here, ad infinitum ... ]
508
+
509
+ unmount({ removeDOM = true }) ← idempotent (_unmounted flag short-circuits second calls)
510
+ └─ beforeUnmount() ← user hook; signals/effects still live
511
+ children.forEach unmount ← cascade BEFORE this instance's disposers
512
+ _disposers.forEach run ← effect cleanup fires
513
+ unmounted() ← user hook; final notification
514
+ DOM removal (if requested)
515
+ ```
516
+
517
+ ### User hooks
518
+
519
+ The framework recognizes these hook names on any component. All are
520
+ optional; the runtime calls each only if defined.
521
+
522
+ | Hook | When it fires | Notes |
523
+ | --------------- | ---------------------------------------------------------- | -------------------------------------------------------------------------------- |
524
+ | `beforeMount` | After `_create`, before DOM is attached | Effects created here auto-register on the component |
525
+ | `mounted` | After DOM attached | Runs once per visit |
526
+ | `beforeUnmount` | Before children unmount and disposers fire | Signals/effects still live |
527
+ | `unmounted` | After disposers fire and DOM is removed | Final notification; runs once per visit |
528
+ | `onError` | A throw escapes any component method (render, hook, event) | Receives `{ status?, message?, error?, path? }`; the renderer walks the layout chain to find the nearest defining component |
529
+
530
+
531
+ ### Effect ownership
532
+
533
+ Every `__effect(fn)` call automatically registers its disposer with
534
+ the **currently-pushed component** at the time of the call (via
535
+ `globalThis.__ripComponent.__getCurrentComponent`). On
536
+ `component.unmount()`, all registered disposers fire. This is the
537
+ mechanism that makes `~> ARIA.bindPopover(...)` work without a
538
+ per-widget `beforeUnmount` hook.
539
+
540
+ Two exceptions:
541
+
542
+ 1. Effects created by **factory blocks** (`for`/`if` in render
543
+ templates) opt out of auto-registration via
544
+ `__effect(fn, {skipRegister: true})`. Their disposers live in the
545
+ factory's local `disposers` array and are called by `d(detaching)`
546
+ when the block is removed.
547
+ 2. Top-level effects created outside any component context have no
548
+ parent to register with; the disposer must be called manually.
549
+
550
+ ### Effect cleanup-on-rerun
551
+
552
+ If a `~>` body returns a function, that function becomes the effect's
553
+ cleanup. It fires:
554
+
555
+ 1. Before each re-run, just after the new run is about to start.
556
+ 2. On `effect.dispose()` (which fires when the owning component
557
+ unmounts).
558
+
559
+ For sync bodies, this is straightforward. For async bodies (the body
560
+ returns a Promise), the cleanup function still works, but with two
561
+ extra guards in place:
562
+
563
+ - A per-run `runId` counter discards async resolutions from a
564
+ superseded run. If the effect re-ran while the prior body was
565
+ awaiting, the prior body's eventual resolution is run-and-discarded
566
+ immediately, never installed on the now-current run's `_cleanup`.
567
+ - The effect's `AbortSignal` is aborted on every re-run / dispose.
568
+ User code that captured the signal via `getEffectSignal()` and
569
+ passed it to `fetch` / `setTimeout` etc. sees `AbortError` and
570
+ unwinds cleanly.
571
+
572
+ ### Parent chain (for context)
573
+
574
+ `component._parent` is **set-once** during the first
575
+ `__pushComponent` that has a non-self predecessor. Subsequent pushes
576
+ preserve the construction-time chain. This keeps `getContext` /
577
+ `hasContext` / `__handleComponentError` walking up the tree
578
+ correctly even after the same component is re-pushed for its own
579
+ mount or factory re-entry.
580
+
581
+ The graph-traversal sites (`getContext`, `hasContext`,
582
+ `__handleComponentError`) all carry a `visited` Set as
583
+ defense-in-depth against any future bug that corrupts the chain. A
584
+ self-cycle in `_parent` no longer hangs the runtime; it's just an
585
+ early termination.
586
+
587
+ ### Layout and page parentage
588
+
589
+ The renderer instantiates layouts in order, threading each as the
590
+ parent for the next, so an outer layout's `setContext` is visible to
591
+ inner layouts and to the page via `getContext`. The page is parented
592
+ to the innermost layout. Construction-time parent chain survives the
593
+ mount-time re-pushes.
594
+
595
+ ### Factory blocks (for/if in render)
596
+
597
+ Factory blocks own their child component instances exclusively in a
598
+ local `_factoryChildren` array. When the block is detached
599
+ (`d(detaching)`), each child has `unmount({removeDOM: false})`
600
+ called. Child instances are NOT pushed onto the parent's `_children`
601
+ — that array would otherwise grow unboundedly under loop churn
602
+ (every removed iteration would leave a stale ref).
603
+
604
+ Conditional / loop reactive effects in **class mode** (top-level
605
+ component) push a manual disposer onto the parent's `_disposers`
606
+ that calls `currentBlock.d(true)` on unmount. Without this, parent
607
+ unmount would dispose the effect (preventing future re-runs) but
608
+ leave the current block alive — its DOM, signal subscriptions, and
609
+ child components all leaked.
610
+
611
+ ### Keyed list reconciliation
612
+
613
+ `__reconcile` reuses blocks across renders when their keys match.
614
+ Phase-1 prefix scan calls `p()` (the per-render update) on the reused
615
+ block ONLY if the underlying item reference changed (even if the key
616
+ is the same). With a custom `keyFn` like `(item) -> item.id`, this
617
+ catches the case where stable keys map to mutated item content (an
618
+ item was replaced wholesale but the id stayed the same).
619
+
620
+ ---
621
+
622
+ ## 5. Async effects
623
+
624
+ For `~>` bodies that use `await`, capture the effect's
625
+ `AbortSignal` BEFORE any await, then pass it through to anything
626
+ that supports it (fetch, setTimeout via `AbortSignal.timeout`, etc.):
627
+
628
+ ```rip
629
+ ~>
630
+ signal = getEffectSignal() # capture before await
631
+ return unless @userId
632
+ res = fetch! "/api/users/#{@userId}", {signal}
633
+ return if signal.aborted # bail if disposed mid-flight
634
+ data = res.json!
635
+ return if signal.aborted
636
+ @user = data
637
+ ```
638
+
639
+ `getEffectSignal()` returns the AbortSignal of the currently-running
640
+ effect, or `null` if called outside an effect or before
641
+ `AbortController` is available. The signal is aborted whenever the
642
+ effect re-runs (a dependency changed) or is disposed (the owning
643
+ component is unmounting).
644
+
645
+ If you don't capture the signal, the await still runs to completion,
646
+ but the effect's cleanup-via-return-function still works — and any
647
+ post-await mutation might write to a destroyed component. The signal
648
+ is the cleanest way to bail early.
649
+
650
+ ---
651
+
652
+ ## 6. Gotchas
653
+
654
+ Things that have bitten us before. Each is documented at its
655
+ in-source enforcement site too, but here's the unified list.
656
+
657
+ ### The bundle boundary matters
658
+
659
+ The framework bundle (`docs/dist/rip.min.js`) loads ONCE at app
660
+ startup. Anything inside it evaluates exactly once and registers its
661
+ globals (`__rip`, `__ripComponent`, `__aria`, etc.) for the page
662
+ lifetime. The widget bundle (`docs/ui/bundle.json`) loads on-demand
663
+ when a widget is referenced; each widget compiles separately.
664
+
665
+ **Do not move framework-level helpers into the widget bundle.** ARIA
666
+ helpers were once moved to `packages/ui/browser/components/_aria.rip`
667
+ to "decouple" them. Two things broke immediately:
668
+
669
+ 1. `globalThis.ARIA` was undefined when widgets evaluated — the
670
+ widget bundle is module-graph-driven; nothing imported `_aria.rip`
671
+ so it never loaded.
672
+ 2. We added `import './_aria.rip'` to 21 widgets to compensate. The
673
+ per-widget import was fragile (a new widget contributor could
674
+ forget) and didn't actually save bytes (it just shifted them
675
+ between bundles).
676
+
677
+ We reverted. ARIA stays in `packages/app/index.rip` because *that
678
+ file is part of the framework bundle* — guaranteed-once evaluation,
679
+ guaranteed-globally-available. If you want to refactor a primitive
680
+ out of `index.rip`, it can move to a sibling file in `packages/app/`
681
+ **only if `scripts/build.js` is updated to include it in the
682
+ framework bundle**.
683
+
684
+ ### Render-template name shadowing (fixed)
685
+
686
+ Earlier versions of the compiler treated a lowercase identifier as
687
+ an HTML tag *even when a local of the same name was in lexical
688
+ scope*. Writing `code = ex.body` then `span code` either silently
689
+ mis-routed the reference or emitted a stray `<code>` element. The
690
+ rule now matches every other lexically-scoped language: a render-
691
+ scope local shadows the HTML tag with the same name.
692
+
693
+ ```rip
694
+ # Both of these now do what they look like — `code` is read as a value,
695
+ # not interpreted as the <code> HTML tag.
696
+ for ex in examples
697
+ code = if ex.curl? then buildCurl(ex) else ex.code
698
+ CodeBlock label: ex.label, code: code
699
+
700
+ for code in examples
701
+ span code # → <span>{code}</span>
702
+ ```
703
+
704
+ Bindings introduced by `name = expr` and loop variables introduced
705
+ by `for x in ...` / `for x, i in ...` are both treated as lexical
706
+ locals. The shadowing only resolves within the same block factory
707
+ (loop body, conditional branch) — render locals do not currently
708
+ thread across factory boundaries the way loop variables do.
709
+
710
+ Render bindings are creation-time captures, not reactive computeds.
711
+ `code = ex.body` evaluates once when the block is built and never
712
+ re-runs, mirroring the existing semantics of `span ex.body` (also
713
+ one-shot). If you need the value to track changes, lift the
714
+ expression to a class-level `:=` / `~=` member, or read the reactive
715
+ source directly inside the DOM expression.
716
+
717
+ ### Nested `for` loops can both name `i` (fixed)
718
+
719
+ The outer `for item in items` no longer auto-allocates `i` and then
720
+ collides with an inner `for v, i in ...`. The compiler now pre-scans
721
+ the loop body for explicit descendant index names and skips any name
722
+ that would clash, falling back to a mangled internal name only if
723
+ every conventional letter is taken. The patch function's parameter
724
+ list stays unique at any nesting depth.
725
+
726
+ ```rip
727
+ # All of these now compile cleanly:
728
+ for item in items
729
+ for v, i in item.options
730
+ span "#{v}@#{i}"
731
+
732
+ for item in items
733
+ for group in item.groups
734
+ for v, i in group.values
735
+ span "#{v}@#{i}"
736
+ ```
737
+
738
+ (User-explicit duplicates — e.g. `for x, i in xs / for y, i in ys`
739
+ where the same `i` is bound at two nesting levels — are still a
740
+ strict-mode error. That's a real name conflict in the user's source,
741
+ not something the compiler should silently rewrite.)
742
+
743
+ ### Snapshot tests are brittle
744
+
745
+ The compiler's codegen is exercised by ~50 `code "..."` snapshot
746
+ tests in `test/rip/components.rip`. Any codegen change (we did 19
747
+ snapshot updates in Wave 8a, 11 in Wave 11) requires updating those
748
+ expected outputs. The pattern of "compile this Rip → expect this
749
+ exact JS" is fragile but catches accidental codegen regressions.
750
+
751
+ When you change codegen, expect snapshot churn. Use the snapshot
752
+ auto-update script (`/tmp/update-snapshots.mjs` from the wave 8a-12
753
+ sessions) only after **manually verifying the diff is mechanical**
754
+ (your intentional change, not a behavior regression).
755
+
756
+ ### No browser e2e tests
757
+
758
+ The unit test suite (`bun run test`) covers compiler codegen,
759
+ runtime semantics, schema, and server behaviors — but does NOT load
760
+ `rip.min.js` in a browser-like environment and verify the framework
761
+ runs end-to-end. We rely on:
762
+
763
+ - Snapshot tests catching codegen regressions.
764
+ - Runtime unit tests catching reactive/lifecycle regressions.
765
+ - `scripts/check-bundle-graph.js` catching bundle-composition
766
+ regressions.
767
+ - Manual verification (loading a real app) for end-to-end behavior.
768
+
769
+ This is a real coverage gap. Refactors that touch the bundle
770
+ composition or the browser entry point should be smoke-tested by
771
+ loading an actual app.
772
+
773
+ ---
774
+
775
+ ## 7. When NOT to use Rip App
776
+
777
+ Honest list of where Rip App is the wrong tool:
778
+
779
+ - **Server-side rendering (SSR) or streaming.** Rip App is
780
+ browser-first by design. There's no `renderToString`, no hydration
781
+ protocol, no resumability. If your project's primary requirement
782
+ is SEO-friendly server rendering, use a framework that has SSR as
783
+ a core concern (Next.js, SvelteKit, Nuxt, SolidStart).
784
+ - **Multi-team scale.** The render DSL is unconventional enough that
785
+ a large team will keep tripping on the conceptual model (block
786
+ factories, fine-grained reactivity, the `@`/`:=`/`~=` keyword
787
+ family) even after the historical tag-name and loop-index
788
+ footguns are gone. Rip App is happiest with a small focused team
789
+ that fits the framework's mental model in one head.
790
+ - **Plugin-ecosystem-dependent apps.** If your roadmap depends on
791
+ "there's a library for that" — auth, charts, maps, file uploads,
792
+ rich-text editing — the npm ecosystem around React/Vue is
793
+ dramatically larger. Rip's first-party packages cover the basics;
794
+ they don't cover everything.
795
+ - **TypeScript-strict shops.** Rip has its own type system with
796
+ growing capability, but it's not yet at the maturity of TypeScript
797
+ + a major framework's `@types/*`. If your team's correctness story
798
+ is "TypeScript catches it," Rip is a step laterally, not forward.
799
+ - **Multiple isolated app instances on one page.** The single-stash
800
+ / single-launch / single-`globalThis.__ripApp` model assumes one
801
+ app per page. Multi-app embedding is possible but you'd be
802
+ fighting the design.
803
+
804
+ For everything else — single-team browser apps, internal tools,
805
+ dashboards, documentation sites, demos, weekend projects, hobbyist
806
+ work — Rip App is genuinely competitive. The "no build step,
807
+ batteries included, drop in a script tag" pitch is real, and the
808
+ framework's lifecycle + reactive model is honestly solid.