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
package/src/app.rip DELETED
@@ -1,1571 +0,0 @@
1
- # ==============================================================================
2
- # Rip App — application framework for Rip
3
- #
4
- # Provides the runtime infrastructure for building reactive web applications:
5
- #
6
- # Stash — deep reactive proxy with path navigation
7
- # Resource — async data loading with reactive states
8
- # Timing — delay, debounce, throttle, hold
9
- # Components — in-memory component store with file watchers
10
- # Router — file-based URL routing with layouts and params
11
- # Renderer — compile-import-mount pipeline with error boundaries
12
- # Launch — orchestrator that wires everything together
13
- #
14
- # The reactive primitives (__state, __effect, __batch) are provided by the
15
- # Rip compiler runtime and registered on globalThis when the bundle loads.
16
- #
17
- # Author: Steve Shreeve <steve.shreeve@gmail.com>
18
- # Date: February 2026
19
- # ==============================================================================
20
-
21
- # Rip's reactive primitives (registered on globalThis by rip.js)
22
- { __state, __effect, __batch } = globalThis.__rip
23
-
24
- # Re-export context functions from the component runtime
25
- { setContext, getContext, hasContext } = globalThis.__ripComponent or {}
26
- export { setContext, getContext, hasContext }
27
-
28
- # ==============================================================================
29
- # Stash — deep reactive proxy with path navigation
30
- #
31
- # Wraps a plain object in a Proxy that lazily creates fine-grained signals
32
- # for each property. Nested objects are wrapped recursively. Supports path-
33
- # based bracket access (e.g., app['data/theme/title']) and 7 reserved methods
34
- # for atomic/structural operations: inc, dec, flip, join, keys, has, del.
35
- # ==============================================================================
36
-
37
- PROXIES = new WeakMap()
38
- METHODS = new WeakMap()
39
-
40
- _keysVersion = 0
41
- _writeVersion = __state(0)
42
- _depth = 0
43
-
44
- isPathKey = (prop) -> typeof prop is 'string' and (prop.indexOf('.') isnt -1 or prop.indexOf('/') isnt -1 or prop.indexOf('[') isnt -1)
45
-
46
- isNum = (val) -> /^-?\d+$/.test val
47
-
48
- getSignal = (target, prop) ->
49
- unless target[:signals]
50
- Object.defineProperty target, :signals, { value: new Map(), enumerable: false }
51
- sig = target[:signals].get(prop)
52
- unless sig
53
- sig = __state(target[prop])
54
- target[:signals].set(prop, sig)
55
- sig
56
-
57
- keysSignal = (target) -> getSignal(target, :keys)
58
-
59
- wrapDeep = (value) ->
60
- return value unless value? and typeof value is 'object'
61
- return value if value[:stash]
62
- return value if value instanceof Date or value instanceof RegExp or value instanceof Map or value instanceof Set or value instanceof Promise
63
- existing = PROXIES.get(value)
64
- return existing if existing
65
- makeProxy(value)
66
-
67
- makeProxy = (target) ->
68
- proxy = null
69
- handler =
70
- get: (target, prop) ->
71
- return true if prop is :stash
72
- return target if prop is :raw
73
- return Reflect.get(target, prop) if typeof prop is 'symbol'
74
-
75
- if prop is 'length' and Array.isArray(target)
76
- keysSignal(target).value
77
- return target.length
78
-
79
- if not _depth and isPathKey(prop)
80
- return stashGet(proxy, prop)
81
-
82
- fn = stashMethodFn(proxy, prop)
83
- return fn if fn
84
-
85
- sig = getSignal(target, prop)
86
- val = sig.value
87
-
88
- return wrapDeep(val) if val? and typeof val is 'object'
89
- val
90
-
91
- set: (target, prop, value) ->
92
- if not _depth and isPathKey(prop)
93
- stashSet(proxy, prop, value)
94
- return true
95
-
96
- old = target[prop]
97
- r = if value?[:raw] then value[:raw] else value
98
- return true if r is old
99
- target[prop] = r
100
-
101
- if target[:signals]?.has(prop)
102
- target[:signals].get(prop).value = r
103
- if old is undefined and r isnt undefined
104
- keysSignal(target).value = ++_keysVersion
105
- _writeVersion.value++
106
-
107
- true
108
-
109
- deleteProperty: (target, prop) ->
110
- delete target[prop]
111
- sig = target[:signals]?.get(prop)
112
- sig?.value = undefined
113
- keysSignal(target).value = ++_keysVersion
114
- _writeVersion.value++
115
- true
116
-
117
- ownKeys: (target) ->
118
- keysSignal(target).value
119
- Reflect.ownKeys(target)
120
-
121
- proxy = new Proxy(target, handler)
122
- PROXIES.set(target, proxy)
123
- proxy
124
-
125
- # Path navigation
126
- PATH_RE = /([./][^./\[\s]+|\[[-+]?\d+\]|\[(?:"[^"]+"|'[^']+')\])/
127
-
128
- walk = (path) ->
129
- list = ('.' + path).split(PATH_RE)
130
- list.shift()
131
- result = []
132
- i = 0
133
- while i < list.length
134
- part = list[i]
135
- chr = part[0]
136
- if chr is '.' or chr is '/'
137
- result.push part.slice(1)
138
- else if chr is '['
139
- if part[1] is '"' or part[1] is "'"
140
- result.push part.slice(2, -2)
141
- else
142
- result.push +(part.slice(1, -1))
143
- i += 2
144
- result
145
-
146
- resolveIndex = (seg, obj) ->
147
- if typeof seg is 'number' and seg < 0
148
- t = raw(obj)
149
- return t.length + seg if Array.isArray(t)
150
- seg
151
-
152
- stashGet = (proxy, path) ->
153
- segs = walk(path)
154
- obj = proxy
155
- _depth++
156
- try
157
- for seg in segs
158
- return undefined unless obj?
159
- obj = obj[resolveIndex(seg, obj)]
160
- obj
161
- finally
162
- _depth--
163
-
164
- stashSet = (proxy, path, value) ->
165
- segs = walk(path)
166
- obj = proxy
167
- _depth++
168
- try
169
- for seg, i in segs
170
- key = resolveIndex(seg, obj)
171
- if i is segs.length - 1
172
- obj[key] = value
173
- else
174
- unless obj[key]?
175
- nextSeg = segs[i + 1]
176
- obj[key] = if typeof nextSeg is 'number' or isNum(nextSeg) then [] else {}
177
- obj = obj[key]
178
- value
179
- finally
180
- _depth--
181
-
182
- # Stash methods — reserved names for atomic/structural operations
183
- STASH_METHOD_NAMES = { inc: true, dec: true, flip: true, join: true, keys: true, has: true, del: true }
184
-
185
- stashMethodFn = (proxy, prop) ->
186
- return undefined unless STASH_METHOD_NAMES[prop]
187
- cache = METHODS.get(proxy)
188
- unless cache
189
- cache = {}
190
- METHODS.set(proxy, cache)
191
- return cache[prop] if cache[prop]
192
- fn = switch prop
193
- when 'inc' then (path, step = 1) -> cur = stashGet(proxy, path) ?? 0; stashSet(proxy, path, cur + step)
194
- when 'dec' then (path, step = 1) -> cur = stashGet(proxy, path) ?? 0; stashSet(proxy, path, cur - step)
195
- when 'flip' then (path) -> cur = stashGet(proxy, path) ?? false; stashSet(proxy, path, not cur)
196
- when 'join' then (path, obj) ->
197
- __batch ->
198
- target = stashGet(proxy, path)
199
- unless target? and typeof target is 'object'
200
- stashSet(proxy, path, {})
201
- target = stashGet(proxy, path)
202
- _depth++
203
- try
204
- target[k] = v for k, v of obj
205
- finally
206
- _depth--
207
- when 'keys' then (path) ->
208
- _depth++
209
- try
210
- segs = walk(path)
211
- obj = proxy
212
- for seg in segs
213
- return [] unless obj?
214
- obj = obj[resolveIndex(seg, obj)]
215
- return [] unless obj? and typeof obj is 'object'
216
- t = raw(obj)
217
- keysSignal(t).value
218
- Object.keys(t)
219
- finally
220
- _depth--
221
- when 'has' then (path) ->
222
- _depth++
223
- try
224
- segs = walk(path)
225
- return false unless segs.length > 0
226
- obj = proxy
227
- for seg, i in segs
228
- key = resolveIndex(seg, obj)
229
- if i is segs.length - 1
230
- t = raw(obj)
231
- keysSignal(t).value
232
- return Object.prototype.hasOwnProperty.call(t, key)
233
- return false unless obj?
234
- obj = obj[key]
235
- false
236
- finally
237
- _depth--
238
- when 'del' then (path) ->
239
- _depth++
240
- try
241
- segs = walk(path)
242
- return unless segs.length > 0
243
- obj = proxy
244
- for seg, i in segs
245
- key = resolveIndex(seg, obj)
246
- if i is segs.length - 1
247
- delete obj[key]
248
- return
249
- return unless obj?
250
- obj = obj[key]
251
- finally
252
- _depth--
253
- cache[prop] = fn
254
- fn
255
-
256
- export stash = (data = {}) -> makeProxy(data)
257
-
258
- export raw = (proxy) -> if proxy?[:raw] then proxy[:raw] else proxy
259
-
260
- export isStash = (obj) -> obj?[:stash] is true
261
-
262
- export persistStash = (app, opts = {}) ->
263
- target = raw(app) or app
264
- return if target[:persisted]
265
- target[:persisted] = true
266
- storage = if opts.local then localStorage else sessionStorage
267
- storageKey = opts.key or '__rip_app'
268
- try
269
- saved = storage.getItem(storageKey)
270
- if saved
271
- savedData = JSON.parse(saved)
272
- app.data[k] = v for k, v of savedData
273
- catch
274
- null
275
- _save = ->
276
- try storage.setItem storageKey, JSON.stringify(raw(app.data))
277
- catch then null
278
- __effect ->
279
- _writeVersion.value
280
- t = setTimeout _save, 2000
281
- -> clearTimeout t
282
- window.addEventListener 'beforeunload', _save
283
-
284
- # ==============================================================================
285
- # Resource — async data loading with reactive loading/error/data states
286
- #
287
- # Wraps an async function and exposes reactive .data, .loading, and .error
288
- # properties. Calls the function immediately unless opts.lazy is set.
289
- # Call .refetch() to reload.
290
- # ==============================================================================
291
-
292
- export createResource = (fn, opts = {}) ->
293
- _data = __state(opts.initial or null)
294
- _loading = __state(false)
295
- _error = __state(null)
296
-
297
- load = ->
298
- _loading.value = true
299
- _error.value = null
300
- try
301
- result = await fn()
302
- _data.value = result
303
- catch err
304
- _error.value = err
305
- finally
306
- _loading.value = false
307
-
308
- resource =
309
- data: undefined
310
- loading: undefined
311
- error: undefined
312
- refetch: load
313
-
314
- Object.defineProperty resource, 'data', get: -> _data.value
315
- Object.defineProperty resource, 'loading', get: -> _loading.value
316
- Object.defineProperty resource, 'error', get: -> _error.value
317
-
318
- load() unless opts.lazy
319
- resource
320
-
321
- # ==============================================================================
322
- # Timing — reactive timing primitives
323
- #
324
- # Each takes a duration (ms) and a reactive source (signal or function).
325
- # Returns a new signal whose value is derived from the source with a
326
- # time-based transformation. Cleanup is automatic via effect disposal.
327
- # ==============================================================================
328
-
329
- _toFn = (source) ->
330
- if typeof source is 'function' then source else -> source.value
331
-
332
- _proxy = (out, source) ->
333
- obj = read: -> out.read()
334
- Object.defineProperty obj, 'value',
335
- get: -> out.value
336
- set: (v) -> source.value = v
337
- obj
338
-
339
- # delay(ms, source) — truthy waits ms, falsy immediate
340
- # Usage: showLoading := delay 200 -> loading
341
- # navigating = delay 100, __state(false)
342
- export delay = (ms, source) ->
343
- fn = _toFn(source)
344
- out = __state(!!fn())
345
- __effect ->
346
- if fn()
347
- t = setTimeout (-> out.value = true), ms
348
- -> clearTimeout t
349
- else
350
- out.value = false
351
- if typeof source isnt 'function' then _proxy(out, source) else out
352
-
353
- # debounce(ms, source) — waits ms after last change, then propagates
354
- # Usage: debouncedQuery := debounce 300 -> query
355
- export debounce = (ms, source) ->
356
- fn = _toFn(source)
357
- out = __state(fn())
358
- __effect ->
359
- val = fn()
360
- t = setTimeout (-> out.value = val), ms
361
- -> clearTimeout t
362
- if typeof source isnt 'function' then _proxy(out, source) else out
363
-
364
- # throttle(ms, source) — propagates at most once per ms
365
- # Usage: smoothScroll := throttle 100 -> scrollY
366
- export throttle = (ms, source) ->
367
- fn = _toFn(source)
368
- out = __state(fn())
369
- last = 0
370
- __effect ->
371
- val = fn()
372
- now = Date.now()
373
- remaining = ms - (now - last)
374
- if remaining <= 0
375
- out.value = val
376
- last = now
377
- else
378
- t = setTimeout (->
379
- out.value = fn()
380
- last = Date.now()
381
- ), remaining
382
- -> clearTimeout t
383
- if typeof source isnt 'function' then _proxy(out, source) else out
384
-
385
- # hold(ms, source) — once truthy, stays true for at least ms
386
- # Usage: showSaved := hold 2000 -> saved
387
- export hold = (ms, source) ->
388
- fn = _toFn(source)
389
- out = __state(!!fn())
390
- __effect ->
391
- if fn()
392
- out.value = true
393
- else
394
- t = setTimeout (-> out.value = false), ms
395
- -> clearTimeout t
396
- if typeof source isnt 'function' then _proxy(out, source) else out
397
-
398
- # ==============================================================================
399
- # Components — in-memory file store with watchers
400
- #
401
- # A virtual filesystem for .rip component sources. Supports read, write,
402
- # delete, listing, and glob-style operations. Watchers are notified on
403
- # create/change/delete. Compiled modules are cached alongside sources.
404
- # ==============================================================================
405
-
406
- export createComponents = ->
407
- files = new Map()
408
- watchers = []
409
- compiled = new Map()
410
-
411
- notify = (event, path) ->
412
- for watcher in watchers
413
- watcher(event, path)
414
-
415
- read: (path) -> files.get(path)
416
- write: (path, content) ->
417
- isNew = not files.has(path)
418
- files.set(path, content)
419
- compiled.delete(path)
420
- notify (if isNew then 'create' else 'change'), path
421
-
422
- del: (path) ->
423
- files.delete(path)
424
- compiled.delete(path)
425
- notify 'delete', path
426
-
427
- exists: (path) -> files.has(path)
428
- size: -> files.size
429
-
430
- list: (dir = '') ->
431
- result = []
432
- prefix = if dir then dir + '/' else ''
433
- for [path] in files
434
- if path.startsWith(prefix)
435
- rest = path.slice(prefix.length)
436
- continue if rest.includes('/')
437
- result.push path
438
- result
439
-
440
- listAll: (dir = '') ->
441
- result = []
442
- prefix = if dir then dir + '/' else ''
443
- for [path] in files
444
- result.push path if path.startsWith(prefix)
445
- result
446
-
447
- load: (obj) ->
448
- for key, content of obj
449
- files.set(key, content)
450
-
451
- watch: (fn) ->
452
- watchers.push fn
453
- -> watchers.splice(watchers.indexOf(fn), 1)
454
-
455
- getCompiled: (path) -> compiled.get(path)
456
- setCompiled: (path, result) -> compiled.set(path, result)
457
-
458
- # ==============================================================================
459
- # Router — file-based URL routing with reactive state
460
- #
461
- # Maps URL paths to component files using a file-system convention:
462
- # components/index.rip → /
463
- # components/about.rip → /about
464
- # components/users/[id].rip → /users/:id
465
- # components/[...rest].rip → /* (catch-all)
466
- #
467
- # Supports nested layouts via _layout.rip files, hash-mode routing,
468
- # base path prefixes, and query/hash tracking.
469
- # ==============================================================================
470
-
471
- fileToPattern = (rel) ->
472
- pattern = rel.replace(/\.rip$/, '')
473
- pattern = pattern.replace(/\[\.\.\.(\w+)\]/g, '*$1')
474
- pattern = pattern.replace(/\[(\w+)\]/g, ':$1')
475
- return '/' if pattern is 'index'
476
- pattern = pattern.replace(/\/index$/, '')
477
- '/' + pattern
478
-
479
- patternToRegex = (pattern) ->
480
- names = []
481
- str = pattern
482
- .replace /\*(\w+)/g, (_, name) -> names.push(name); '(.+)'
483
- .replace /:(\w+)/g, (_, name) -> names.push(name); '([^/]+)'
484
- { regex: new RegExp('^' + str + '$'), names }
485
-
486
- matchRoute = (path, routes) ->
487
- for route in routes
488
- match = path.match(route.regex.regex)
489
- if match
490
- params = {}
491
- for name, i in route.regex.names
492
- params[name] = decodeURIComponent(match[i + 1])
493
- return { route, params }
494
- null
495
-
496
- buildRoutes = (components, root = 'components') ->
497
- routes = []
498
- layouts = new Map()
499
- allFiles = components.listAll(root)
500
-
501
- for filePath in allFiles
502
- rel = filePath.slice(root.length + 1)
503
- continue unless rel.endsWith('.rip')
504
- name = rel.split('/').pop()
505
-
506
- if name is '_layout.rip'
507
- dir = if rel is '_layout.rip' then '' else rel.slice(0, -'/_layout.rip'.length)
508
- layouts.set dir, filePath
509
- continue
510
-
511
- continue if name.startsWith('_')
512
-
513
- # Skip files in _-prefixed directories (shared components, not pages)
514
- segs = rel.split('/')
515
- continue if segs.length > 1 and segs.some((s, i) -> i < segs.length - 1 and s.startsWith('_'))
516
-
517
- urlPattern = fileToPattern(rel)
518
- regex = patternToRegex(urlPattern)
519
- routes.push { pattern: urlPattern, regex, file: filePath, rel }
520
-
521
- # Sort: static first, then fewest dynamic segments, catch-all last
522
- routes.sort (a, b) ->
523
- aDyn = (a.pattern.match(/:/g) or []).length
524
- bDyn = (b.pattern.match(/:/g) or []).length
525
- aCatch = if a.pattern.includes('*') then 1 else 0
526
- bCatch = if b.pattern.includes('*') then 1 else 0
527
- return aCatch - bCatch if aCatch isnt bCatch
528
- return aDyn - bDyn if aDyn isnt bDyn
529
- a.pattern.localeCompare(b.pattern)
530
-
531
- { routes, layouts }
532
-
533
- getLayoutChain = (routeFile, root, layouts) ->
534
- chain = []
535
- rel = routeFile.slice(root.length + 1)
536
- segments = rel.split('/')
537
- dir = ''
538
-
539
- chain.push layouts.get('') if layouts.has('')
540
- for seg, i in segments
541
- break if i is segments.length - 1
542
- dir = if dir then dir + '/' + seg else seg
543
- chain.push layouts.get(dir) if layouts.has(dir)
544
- chain
545
-
546
- export createRouter = (components, opts = {}) ->
547
- root = opts.root or 'components'
548
- base = opts.base or ''
549
- hashMode = opts.hash or false
550
- onError = opts.onError or null
551
-
552
- stripBase = (url) ->
553
- if base and url.startsWith(base) then url.slice(base.length) or '/' else url
554
-
555
- addBase = (path) ->
556
- if base then base + path else path
557
-
558
- readUrl = ->
559
- if hashMode
560
- h = location.hash.slice(1)
561
- return '/' unless h
562
- if h[0] is '/' then h else '/' + h
563
- else
564
- location.pathname + location.search + location.hash
565
-
566
- writeUrl = (path) ->
567
- if hashMode
568
- if path is '/' then location.pathname else '#' + path.slice(1)
569
- else
570
- addBase(path)
571
-
572
- _path = __state(stripBase(if hashMode then readUrl() else location.pathname))
573
- _params = __state({})
574
- _route = __state(null)
575
- _layouts = __state([])
576
- _query = __state({})
577
- _hash = __state('')
578
- _navigating = delay 100, __state(false)
579
-
580
- tree = buildRoutes(components, root)
581
- navCallbacks = new Set()
582
-
583
- components.watch (event, path) ->
584
- return unless path.startsWith(root + '/')
585
- tree = buildRoutes(components, root)
586
-
587
- resolve = (url) ->
588
- rawPath = url.split('?')[0].split('#')[0]
589
- path = stripBase(rawPath)
590
- path = if path[0] is '/' then path else '/' + path
591
- queryStr = url.split('?')[1]?.split('#')[0] or ''
592
- hash = if url.includes('#') then url.split('#')[1] else ''
593
-
594
- result = matchRoute(path, tree.routes)
595
- if result
596
- __batch ->
597
- _path.value = path
598
- _params.value = result.params
599
- _route.value = result.route
600
- _layouts.value = getLayoutChain(result.route.file, root, tree.layouts)
601
- _query.value = Object.fromEntries(new URLSearchParams(queryStr))
602
- _hash.value = hash
603
- cb(router.current) for cb in navCallbacks
604
- return true
605
-
606
- onError({ status: 404, path }) if onError
607
- false
608
-
609
- onPopState = -> resolve(readUrl())
610
- window.addEventListener 'popstate', onPopState if typeof window isnt 'undefined'
611
-
612
- onClick = (e) ->
613
- return if e.button isnt 0 or e.metaKey or e.ctrlKey or e.shiftKey or e.altKey
614
- target = e.target
615
- target = target.parentElement while target and target.tagName isnt 'A'
616
- return unless target?.href
617
- url = new URL(target.href, location.origin)
618
- return if url.origin isnt location.origin
619
- return if target.target is '_blank' or target.hasAttribute('data-external')
620
- e.preventDefault()
621
- dest = if hashMode and url.hash then (url.hash.slice(1) or '/') else (url.pathname + url.search + url.hash)
622
- router.push dest
623
-
624
- document.addEventListener 'click', onClick if typeof document isnt 'undefined'
625
-
626
- router =
627
- push: (url) ->
628
- if resolve(url)
629
- history.pushState null, '', writeUrl(_path.read())
630
-
631
- replace: (url) ->
632
- if resolve(url)
633
- history.replaceState null, '', writeUrl(_path.read())
634
-
635
- back: -> history.back()
636
- forward: -> history.forward()
637
-
638
- current: undefined
639
- path: undefined
640
- params: undefined
641
- route: undefined
642
- layouts: undefined
643
- query: undefined
644
- hash: undefined
645
- navigating: undefined
646
-
647
- onNavigate: (cb) ->
648
- navCallbacks.add cb
649
- -> navCallbacks.delete cb
650
-
651
- rebuild: -> tree = buildRoutes(components, root)
652
-
653
- routes: undefined
654
-
655
- init: ->
656
- resolve readUrl()
657
- router
658
-
659
- destroy: ->
660
- window.removeEventListener 'popstate', onPopState if typeof window isnt 'undefined'
661
- document.removeEventListener 'click', onClick if typeof document isnt 'undefined'
662
- navCallbacks.clear()
663
-
664
- Object.defineProperty router, 'current', get: ->
665
- { path: _path.value, params: _params.value, route: _route.value, layouts: _layouts.value, query: _query.value, hash: _hash.value }
666
-
667
- Object.defineProperty router, 'path', get: -> _path.value
668
- Object.defineProperty router, 'params', get: -> _params.value
669
- Object.defineProperty router, 'route', get: -> _route.value
670
- Object.defineProperty router, 'layouts', get: -> _layouts.value
671
- Object.defineProperty router, 'query', get: -> _query.value
672
- Object.defineProperty router, 'hash', get: -> _hash.value
673
- Object.defineProperty router, 'navigating',
674
- get: -> _navigating.value
675
- set: (v) -> _navigating.value = v
676
- Object.defineProperty router, 'routes', get: -> tree.routes
677
-
678
- router
679
-
680
- # ==============================================================================
681
- # Renderer — compile, import, mount, unmount, and manage components
682
- #
683
- # Handles the full lifecycle: compile Rip source to JS, import as an ES
684
- # module, instantiate, mount into the DOM. Supports nested layouts with
685
- # slot-based composition, component caching for back/forward navigation,
686
- # generation tracking to prevent stale mounts, and error boundaries.
687
- # ==============================================================================
688
-
689
- arraysEqual = (a, b) ->
690
- return false if a.length isnt b.length
691
- for item, i in a
692
- return false if item isnt b[i]
693
- true
694
-
695
- findComponent = (mod) ->
696
- for key, val of mod
697
- return val if typeof val is 'function' and (val.prototype?.mount or val.prototype?._create)
698
- mod.default if typeof mod.default is 'function'
699
-
700
- # --------------------------------------------------------------------------
701
- # Component resolution — name discovery, lazy compilation, class registry
702
- # --------------------------------------------------------------------------
703
-
704
- findAllComponents = (mod) ->
705
- result = {}
706
- for key, val of mod
707
- if typeof val is 'function' and (val.prototype?.mount or val.prototype?._create)
708
- result[key] = val
709
- result
710
-
711
- # Convert file path to PascalCase component name
712
- # components/card.rip → Card, components/todo-item.rip → TodoItem
713
- fileToComponentName = (filePath) ->
714
- name = filePath.split('/').pop().replace(/\.rip$/, '')
715
- name.replace /(^|[-_])([a-z])/g, (_, sep, ch) -> ch.toUpperCase()
716
-
717
- # Build name-to-path map from component store
718
- buildComponentMap = (components, root = 'components') ->
719
- map = {}
720
- for path in components.listAll(root)
721
- continue unless path.endsWith('.rip')
722
- fileName = path.split('/').pop()
723
- continue if fileName.startsWith('_')
724
- name = fileToComponentName(path)
725
- if map[name]
726
- console.warn "[Rip] Component name collision: #{name} (#{map[name]} vs #{path})"
727
- map[name] = path
728
- map
729
-
730
- # Resolve a .rip import specifier to a path in the component store
731
- resolveStorePath = (specifier, currentPath, components) ->
732
- clean = specifier.replace(/^(\.\.\/|\.\/)+/, '')
733
- basename = clean.split('/').pop()
734
- # Direct resolution relative to current file in the store
735
- if currentPath
736
- parts = currentPath.split('/')
737
- parts.pop()
738
- for seg in specifier.split('/')
739
- if seg is '..'
740
- parts.pop()
741
- else unless seg is '.'
742
- parts.push seg
743
- candidate = parts.join('/')
744
- return candidate if components.exists(candidate)
745
- # Try common store prefixes
746
- return "components/_lib/#{clean}" if components.exists("components/_lib/#{clean}")
747
- return "components/#{clean}" if components.exists("components/#{clean}")
748
- # Basename search as last resort
749
- for p in components.listAll('components')
750
- return p if p.endsWith("/#{basename}")
751
- null
752
-
753
- compileAndImport = (source, compile, components = null, path = null, resolver = null) ->
754
- # Check compilation cache
755
- if components and path
756
- cached = components.getCompiled(path)
757
- return cached if cached
758
-
759
- # Mark as in-progress to prevent circular dependency loops
760
- if resolver and path
761
- resolver.compiling ?= {}
762
- resolver.compiling[path] = true
763
-
764
- # Browser-debugger support — when enabled, compile with an inline source
765
- # map keyed to the component's logical path. We still need to compensate
766
- # for line shifts introduced by the header + preamble we prepend below;
767
- # `prefixLines` tracks that and we apply `offsetSourceMap` once at the end.
768
- debug = globalThis?.__ripDebug?.enabled and path
769
- prefixLines = 0
770
- js = if debug
771
- compile(source, sourceMap: 'inline', filename: path)
772
- else
773
- compile(source)
774
-
775
- if resolver
776
- importedNames = new Set()
777
-
778
- # Step 1: Rewrite .rip imports to blob URLs
779
- if components
780
- ripImportRe = /^(\s*import\s+(?:\{([^}]+)\}\s+from\s+|.*?\s+from\s+)?['"])([^'"]*\.rip)(['"];?\s*)$/gm
781
- matches = Array.from(js.matchAll(ripImportRe))
782
- # Walk matches in reverse so string indices stay valid after each splice
783
- for m in matches by -1
784
- [full, pre, namedImports, specifier, post] = m
785
- storePath = resolveStorePath(specifier, path, components)
786
- continue if storePath is path
787
- unless storePath
788
- msg = "[Rip] Could not resolve import: #{specifier}"
789
- msg += " (from #{path})" if path
790
- console.warn msg
791
- continue
792
- # Guard against circular imports — skip if already being compiled
793
- unless resolver.blobUrls?[storePath]
794
- continue if resolver.compiling?[storePath]
795
- depSource = components.read(storePath)
796
- if depSource
797
- compileAndImport! depSource, compile, components, storePath, resolver
798
- blobUrl = resolver.blobUrls?[storePath]
799
- if blobUrl
800
- replacement = "#{pre}#{blobUrl}#{post}"
801
- js = js.slice(0, m.index) + replacement + js.slice(m.index + full.length)
802
- if namedImports
803
- for n in namedImports.split(',')
804
- importedNames.add(n.trim().split(/\s+as\s+/).pop().trim())
805
-
806
- # Step 2: Implicit component resolution — for template-used components not explicitly imported
807
- needed = {}
808
- for name, depPath of resolver.map
809
- continue if importedNames.has(name)
810
- if depPath isnt path and js.includes("new #{name}(")
811
- unless resolver.classes[name]
812
- depSource = components.read(depPath)
813
- if depSource
814
- depMod = compileAndImport! depSource, compile, components, depPath, resolver
815
- found = findAllComponents(depMod)
816
- resolver.classes[k] = v for k, v of found
817
- needed[name] = true if resolver.classes[name]
818
-
819
- # Inject resolved components into scope via preamble
820
- names = Object.keys(needed)
821
- if names.length > 0
822
- preamble = "const {#{names.join(', ')}} = globalThis['#{resolver.key}'];\n"
823
- js = preamble + js
824
- prefixLines += 1
825
-
826
- header = if path then "// #{path}\n" else ''
827
- prefixLines += 1 if header
828
- finalJs = header + js
829
-
830
- # Source-map post-processing: prepend N semicolons to map.mappings so
831
- # generated lines line up with `header + preamble + js`. The Blob URL
832
- # itself becomes the source identity in DevTools, so an explicit
833
- # //# sourceURL pragma isn't needed here (only for eval'd code).
834
- if debug and prefixLines > 0
835
- offset = globalThis?.__ripDebug?.offsetSourceMap
836
- finalJs = offset(finalJs, prefixLines) if offset
837
-
838
- blob = new Blob([finalJs], { type: 'application/javascript' })
839
- url = URL.createObjectURL(blob)
840
-
841
- # Cache blob URL so other files can rewrite imports to point here
842
- if resolver and path
843
- resolver.blobUrls ?= {}
844
- resolver.blobUrls[path] = url
845
- delete resolver.compiling[path] if resolver.compiling
846
-
847
- mod = await import(url)
848
-
849
- # Register any components from this module
850
- if resolver
851
- found = findAllComponents(mod)
852
- resolver.classes[k] = v for k, v of found
853
-
854
- # Store in cache
855
- components.setCompiled(path, mod) if components and path
856
- mod
857
-
858
- export createRenderer = (opts = {}) ->
859
- { router, app, components, resolver, compile, target, onError } = opts
860
-
861
- container = if typeof target is 'string'
862
- document.querySelector(target)
863
- else
864
- target or document.getElementById('app')
865
-
866
- unless container
867
- container = document.createElement('div')
868
- container.id = 'app'
869
- document.body.appendChild container
870
-
871
- currentComponent = null
872
- currentRoute = null
873
- currentParams = null
874
- currentLayouts = []
875
- layoutInstances = []
876
- mountPoint = container
877
- generation = 0
878
- disposeEffect = null
879
- componentCache = new Map()
880
- maxCacheSize = opts.cacheSize or 10
881
-
882
- cacheComponent = ->
883
- if currentComponent and currentRoute
884
- currentComponent.beforeUnmount() if currentComponent.beforeUnmount
885
- componentCache.set currentRoute, currentComponent
886
- # Evict oldest if over limit
887
- if componentCache.size > maxCacheSize
888
- oldest = componentCache.keys().next().value
889
- evicted = componentCache.get(oldest)
890
- evicted.unmounted() if evicted.unmounted
891
- componentCache.delete oldest
892
- # Don't remove _root here — leave visible until new content is ready
893
- currentComponent = null
894
- currentRoute = null
895
-
896
- unmount = ->
897
- cacheComponent()
898
- for inst in layoutInstances by -1
899
- inst.beforeUnmount() if inst.beforeUnmount
900
- inst.unmounted() if inst.unmounted
901
- inst._target?.remove()
902
- layoutInstances = []
903
- mountPoint = container
904
-
905
- # Invalidate cached components when their source changes (HMR)
906
- components.watch (event, path) ->
907
- if componentCache.has(path)
908
- evicted = componentCache.get(path)
909
- evicted.unmounted() if evicted.unmounted
910
- componentCache.delete path
911
-
912
- mountRoute = (info) ->
913
- { route, params, layouts: layoutFiles, query } = info
914
- return unless route
915
- if route.file is currentRoute and JSON.stringify(params) is JSON.stringify(currentParams)
916
- return
917
- currentParams = params
918
-
919
- gen = ++generation
920
- router.navigating = true
921
-
922
- try
923
- source = components.read(route.file)
924
- unless source
925
- onError({ status: 404, message: "File not found: #{route.file}" }) if onError
926
- router.navigating = false
927
- return
928
-
929
- mod = compileAndImport! source, compile, components, route.file, resolver
930
- if gen isnt generation then router.navigating = false; return
931
-
932
- Component = findComponent(mod)
933
- unless Component
934
- onError({ status: 500, message: "No component found in #{route.file}" }) if onError
935
- router.navigating = false
936
- return
937
-
938
- layoutsChanged = not arraysEqual(layoutFiles, currentLayouts)
939
- oldTarget = currentComponent?._target
940
-
941
- if layoutsChanged
942
- unmount()
943
- else
944
- cacheComponent()
945
-
946
- mp = if layoutsChanged then container else mountPoint
947
-
948
- if layoutsChanged and layoutFiles.length > 0
949
- container.innerHTML = ''
950
- mp = container
951
-
952
- for layoutFile in layoutFiles
953
- layoutSource = components.read(layoutFile)
954
- continue unless layoutSource
955
- layoutMod = compileAndImport! layoutSource, compile, components, layoutFile, resolver
956
- if gen isnt generation then router.navigating = false; return
957
-
958
- LayoutClass = findComponent(layoutMod)
959
- continue unless LayoutClass
960
-
961
- inst = new LayoutClass { app, params, router }
962
- inst.beforeMount() if inst.beforeMount
963
- wrapper = document.createElement('div')
964
- wrapper.setAttribute 'data-layout', layoutFile
965
- mp.appendChild wrapper
966
- inst.mount wrapper
967
- layoutInstances.push inst
968
-
969
- slot = wrapper.querySelector('#content') or wrapper
970
- mp = slot
971
-
972
- currentLayouts = [...layoutFiles]
973
- mountPoint = mp
974
- else if layoutsChanged
975
- container.innerHTML = ''
976
- currentLayouts = []
977
- mountPoint = container
978
-
979
- # Check component cache for a preserved instance
980
- cached = componentCache.get(route.file)
981
- if cached
982
- componentCache.delete route.file
983
- mp.appendChild cached._target
984
- currentComponent = cached
985
- currentRoute = route.file
986
- cached.params = params if params
987
- cached.query = query if query
988
- cached.mounted() if cached.mounted
989
- cached.load!(params, query) if cached.load
990
- else
991
- pageWrapper = document.createElement('div')
992
- pageWrapper.setAttribute 'data-component', route.file
993
- mp.appendChild pageWrapper
994
-
995
- instance = new Component { app, params, query, router }
996
- instance.beforeMount() if instance.beforeMount
997
- instance.mount pageWrapper
998
- currentComponent = instance
999
- currentRoute = route.file
1000
-
1001
- instance.load!(params, query) if instance.load
1002
- oldTarget?.remove()
1003
- router.navigating = false
1004
-
1005
- catch err
1006
- router.navigating = false
1007
- console.error "Renderer: error mounting #{route.file}:", err
1008
- onError({ status: 500, message: err.message, error: err }) if onError
1009
-
1010
- # Walk layout chain for an error boundary (component with onError method)
1011
- handled = false
1012
- for inst in layoutInstances by -1
1013
- if inst.onError
1014
- try
1015
- inst.onError(err)
1016
- handled = true
1017
- break
1018
- catch boundaryErr
1019
- console.error "Renderer: error boundary failed:", boundaryErr
1020
-
1021
- unless handled
1022
- pre = document.createElement('pre')
1023
- pre.style.cssText = 'color:red;padding:1em'
1024
- pre.textContent = err.stack or err.message
1025
- container.innerHTML = ''
1026
- container.appendChild pre
1027
-
1028
- renderer =
1029
- start: ->
1030
- disposeEffect = __effect ->
1031
- current = router.current
1032
- mountRoute(current) if current.route
1033
- router.init()
1034
- renderer
1035
-
1036
- stop: ->
1037
- unmount()
1038
- if disposeEffect
1039
- disposeEffect()
1040
- disposeEffect = null
1041
- container.innerHTML = ''
1042
-
1043
- remount: ->
1044
- current = router.current
1045
- mountRoute(current) if current.route
1046
-
1047
- cache: componentCache
1048
-
1049
- renderer
1050
-
1051
- # ==============================================================================
1052
- # SSE Watch — hot-reload connection with exponential backoff
1053
- # ==============================================================================
1054
-
1055
- connectWatch = (url) ->
1056
- retryDelay = 1000
1057
- maxDelay = 30000
1058
-
1059
- connect = ->
1060
- es = new EventSource(url)
1061
-
1062
- es.addEventListener 'connected', ->
1063
- retryDelay = 1000
1064
- console.log '[Rip] Hot reload connected'
1065
-
1066
- es.addEventListener 'reload', ->
1067
- console.log '[Rip] Reloading...'
1068
- location.reload()
1069
-
1070
- es.addEventListener 'css', ->
1071
- for link as document.querySelectorAll('link[rel="stylesheet"]')
1072
- url = new URL(link.href)
1073
- url.searchParams.set('_t', Date.now())
1074
- link.href = url.toString()
1075
-
1076
- es.onerror = ->
1077
- es.close()
1078
- setTimeout connect, retryDelay
1079
- retryDelay = Math.min(retryDelay * 2, maxDelay)
1080
-
1081
- connect()
1082
-
1083
- # ==============================================================================
1084
- # Launch — fetch bundle, create stash, wire router + renderer, start app
1085
- #
1086
- # Entry point for Rip applications. Handles two bundle sources:
1087
- # 1. Explicit bundle object (opts.bundle)
1088
- # 2. Fetch from URL (opts.bundleUrl) — used by serve middleware's data-launch
1089
- #
1090
- # Creates the app stash, sets up persistence if configured, builds the
1091
- # component resolver, initializes the router and renderer, and optionally
1092
- # connects SSE hot-reload.
1093
- # ==============================================================================
1094
-
1095
- export launch = (appBase = '', opts = {}) ->
1096
- globalThis.__ripLaunched = true
1097
- if typeof appBase is 'object'
1098
- opts = appBase
1099
- appBase = ''
1100
- appBase = appBase.replace(/\/+$/, '') # strip trailing slashes
1101
- target = opts.target or '#app'
1102
- compile = opts.compile or null
1103
- persist = opts.persist or false
1104
- hash = opts.hash or false
1105
-
1106
- # Auto-detect compile function from the global rip.js module
1107
- unless compile
1108
- compile = globalThis?.compileToJS or null
1109
-
1110
- # Auto-create target element
1111
- if typeof document isnt 'undefined' and not document.querySelector(target)
1112
- el = document.createElement('div')
1113
- el.id = target.replace(/^#/, '')
1114
- document.body.prepend el
1115
-
1116
- # Get the app bundle — explicit object or fetch from URL
1117
- if opts.bundle
1118
- bundle = opts.bundle
1119
- else if opts.bundleUrl
1120
- headers = {}
1121
- etagKey = "__rip_etag_#{opts.bundleUrl}"
1122
- cached = sessionStorage.getItem(etagKey)
1123
- headers['If-None-Match'] = cached if cached
1124
- res = await fetch(opts.bundleUrl, { headers })
1125
- if res.status is 304
1126
- bundle = JSON.parse(sessionStorage.getItem("#{etagKey}_data"))
1127
- else if res.ok
1128
- bundle = res.json!
1129
- etag = res.headers.get('etag')
1130
- if etag
1131
- sessionStorage.setItem(etagKey, etag)
1132
- sessionStorage.setItem("#{etagKey}_data", JSON.stringify(bundle))
1133
- else
1134
- throw new Error "launch: #{opts.bundleUrl} (#{res.status})"
1135
- else
1136
- throw new Error "launch: no bundle or bundleUrl provided"
1137
-
1138
- # Create the unified stash
1139
- app = stash { components: {}, routes: {}, data: {} }
1140
- globalThis.__ripApp = app
1141
-
1142
- # Hydrate from bundle — any keys populate the stash
1143
- app.data = bundle.data if bundle.data
1144
- if bundle.routes
1145
- app.routes = bundle.routes
1146
-
1147
- # Restore persisted state (overrides bundle defaults with saved user state)
1148
- if persist and typeof sessionStorage isnt 'undefined'
1149
- persistStash app, local: persist is 'local', key: "__rip_#{appBase}"
1150
-
1151
- # Create components store and load component sources
1152
- appComponents = createComponents()
1153
- appComponents.load(bundle.components) if bundle.components
1154
-
1155
- # Build component resolver — name-to-path map + app-scoped class registry
1156
- classesKey = "__rip_#{appBase.replace(/\//g, '_') or 'app'}"
1157
- resolver = { map: buildComponentMap(appComponents), classes: {}, key: classesKey }
1158
- globalThis[classesKey] = resolver.classes if typeof globalThis isnt 'undefined'
1159
-
1160
- # Set document title
1161
- document.title = app.data.title if app.data.title and typeof document isnt 'undefined'
1162
-
1163
- # Create router
1164
- router = createRouter appComponents,
1165
- root: 'components'
1166
- base: appBase
1167
- hash: hash
1168
- onError: (err) -> console.error "[Rip] Error #{err.status}: #{err.message or err.path}"
1169
-
1170
- # Create renderer
1171
- renderer = createRenderer
1172
- router: router
1173
- app: app
1174
- components: appComponents
1175
- resolver: resolver
1176
- compile: compile
1177
- target: target
1178
- onError: (err) -> console.error "[Rip] #{err.message}", err.error
1179
-
1180
- # Start
1181
- renderer.start()
1182
-
1183
- # Connect SSE watch if enabled
1184
- if bundle.data?.watch
1185
- connectWatch "#{appBase}/watch"
1186
-
1187
- # Expose for console and dev tools
1188
- if typeof window isnt 'undefined'
1189
- window.app = app
1190
- window.__RIP__ =
1191
- app: app
1192
- components: appComponents
1193
- router: router
1194
- renderer: renderer
1195
- resolver: resolver
1196
- cache: renderer.cache
1197
- version: '0.3.0'
1198
-
1199
- { app, components: appComponents, router, renderer }
1200
-
1201
- # ==============================================================================
1202
- # ARIA — keyboard navigation and popup lifecycle utilities for UI components
1203
- #
1204
- # Provides the WAI-ARIA keyboard interaction patterns used by headless widgets.
1205
- # Registered on globalThis so any component can use them without explicit imports.
1206
- #
1207
- # ARIA.listNav(e, handlers) — popup lists (listbox, menu, combobox)
1208
- # ARIA.rovingNav(e, handlers, orient) — inline composites (radiogroup, tabs, toolbar)
1209
- # ARIA.popupDismiss(open, popup, close, els, repos) — dismiss on outside click; reposition or close on scroll
1210
- #
1211
- # Both nav handlers support an optional `char:` handler for typeahead search.
1212
- # APG gaps handled per-component (not in ARIA helpers):
1213
- # - Submenu ArrowRight/Left: belongs in the Menu component (menu.rip)
1214
- # - Grid 2D navigation: belongs in the Grid component (grid.rip)
1215
- # - F2 cell edit mode: belongs in the Grid component (grid.rip)
1216
- #
1217
- # Both nav handlers:
1218
- # - Guard against IME composition events (e.isComposing, CJK input)
1219
- # - Call e.preventDefault() + e.stopPropagation() for handled keys
1220
- # - Only invoke a handler if it is provided (all keys are optional)
1221
- # - Alias PageUp/PageDown to first/last (fn+Up/Down on macOS)
1222
- #
1223
- # ARIA: https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/
1224
- # ==============================================================================
1225
-
1226
- _ariaNAV = (e, fn) ->
1227
- return unless fn
1228
- e.preventDefault()
1229
- e.stopPropagation()
1230
- fn()
1231
-
1232
- globalThis.__ariaLastFocusedEl ?= null
1233
- if typeof document isnt 'undefined' and not globalThis.__ariaFocusTrackerBound
1234
- document.addEventListener 'focusin', (e) -> globalThis.__ariaLastFocusedEl = e.target, true
1235
- globalThis.__ariaFocusTrackerBound = true
1236
-
1237
- _ariaListNav = (e, h) ->
1238
- return if e.isComposing # IME guard: ignore events during CJK composition
1239
- switch e.key
1240
- when 'ArrowDown' then _ariaNAV e, h.next
1241
- when 'ArrowUp' then _ariaNAV e, h.prev
1242
- when 'Home', 'PageUp' then _ariaNAV e, h.first
1243
- when 'End', 'PageDown' then _ariaNAV e, h.last
1244
- when 'Enter', ' ' then _ariaNAV e, h.select
1245
- when 'Escape' then _ariaNAV e, h.dismiss
1246
- when 'Tab' then h.tab?() # no preventDefault: allow natural focus movement
1247
- else h.char?(e.key) if e.key.length is 1 # printable chars: typeahead
1248
-
1249
- _ariaPopupDismiss = (open, popup, close, els = [], repos = null) ->
1250
- return unless open
1251
- get = (x) -> if typeof x is 'function' then x() else x
1252
- onDown = (e) => close() unless [get(popup), ...els.map(get)].some (el) -> el?.contains(e.target)
1253
- onScroll = (e) =>
1254
- return if get(popup)?.contains(e.target)
1255
- if repos then repos() else close()
1256
- document.addEventListener 'mousedown', onDown
1257
- window.addEventListener 'scroll', onScroll, true
1258
- ->
1259
- document.removeEventListener 'mousedown', onDown
1260
- window.removeEventListener 'scroll', onScroll, true
1261
-
1262
- # popupGuard — per-component reopen suppression for pointer-driven popup closes.
1263
- # Use when the same gesture that closes a popup can otherwise refocus/reclick a
1264
- # trigger and reopen it immediately on the tail end of that sequence.
1265
- _ariaPopupGuard = (delay = 250) ->
1266
- blockedUntil = 0
1267
- {
1268
- block: (ms = delay) ->
1269
- blockedUntil = Date.now() + ms
1270
- canOpen: ->
1271
- Date.now() >= blockedUntil
1272
- }
1273
-
1274
- # bindPopover — sync reactive open state with native Popover API
1275
- # open: reactive boolean
1276
- # popover: element or lazy getter (=> el)
1277
- # setOpen: callback that receives actual native open state (true/false)
1278
- # source: optional control element or lazy getter used as popover invoker
1279
- _ariaBindPopover = (open, popover, setOpen, source = null) ->
1280
- get = (x) -> if typeof x is 'function' then x() else x
1281
- currentFocus = ->
1282
- active = document.activeElement
1283
- return active if active and active isnt document.body
1284
- last = globalThis.__ariaLastFocusedEl
1285
- return last if last?.isConnected isnt false
1286
- null
1287
- el = get(popover)
1288
- return unless el
1289
- return unless Object.hasOwn(HTMLElement.prototype, 'togglePopover')
1290
- restoreEl = null
1291
-
1292
- syncState = (isOpen) ->
1293
- if isOpen
1294
- el.hidden = false
1295
- try el.inert = false
1296
- catch then null
1297
- el.removeAttribute 'aria-hidden'
1298
- else
1299
- try el.inert = true
1300
- catch then null
1301
- el.setAttribute 'aria-hidden', 'true'
1302
- el.hidden = true
1303
-
1304
- restoreFocus = ->
1305
- target = restoreEl
1306
- restoreEl = null
1307
- return unless target?.focus?
1308
- focusAttempt = (tries = 6) ->
1309
- return unless target.isConnected isnt false
1310
- try target.focus preventScroll: true
1311
- catch then target.focus()
1312
- return if document.activeElement is target or tries <= 1
1313
- setTimeout (-> focusAttempt(tries - 1)), 16
1314
- requestAnimationFrame -> focusAttempt()
1315
-
1316
- onToggle = (e) ->
1317
- isOpen = e.newState is 'open'
1318
- if isOpen
1319
- restoreEl = get(source) or currentFocus()
1320
- syncState(true)
1321
- else
1322
- syncState(false)
1323
- restoreFocus()
1324
- setOpen?(isOpen)
1325
- el.addEventListener 'toggle', onToggle
1326
-
1327
- shown = el.matches(':popover-open')
1328
- desired = !!open
1329
- if shown isnt desired
1330
- src = get(source)
1331
- if desired
1332
- restoreEl = src or currentFocus()
1333
- syncState(true)
1334
- opts = if src and desired then { force: desired, source: src } else { force: desired }
1335
- try
1336
- el.togglePopover(opts)
1337
- catch
1338
- # If the element cannot be toggled right now (for example detached),
1339
- # keep runtime stable; effect will retry on the next reactive pass.
1340
- null
1341
- else
1342
- syncState(desired)
1343
-
1344
- -> el.removeEventListener 'toggle', onToggle
1345
-
1346
- # bindDialog — sync reactive open state with native <dialog>
1347
- # open: reactive boolean
1348
- # dialog: dialog element or lazy getter (=> el)
1349
- # setOpen: callback invoked when native state changes
1350
- # dismissable: false prevents Esc/cancel close
1351
- _ariaBindDialog = (open, dialog, setOpen, dismissable = true) ->
1352
- get = (x) -> if typeof x is 'function' then x() else x
1353
- currentFocus = ->
1354
- active = document.activeElement
1355
- return active if active and active isnt document.body
1356
- last = globalThis.__ariaLastFocusedEl
1357
- return last if last?.isConnected isnt false
1358
- null
1359
- el = get(dialog)
1360
- return unless el?.showModal?
1361
- restoreEl = null
1362
-
1363
- syncState = (isOpen) ->
1364
- if isOpen
1365
- el.hidden = false
1366
- try el.inert = false
1367
- catch then null
1368
- el.removeAttribute 'aria-hidden'
1369
- else
1370
- try el.inert = true
1371
- catch then null
1372
- el.setAttribute 'aria-hidden', 'true'
1373
- el.hidden = true
1374
-
1375
- restoreFocus = ->
1376
- target = restoreEl
1377
- restoreEl = null
1378
- return unless target?.focus?
1379
- focusAttempt = (tries = 6) ->
1380
- return unless target.isConnected isnt false
1381
- try target.focus preventScroll: true
1382
- catch then target.focus()
1383
- return if document.activeElement is target or tries <= 1
1384
- setTimeout (-> focusAttempt(tries - 1)), 16
1385
- requestAnimationFrame -> focusAttempt()
1386
-
1387
- onCancel = (e) ->
1388
- unless dismissable
1389
- e.preventDefault()
1390
- return
1391
- setOpen?(false)
1392
-
1393
- onClose = ->
1394
- setOpen?(false)
1395
- syncState(false)
1396
- restoreFocus()
1397
- el.addEventListener 'cancel', onCancel
1398
- el.addEventListener 'close', onClose
1399
-
1400
- if open and not el.open
1401
- restoreEl = currentFocus() unless restoreEl
1402
- syncState(true)
1403
- try el.showModal()
1404
- catch then null
1405
- else if not open and el.open
1406
- el.close()
1407
- else
1408
- syncState(!!open)
1409
-
1410
- ->
1411
- el.removeEventListener 'cancel', onCancel
1412
- el.removeEventListener 'close', onClose
1413
-
1414
- _ariaRovingNav = (e, h, orientation = 'vertical') ->
1415
- return if e.isComposing # IME guard
1416
- vert = orientation isnt 'horizontal'
1417
- horz = orientation isnt 'vertical'
1418
- switch e.key
1419
- when 'ArrowDown' then _ariaNAV e, h.next if vert
1420
- when 'ArrowUp' then _ariaNAV e, h.prev if vert
1421
- when 'ArrowRight' then _ariaNAV e, h.next if horz
1422
- when 'ArrowLeft' then _ariaNAV e, h.prev if horz
1423
- when 'Home', 'PageUp' then _ariaNAV e, h.first
1424
- when 'End', 'PageDown' then _ariaNAV e, h.last
1425
- when 'Enter', ' ' then _ariaNAV e, h.select
1426
- when 'Escape' then _ariaNAV e, h.dismiss
1427
- else h.char?(e.key) if e.key.length is 1 # printable chars: typeahead (e.g. tabs)
1428
-
1429
- # positionBelow — position a fixed popup below its trigger with viewport flip
1430
- # gap: pixels between trigger bottom and popup top (default 4)
1431
- # setVisible: call visibility:visible after positioning (default true)
1432
- _ariaPositionBelow = (trigger, popup, gap = 4, setVisible = true) ->
1433
- return unless trigger and popup
1434
- tr = trigger.getBoundingClientRect()
1435
- popup.style.position = 'fixed'
1436
- popup.style.left = "#{tr.left}px"
1437
- popup.style.top = "#{tr.bottom + gap}px"
1438
- popup.style.minWidth = "#{tr.width}px"
1439
- fl = popup.getBoundingClientRect()
1440
- popup.style.top = "#{tr.top - fl.height - gap}px" if fl.bottom > window.innerHeight
1441
- popup.style.left = "#{window.innerWidth - fl.width - gap}px" if fl.right > window.innerWidth
1442
- popup.style.visibility = 'visible' if setVisible
1443
-
1444
- # trapFocus — trap Tab key inside a panel (focus wraps first↔last)
1445
- # Returns a cleanup function; call it when the panel closes
1446
- _FOCUSABLE =! 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])'
1447
- _ariaTrapFocus = (panel) ->
1448
- handler = (e) ->
1449
- return unless e.key is 'Tab'
1450
- list = Array.from(panel.querySelectorAll(_FOCUSABLE)).filter (f) -> f.offsetParent isnt null
1451
- return unless list.length
1452
- first = list[0]; last = list[list.length - 1]
1453
- if e.shiftKey
1454
- if document.activeElement is first then (e.preventDefault(); last.focus())
1455
- else
1456
- if document.activeElement is last then (e.preventDefault(); first.focus())
1457
- panel.addEventListener 'keydown', handler
1458
- -> panel.removeEventListener 'keydown', handler
1459
-
1460
- # wireAria — auto-label a panel from its first heading and first paragraph
1461
- _ariaWireAria = (panel, id) ->
1462
- return unless panel
1463
- heading = panel.querySelector('h1,h2,h3,h4,h5,h6')
1464
- if heading
1465
- heading.id ?= "#{id}-title"
1466
- panel.setAttribute 'aria-labelledby', heading.id
1467
- desc = panel.querySelector('p')
1468
- if desc
1469
- desc.id ?= "#{id}-desc"
1470
- panel.setAttribute 'aria-describedby', desc.id
1471
-
1472
- # Shared modal scroll-lock stack — fixes race when Dialog + AlertDialog both open
1473
- # lockScroll(instance): locks body scroll
1474
- # unlockScroll(instance): removes from stack, restores scroll if stack empty
1475
- _ariaModalStack = []
1476
- _ariaLockScroll = (instance) ->
1477
- scrollY = window.scrollY
1478
- _ariaModalStack.push { instance, scrollY }
1479
- if _ariaModalStack.length is 1
1480
- document.body.style.position = 'fixed'
1481
- document.body.style.top = "-#{scrollY}px"
1482
- document.body.style.width = '100%'
1483
-
1484
- _ariaUnlockScroll = (instance) ->
1485
- idx = _ariaModalStack.findIndex (m) -> m.instance is instance
1486
- return if idx < 0
1487
- { scrollY } = _ariaModalStack.splice(idx, 1)[0]
1488
- unless _ariaModalStack.length
1489
- document.body.style.position = ''
1490
- document.body.style.top = ''
1491
- document.body.style.width = ''
1492
- window.scrollTo 0, scrollY
1493
-
1494
- _ariaHasAnchor = do ->
1495
- try
1496
- return false unless document?.createElement
1497
- anchor = document.createElement('div')
1498
- floating = document.createElement('div')
1499
- anchor.style.cssText = 'position:fixed;top:100px;left:100px;width:10px;height:10px;anchor-name:--probe'
1500
- floating.style.cssText = 'position:fixed;inset:auto;margin:0;position-anchor:--probe;position-area:bottom start;width:10px;height:10px'
1501
- document.body.appendChild(anchor)
1502
- document.body.appendChild(floating)
1503
- rect = floating.getBoundingClientRect()
1504
- anchor.remove()
1505
- floating.remove()
1506
- rect.top > 50
1507
- catch
1508
- false
1509
-
1510
- _ariaPosition = (trigger, floating, opts = {}) ->
1511
- return unless trigger and floating
1512
- placement = opts.placement ?? 'bottom start'
1513
- offset = opts.offset ?? 4
1514
- matchWidth = opts.matchWidth ?? false
1515
- if _ariaHasAnchor
1516
- name = "--anchor-#{floating.id or Math.random().toString(36).slice(2, 8)}"
1517
- trigger.style.anchorName = name
1518
- floating.style.positionAnchor = name
1519
- floating.style.position = 'fixed'
1520
- floating.style.inset = 'auto'
1521
- floating.style.margin = '0'
1522
- floating.style.positionArea = placement
1523
- floating.style.positionTry = 'flip-block, flip-inline, flip-block flip-inline'
1524
- floating.style.positionVisibility = 'anchors-visible'
1525
- [side] = placement.split(' ')
1526
- floating.style.marginTop = ''
1527
- floating.style.marginBottom = ''
1528
- floating.style.marginLeft = ''
1529
- floating.style.marginRight = ''
1530
- switch side
1531
- when 'bottom' then floating.style.marginTop = "#{offset}px"
1532
- when 'top' then floating.style.marginBottom = "#{offset}px"
1533
- when 'left' then floating.style.marginRight = "#{offset}px"
1534
- when 'right' then floating.style.marginLeft = "#{offset}px"
1535
- floating.style.minWidth = 'anchor-size(width)' if matchWidth
1536
- else
1537
- rect = trigger.getBoundingClientRect()
1538
- floating.style.position = 'fixed'
1539
- floating.style.inset = 'auto'
1540
- floating.style.margin = '0'
1541
- [side, align] = placement.split(' ')
1542
- align ??= 'start'
1543
- switch side
1544
- when 'bottom'
1545
- floating.style.top = "#{rect.bottom + offset}px"
1546
- when 'top'
1547
- floating.style.bottom = "#{window.innerHeight - rect.top + offset}px"
1548
- when 'left'
1549
- floating.style.right = "#{window.innerWidth - rect.left + offset}px"
1550
- when 'right'
1551
- floating.style.left = "#{rect.right + offset}px"
1552
- if side in ['bottom', 'top']
1553
- switch align
1554
- when 'start' then floating.style.left = "#{rect.left}px"
1555
- when 'center' then floating.style.left = "#{rect.left + rect.width / 2}px"; floating.style.transform = 'translateX(-50%)'
1556
- when 'end' then floating.style.right = "#{window.innerWidth - rect.right}px"
1557
- else
1558
- switch align
1559
- when 'start' then floating.style.top = "#{rect.top}px"
1560
- when 'center' then floating.style.top = "#{rect.top + rect.height / 2}px"; floating.style.transform = 'translateY(-50%)'
1561
- when 'end' then floating.style.bottom = "#{window.innerHeight - rect.bottom}px"
1562
- floating.style.minWidth = "#{rect.width}px" if matchWidth
1563
-
1564
- globalThis.__aria ??= {
1565
- listNav: _ariaListNav, rovingNav: _ariaRovingNav, popupDismiss: _ariaPopupDismiss, popupGuard: _ariaPopupGuard,
1566
- bindPopover: _ariaBindPopover, bindDialog: _ariaBindDialog,
1567
- positionBelow: _ariaPositionBelow, trapFocus: _ariaTrapFocus, wireAria: _ariaWireAria,
1568
- lockScroll: _ariaLockScroll, unlockScroll: _ariaUnlockScroll,
1569
- position: _ariaPosition, hasAnchor: _ariaHasAnchor,
1570
- }
1571
- globalThis.ARIA ??= globalThis.__aria