@rip-lang/ui 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ui.rip ADDED
@@ -0,0 +1,935 @@
1
+ # ==============================================================================
2
+ # Rip UI — Unified reactive framework
3
+ #
4
+ # Two files. One is JavaScript, everything else is Rip.
5
+ #
6
+ # /rip/browser.js — the compiler (pre-compiled JS, cached forever)
7
+ # /rip/ui.rip — the framework (this file, compiled in browser)
8
+ #
9
+ # The app stash:
10
+ # app.data — reactive app state
11
+ # app.routes — navigation state (path, params, query, hash)
12
+ #
13
+ # Boot (in <script type="text/rip">):
14
+ # { launch } = importRip! '/rip/ui.rip'
15
+ # launch '/myapp'
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
+
32
+ STASH = Symbol('stash')
33
+ SIGNALS = Symbol('signals')
34
+ RAW = Symbol('raw')
35
+ PROXIES = new WeakMap()
36
+ _keysVersion = 0
37
+ _writeVersion = __state(0)
38
+
39
+ getSignal = (target, prop) ->
40
+ unless target[SIGNALS]
41
+ Object.defineProperty target, SIGNALS, { value: new Map(), enumerable: false }
42
+ sig = target[SIGNALS].get(prop)
43
+ unless sig
44
+ sig = __state(target[prop])
45
+ target[SIGNALS].set(prop, sig)
46
+ sig
47
+
48
+ keysSignal = (target) -> getSignal(target, Symbol.for('keys'))
49
+
50
+ wrapDeep = (value) ->
51
+ return value unless value? and typeof value is 'object'
52
+ return value if value[STASH]
53
+ return value if value instanceof Date or value instanceof RegExp or value instanceof Map or value instanceof Set or value instanceof Promise
54
+ existing = PROXIES.get(value)
55
+ return existing if existing
56
+ makeProxy(value)
57
+
58
+ makeProxy = (target) ->
59
+ proxy = null
60
+ handler =
61
+ get: (target, prop) ->
62
+ return true if prop is STASH
63
+ return target if prop is RAW
64
+ return Reflect.get(target, prop) if typeof prop is 'symbol'
65
+
66
+ if prop is 'length' and Array.isArray(target)
67
+ keysSignal(target).value
68
+ return target.length
69
+
70
+ # Stash API methods
71
+ return ((path) -> stashGet(proxy, path)) if prop is 'get'
72
+ return ((path, val) -> stashSet(proxy, path, val)) if prop is 'set'
73
+
74
+ sig = getSignal(target, prop)
75
+ val = sig.value
76
+
77
+ return wrapDeep(val) if val? and typeof val is 'object'
78
+ val
79
+
80
+ set: (target, prop, value) ->
81
+ old = target[prop]
82
+ r = if value?[RAW] then value[RAW] else value
83
+ return true if r is old
84
+ target[prop] = r
85
+
86
+ if target[SIGNALS]?.has(prop)
87
+ target[SIGNALS].get(prop).value = r
88
+ if old is undefined and r isnt undefined
89
+ keysSignal(target).value = ++_keysVersion
90
+ _writeVersion.value++
91
+
92
+ true
93
+
94
+ deleteProperty: (target, prop) ->
95
+ delete target[prop]
96
+ sig = target[SIGNALS]?.get(prop)
97
+ sig.value = undefined if sig
98
+ keysSignal(target).value = ++_keysVersion
99
+ true
100
+
101
+ ownKeys: (target) ->
102
+ keysSignal(target).value
103
+ Reflect.ownKeys(target)
104
+
105
+ proxy = new Proxy(target, handler)
106
+ PROXIES.set(target, proxy)
107
+ proxy
108
+
109
+ # Path navigation
110
+ PATH_RE = /([./][^./\[\s]+|\[[-+]?\d+\]|\[(?:"[^"]+"|'[^']+')\])/
111
+
112
+ walk = (path) ->
113
+ list = ('.' + path).split(PATH_RE)
114
+ list.shift()
115
+ result = []
116
+ i = 0
117
+ while i < list.length
118
+ part = list[i]
119
+ chr = part[0]
120
+ if chr is '.' or chr is '/'
121
+ result.push part.slice(1)
122
+ else if chr is '['
123
+ if part[1] is '"' or part[1] is "'"
124
+ result.push part.slice(2, -2)
125
+ else
126
+ result.push +(part.slice(1, -1))
127
+ i += 2
128
+ result
129
+
130
+ stashGet = (proxy, path) ->
131
+ segs = walk(path)
132
+ obj = proxy
133
+ for seg in segs
134
+ return undefined unless obj?
135
+ obj = obj[seg]
136
+ obj
137
+
138
+ stashSet = (proxy, path, value) ->
139
+ segs = walk(path)
140
+ obj = proxy
141
+ for seg, i in segs
142
+ if i is segs.length - 1
143
+ obj[seg] = value
144
+ else
145
+ obj[seg] = {} unless obj[seg]?
146
+ obj = obj[seg]
147
+ value
148
+
149
+ export stash = (data = {}) -> makeProxy(data)
150
+
151
+ export raw = (proxy) -> if proxy?[RAW] then proxy[RAW] else proxy
152
+
153
+ export isStash = (obj) -> obj?[STASH] is true
154
+
155
+ # ==============================================================================
156
+ # Resource — async data loading with reactive loading/error/data states
157
+ # ==============================================================================
158
+
159
+ export createResource = (fn, opts = {}) ->
160
+ _data = __state(opts.initial or null)
161
+ _loading = __state(false)
162
+ _error = __state(null)
163
+
164
+ load = ->
165
+ _loading.value = true
166
+ _error.value = null
167
+ try
168
+ result = await fn()
169
+ _data.value = result
170
+ catch err
171
+ _error.value = err
172
+ finally
173
+ _loading.value = false
174
+
175
+ resource =
176
+ data: undefined
177
+ loading: undefined
178
+ error: undefined
179
+ refetch: load
180
+
181
+ Object.defineProperty resource, 'data', get: -> _data.value
182
+ Object.defineProperty resource, 'loading', get: -> _loading.value
183
+ Object.defineProperty resource, 'error', get: -> _error.value
184
+
185
+ load() unless opts.lazy
186
+ resource
187
+
188
+ # ==============================================================================
189
+ # Timing — reactive timing primitives composed from __state + __effect + cleanup
190
+ # ==============================================================================
191
+
192
+ _toFn = (source) ->
193
+ if typeof source is 'function' then source else -> source.value
194
+
195
+ _proxy = (out, source) ->
196
+ obj = read: -> out.read()
197
+ Object.defineProperty obj, 'value',
198
+ get: -> out.value
199
+ set: (v) -> source.value = v
200
+ obj
201
+
202
+ # delay(ms, source) — truthy waits ms, falsy immediate
203
+ # Usage: showLoading := delay 200 -> loading
204
+ # navigating = delay 100, __state(false)
205
+ export delay = (ms, source) ->
206
+ fn = _toFn(source)
207
+ out = __state(!!fn())
208
+ __effect ->
209
+ if fn()
210
+ t = setTimeout (-> out.value = true), ms
211
+ -> clearTimeout t
212
+ else
213
+ out.value = false
214
+ if typeof source isnt 'function' then _proxy(out, source) else out
215
+
216
+ # debounce(ms, source) — waits ms after last change, then propagates
217
+ # Usage: debouncedQuery := debounce 300 -> query
218
+ export debounce = (ms, source) ->
219
+ fn = _toFn(source)
220
+ out = __state(fn())
221
+ __effect ->
222
+ val = fn()
223
+ t = setTimeout (-> out.value = val), ms
224
+ -> clearTimeout t
225
+ if typeof source isnt 'function' then _proxy(out, source) else out
226
+
227
+ # throttle(ms, source) — propagates at most once per ms
228
+ # Usage: smoothScroll := throttle 100 -> scrollY
229
+ export throttle = (ms, source) ->
230
+ fn = _toFn(source)
231
+ out = __state(fn())
232
+ last = 0
233
+ __effect ->
234
+ val = fn()
235
+ now = Date.now()
236
+ remaining = ms - (now - last)
237
+ if remaining <= 0
238
+ out.value = val
239
+ last = now
240
+ else
241
+ t = setTimeout (->
242
+ out.value = fn()
243
+ last = Date.now()
244
+ ), remaining
245
+ -> clearTimeout t
246
+ if typeof source isnt 'function' then _proxy(out, source) else out
247
+
248
+ # hold(ms, source) — once truthy, stays true for at least ms
249
+ # Usage: showSaved := hold 2000 -> saved
250
+ export hold = (ms, source) ->
251
+ fn = _toFn(source)
252
+ out = __state(!!fn())
253
+ __effect ->
254
+ if fn()
255
+ out.value = true
256
+ else
257
+ t = setTimeout (-> out.value = false), ms
258
+ -> clearTimeout t
259
+ if typeof source isnt 'function' then _proxy(out, source) else out
260
+
261
+ # ==============================================================================
262
+ # Components — in-memory file storage with watchers
263
+ # ==============================================================================
264
+
265
+ export createComponents = ->
266
+ files = new Map()
267
+ watchers = []
268
+ compiled = new Map()
269
+
270
+ notify = (event, path) ->
271
+ for watcher in watchers
272
+ watcher(event, path)
273
+
274
+ read: (path) -> files.get(path)
275
+ write: (path, content) ->
276
+ isNew = not files.has(path)
277
+ files.set(path, content)
278
+ compiled.delete(path)
279
+ notify (if isNew then 'create' else 'change'), path
280
+
281
+ del: (path) ->
282
+ files.delete(path)
283
+ compiled.delete(path)
284
+ notify 'delete', path
285
+
286
+ exists: (path) -> files.has(path)
287
+ size: -> files.size
288
+
289
+ list: (dir = '') ->
290
+ result = []
291
+ prefix = if dir then dir + '/' else ''
292
+ for [path] in files
293
+ if path.startsWith(prefix)
294
+ rest = path.slice(prefix.length)
295
+ continue if rest.includes('/')
296
+ result.push path
297
+ result
298
+
299
+ listAll: (dir = '') ->
300
+ result = []
301
+ prefix = if dir then dir + '/' else ''
302
+ for [path] in files
303
+ result.push path if path.startsWith(prefix)
304
+ result
305
+
306
+ load: (obj) ->
307
+ for key, content of obj
308
+ files.set(key, content)
309
+
310
+ watch: (fn) ->
311
+ watchers.push fn
312
+ -> watchers.splice(watchers.indexOf(fn), 1)
313
+
314
+ getCompiled: (path) -> compiled.get(path)
315
+ setCompiled: (path, result) -> compiled.set(path, result)
316
+
317
+ # ==============================================================================
318
+ # Router — URL-to-component mapping with reactive state
319
+ # ==============================================================================
320
+
321
+ fileToPattern = (rel) ->
322
+ pattern = rel.replace(/\.rip$/, '')
323
+ pattern = pattern.replace(/\[\.\.\.(\w+)\]/g, '*$1')
324
+ pattern = pattern.replace(/\[(\w+)\]/g, ':$1')
325
+ return '/' if pattern is 'index'
326
+ pattern = pattern.replace(/\/index$/, '')
327
+ '/' + pattern
328
+
329
+ patternToRegex = (pattern) ->
330
+ names = []
331
+ str = pattern
332
+ .replace /\*(\w+)/g, (_, name) -> names.push(name); '(.+)'
333
+ .replace /:(\w+)/g, (_, name) -> names.push(name); '([^/]+)'
334
+ { regex: new RegExp('^' + str + '$'), names }
335
+
336
+ matchRoute = (path, routes) ->
337
+ for route in routes
338
+ match = path.match(route.regex.regex)
339
+ if match
340
+ params = {}
341
+ for name, i in route.regex.names
342
+ params[name] = decodeURIComponent(match[i + 1])
343
+ return { route, params }
344
+ null
345
+
346
+ buildRoutes = (components, root = 'components') ->
347
+ routes = []
348
+ layouts = new Map()
349
+ allFiles = components.listAll(root)
350
+
351
+ for filePath in allFiles
352
+ rel = filePath.slice(root.length + 1)
353
+ continue unless rel.endsWith('.rip')
354
+ name = rel.split('/').pop()
355
+
356
+ if name is '_layout.rip'
357
+ dir = if rel is '_layout.rip' then '' else rel.slice(0, -'/_layout.rip'.length)
358
+ layouts.set dir, filePath
359
+ continue
360
+
361
+ continue if name.startsWith('_')
362
+
363
+ urlPattern = fileToPattern(rel)
364
+ regex = patternToRegex(urlPattern)
365
+ routes.push { pattern: urlPattern, regex, file: filePath, rel }
366
+
367
+ # Sort: static first, then fewest dynamic segments, catch-all last
368
+ routes.sort (a, b) ->
369
+ aDyn = (a.pattern.match(/:/g) or []).length
370
+ bDyn = (b.pattern.match(/:/g) or []).length
371
+ aCatch = if a.pattern.includes('*') then 1 else 0
372
+ bCatch = if b.pattern.includes('*') then 1 else 0
373
+ return aCatch - bCatch if aCatch isnt bCatch
374
+ return aDyn - bDyn if aDyn isnt bDyn
375
+ a.pattern.localeCompare(b.pattern)
376
+
377
+ { routes, layouts }
378
+
379
+ getLayoutChain = (routeFile, root, layouts) ->
380
+ chain = []
381
+ rel = routeFile.slice(root.length + 1)
382
+ segments = rel.split('/')
383
+ dir = ''
384
+
385
+ chain.push layouts.get('') if layouts.has('')
386
+ for seg, i in segments
387
+ break if i is segments.length - 1
388
+ dir = if dir then dir + '/' + seg else seg
389
+ chain.push layouts.get(dir) if layouts.has(dir)
390
+ chain
391
+
392
+ export createRouter = (components, opts = {}) ->
393
+ root = opts.root or 'components'
394
+ base = opts.base or ''
395
+ onError = opts.onError or null
396
+
397
+ stripBase = (url) ->
398
+ if base and url.startsWith(base) then url.slice(base.length) or '/' else url
399
+
400
+ addBase = (path) ->
401
+ if base then base + path else path
402
+
403
+ _path = __state(stripBase(location.pathname))
404
+ _params = __state({})
405
+ _route = __state(null)
406
+ _layouts = __state([])
407
+ _query = __state({})
408
+ _hash = __state('')
409
+ _navigating = delay 100, __state(false)
410
+
411
+ tree = buildRoutes(components, root)
412
+ navCallbacks = new Set()
413
+
414
+ components.watch (event, path) ->
415
+ return unless path.startsWith(root + '/')
416
+ tree = buildRoutes(components, root)
417
+
418
+ resolve = (url) ->
419
+ rawPath = url.split('?')[0].split('#')[0]
420
+ path = stripBase(rawPath)
421
+ queryStr = url.split('?')[1]?.split('#')[0] or ''
422
+ hash = if url.includes('#') then url.split('#')[1] else ''
423
+
424
+ result = matchRoute(path, tree.routes)
425
+ if result
426
+ __batch ->
427
+ _path.value = path
428
+ _params.value = result.params
429
+ _route.value = result.route
430
+ _layouts.value = getLayoutChain(result.route.file, root, tree.layouts)
431
+ _query.value = Object.fromEntries(new URLSearchParams(queryStr))
432
+ _hash.value = hash
433
+ cb(router.current) for cb in navCallbacks
434
+ return true
435
+
436
+ onError({ status: 404, path }) if onError
437
+ false
438
+
439
+ onPopState = -> resolve(location.pathname + location.search + location.hash)
440
+ window.addEventListener 'popstate', onPopState if typeof window isnt 'undefined'
441
+
442
+ onClick = (e) ->
443
+ return if e.button isnt 0 or e.metaKey or e.ctrlKey or e.shiftKey or e.altKey
444
+ target = e.target
445
+ target = target.parentElement while target and target.tagName isnt 'A'
446
+ return unless target?.href
447
+ url = new URL(target.href, location.origin)
448
+ return if url.origin isnt location.origin
449
+ return if target.target is '_blank' or target.hasAttribute('data-external')
450
+ e.preventDefault()
451
+ router.push url.pathname + url.search + url.hash
452
+
453
+ document.addEventListener 'click', onClick if typeof document isnt 'undefined'
454
+
455
+ router =
456
+ push: (url) ->
457
+ if resolve(url)
458
+ history.pushState null, '', addBase(_path.read())
459
+
460
+ replace: (url) ->
461
+ if resolve(url)
462
+ history.replaceState null, '', addBase(_path.read())
463
+
464
+ back: -> history.back()
465
+ forward: -> history.forward()
466
+
467
+ current: undefined # overridden by getter
468
+ path: undefined
469
+ params: undefined
470
+ route: undefined
471
+ layouts: undefined
472
+ query: undefined
473
+ hash: undefined
474
+ navigating: undefined
475
+
476
+ onNavigate: (cb) ->
477
+ navCallbacks.add cb
478
+ -> navCallbacks.delete cb
479
+
480
+ rebuild: -> tree = buildRoutes(components, root)
481
+
482
+ routes: undefined # overridden by getter
483
+
484
+ init: ->
485
+ resolve location.pathname + location.search + location.hash
486
+ router
487
+
488
+ destroy: ->
489
+ window.removeEventListener 'popstate', onPopState if typeof window isnt 'undefined'
490
+ document.removeEventListener 'click', onClick if typeof document isnt 'undefined'
491
+ navCallbacks.clear()
492
+
493
+ Object.defineProperty router, 'current', get: ->
494
+ { path: _path.value, params: _params.value, route: _route.value, layouts: _layouts.value, query: _query.value, hash: _hash.value }
495
+
496
+ Object.defineProperty router, 'path', get: -> _path.value
497
+ Object.defineProperty router, 'params', get: -> _params.value
498
+ Object.defineProperty router, 'route', get: -> _route.value
499
+ Object.defineProperty router, 'layouts', get: -> _layouts.value
500
+ Object.defineProperty router, 'query', get: -> _query.value
501
+ Object.defineProperty router, 'hash', get: -> _hash.value
502
+ Object.defineProperty router, 'navigating',
503
+ get: -> _navigating.value
504
+ set: (v) -> _navigating.value = v
505
+ Object.defineProperty router, 'routes', get: -> tree.routes
506
+
507
+ router
508
+
509
+ # ==============================================================================
510
+ # Renderer — compile, import, mount/unmount, layouts, slots
511
+ # ==============================================================================
512
+
513
+ arraysEqual = (a, b) ->
514
+ return false if a.length isnt b.length
515
+ for item, i in a
516
+ return false if item isnt b[i]
517
+ true
518
+
519
+ findComponent = (mod) ->
520
+ for key, val of mod
521
+ return val if typeof val is 'function' and (val.prototype?.mount or val.prototype?._create)
522
+ mod.default if typeof mod.default is 'function'
523
+
524
+ # --------------------------------------------------------------------------
525
+ # Component resolution — name discovery, lazy compilation, class registry
526
+ # --------------------------------------------------------------------------
527
+
528
+ findAllComponents = (mod) ->
529
+ result = {}
530
+ for key, val of mod
531
+ if typeof val is 'function' and (val.prototype?.mount or val.prototype?._create)
532
+ result[key] = val
533
+ result
534
+
535
+ # Convert file path to PascalCase component name
536
+ # components/card.rip → Card, components/todo-item.rip → TodoItem
537
+ fileToComponentName = (filePath) ->
538
+ name = filePath.split('/').pop().replace(/\.rip$/, '')
539
+ name.replace /(^|[-_])([a-z])/g, (_, sep, ch) -> ch.toUpperCase()
540
+
541
+ # Build name-to-path map from component store
542
+ buildComponentMap = (components, root = 'components') ->
543
+ map = {}
544
+ for path in components.listAll(root)
545
+ continue unless path.endsWith('.rip')
546
+ fileName = path.split('/').pop()
547
+ continue if fileName.startsWith('_')
548
+ name = fileToComponentName(path)
549
+ if map[name]
550
+ console.warn "[Rip] Component name collision: #{name} (#{map[name]} vs #{path})"
551
+ map[name] = path
552
+ map
553
+
554
+ compileAndImport = (source, compile, components = null, path = null, resolver = null) ->
555
+ # Check compilation cache
556
+ if components and path
557
+ cached = components.getCompiled(path)
558
+ return cached if cached
559
+
560
+ js = compile(source)
561
+
562
+ # Resolve component dependencies — scan for PascalCase references in compiled JS
563
+ if resolver
564
+ needed = {}
565
+ for name, depPath of resolver.map
566
+ if depPath isnt path and js.includes("new #{name}(")
567
+ unless resolver.classes[name]
568
+ depSource = components.read(depPath)
569
+ if depSource
570
+ depMod = compileAndImport! depSource, compile, components, depPath, resolver
571
+ found = findAllComponents(depMod)
572
+ resolver.classes[k] = v for k, v of found
573
+ needed[name] = true if resolver.classes[name]
574
+
575
+ # Inject resolved components into scope via preamble
576
+ names = Object.keys(needed)
577
+ if names.length > 0
578
+ preamble = "const {#{names.join(', ')}} = globalThis['#{resolver.key}'];\n"
579
+ js = preamble + js
580
+
581
+ blob = new Blob([js], { type: 'application/javascript' })
582
+ url = URL.createObjectURL(blob)
583
+ try
584
+ mod = await import(url)
585
+ finally
586
+ URL.revokeObjectURL url
587
+
588
+ # Register any components from this module
589
+ if resolver
590
+ found = findAllComponents(mod)
591
+ resolver.classes[k] = v for k, v of found
592
+
593
+ # Store in cache
594
+ components.setCompiled(path, mod) if components and path
595
+ mod
596
+
597
+ export createRenderer = (opts = {}) ->
598
+ { router, app, components, resolver, compile, target, onError } = opts
599
+
600
+ container = if typeof target is 'string'
601
+ document.querySelector(target)
602
+ else
603
+ target or document.getElementById('app')
604
+
605
+ unless container
606
+ container = document.createElement('div')
607
+ container.id = 'app'
608
+ document.body.appendChild container
609
+
610
+ # Fade in after first mount (prevents layout-before-content flicker)
611
+ container.style.opacity = '0'
612
+
613
+ currentComponent = null
614
+ currentRoute = null
615
+ currentLayouts = []
616
+ layoutInstances = []
617
+ mountPoint = container
618
+ generation = 0
619
+ disposeEffect = null
620
+ componentCache = new Map()
621
+ maxCacheSize = opts.cacheSize or 10
622
+
623
+ cacheComponent = ->
624
+ if currentComponent and currentRoute
625
+ currentComponent.beforeUnmount() if currentComponent.beforeUnmount
626
+ componentCache.set currentRoute, currentComponent
627
+ # Evict oldest if over limit
628
+ if componentCache.size > maxCacheSize
629
+ oldest = componentCache.keys().next().value
630
+ evicted = componentCache.get(oldest)
631
+ evicted.unmounted() if evicted.unmounted
632
+ componentCache.delete oldest
633
+ # Don't remove _root here — leave visible until new content is ready
634
+ currentComponent = null
635
+ currentRoute = null
636
+
637
+ unmount = ->
638
+ cacheComponent()
639
+ for inst in layoutInstances by -1
640
+ inst.beforeUnmount() if inst.beforeUnmount
641
+ inst.unmounted() if inst.unmounted
642
+ inst._root?.remove()
643
+ layoutInstances = []
644
+ mountPoint = container
645
+
646
+ # Invalidate cached components when their source changes (HMR)
647
+ components.watch (event, path) ->
648
+ if componentCache.has(path)
649
+ evicted = componentCache.get(path)
650
+ evicted.unmounted() if evicted.unmounted
651
+ componentCache.delete path
652
+
653
+ mountRoute = (info) ->
654
+ { route, params, layouts: layoutFiles, query } = info
655
+ return unless route
656
+ return if route.file is currentRoute # already showing this route
657
+
658
+ gen = ++generation
659
+ router.navigating = true
660
+
661
+ try
662
+ source = components.read(route.file)
663
+ unless source
664
+ onError({ status: 404, message: "File not found: #{route.file}" }) if onError
665
+ router.navigating = false
666
+ return
667
+
668
+ mod = compileAndImport! source, compile, components, route.file, resolver
669
+ if gen isnt generation then router.navigating = false; return
670
+
671
+ Component = findComponent(mod)
672
+ unless Component
673
+ onError({ status: 500, message: "No component found in #{route.file}" }) if onError
674
+ router.navigating = false
675
+ return
676
+
677
+ layoutsChanged = not arraysEqual(layoutFiles, currentLayouts)
678
+ oldRoot = currentComponent?._root
679
+
680
+ if layoutsChanged
681
+ unmount()
682
+ else
683
+ cacheComponent()
684
+
685
+ mp = if layoutsChanged then container else mountPoint
686
+
687
+ if layoutsChanged and layoutFiles.length > 0
688
+ container.innerHTML = ''
689
+ mp = container
690
+
691
+ for layoutFile in layoutFiles
692
+ layoutSource = components.read(layoutFile)
693
+ continue unless layoutSource
694
+ layoutMod = compileAndImport! layoutSource, compile, components, layoutFile, resolver
695
+ if gen isnt generation then router.navigating = false; return
696
+
697
+ LayoutClass = findComponent(layoutMod)
698
+ continue unless LayoutClass
699
+
700
+ inst = new LayoutClass { app, params, router }
701
+ inst.beforeMount() if inst.beforeMount
702
+ wrapper = document.createElement('div')
703
+ wrapper.setAttribute 'data-layout', layoutFile
704
+ mp.appendChild wrapper
705
+ inst.mount wrapper
706
+ layoutInstances.push inst
707
+
708
+ slot = wrapper.querySelector('#content') or wrapper
709
+ mp = slot
710
+
711
+ currentLayouts = [...layoutFiles]
712
+ mountPoint = mp
713
+ else if layoutsChanged
714
+ container.innerHTML = ''
715
+ currentLayouts = []
716
+ mountPoint = container
717
+
718
+ # Check component cache for a preserved instance
719
+ cached = componentCache.get(route.file)
720
+ if cached
721
+ componentCache.delete route.file
722
+ mp.appendChild cached._root
723
+ currentComponent = cached
724
+ currentRoute = route.file
725
+ else
726
+ pageWrapper = document.createElement('div')
727
+ pageWrapper.setAttribute 'data-component', route.file
728
+ mp.appendChild pageWrapper
729
+
730
+ instance = new Component { app, params, query, router }
731
+ instance.beforeMount() if instance.beforeMount
732
+ instance.mount pageWrapper
733
+ currentComponent = instance
734
+ currentRoute = route.file
735
+
736
+ instance.load!(params, query) if instance.load
737
+ oldRoot?.remove()
738
+ router.navigating = false
739
+ if container.style.opacity is '0'
740
+ document.fonts.ready.then ->
741
+ requestAnimationFrame ->
742
+ container.style.transition = 'opacity 150ms ease-in'
743
+ container.style.opacity = '1'
744
+
745
+ catch err
746
+ router.navigating = false
747
+ container.style.opacity = '1'
748
+ console.error "Renderer: error mounting #{route.file}:", err
749
+ onError({ status: 500, message: err.message, error: err }) if onError
750
+
751
+ # Walk layout chain for an error boundary (component with onError method)
752
+ handled = false
753
+ for inst in layoutInstances by -1
754
+ if inst.onError
755
+ try
756
+ inst.onError(err)
757
+ handled = true
758
+ break
759
+ catch boundaryErr
760
+ console.error "Renderer: error boundary failed:", boundaryErr
761
+
762
+ unless handled
763
+ pre = document.createElement('pre')
764
+ pre.style.cssText = 'color:red;padding:1em'
765
+ pre.textContent = err.stack or err.message
766
+ container.innerHTML = ''
767
+ container.appendChild pre
768
+
769
+ renderer =
770
+ start: ->
771
+ disposeEffect = __effect ->
772
+ current = router.current
773
+ mountRoute(current) if current.route
774
+ router.init()
775
+ renderer
776
+
777
+ stop: ->
778
+ unmount()
779
+ if disposeEffect
780
+ disposeEffect()
781
+ disposeEffect = null
782
+ container.innerHTML = ''
783
+
784
+ remount: ->
785
+ current = router.current
786
+ mountRoute(current) if current.route
787
+
788
+ cache: componentCache
789
+
790
+ renderer
791
+
792
+ # ==============================================================================
793
+ # Launch — fetch an app bundle, populate the stash, start everything
794
+ # ==============================================================================
795
+
796
+ export launch = (appBase = '', opts = {}) ->
797
+ appBase = appBase.replace(/\/+$/, '') # strip trailing slashes
798
+ target = opts.target or '#app'
799
+ compile = opts.compile or null
800
+ persist = opts.persist or false
801
+ bundleUrl = "#{appBase}/bundle"
802
+
803
+ # Auto-detect compile function from the global rip.js module
804
+ unless compile
805
+ compile = globalThis?.compileToJS or null
806
+
807
+ # Auto-create target element
808
+ if typeof document isnt 'undefined' and not document.querySelector(target)
809
+ el = document.createElement('div')
810
+ el.id = target.replace(/^#/, '')
811
+ document.body.prepend el
812
+
813
+ # Fetch the app bundle
814
+ res = await fetch(bundleUrl)
815
+ throw new Error "launch: #{bundleUrl} (#{res.status})" unless res.ok
816
+ bundle = res.json!
817
+
818
+ # Create the unified stash
819
+ app = stash { components: {}, routes: {}, data: {} }
820
+
821
+ # Hydrate from bundle — any keys populate the stash
822
+ app.data = bundle.data if bundle.data
823
+ if bundle.routes
824
+ app.routes = bundle.routes
825
+
826
+ # Restore persisted state (overrides bundle defaults with saved user state)
827
+ if persist and typeof sessionStorage isnt 'undefined'
828
+ _storageKey = "__rip_#{appBase}"
829
+ _storage = if persist is 'local' then localStorage else sessionStorage
830
+ try
831
+ saved = _storage.getItem(_storageKey)
832
+ if saved
833
+ savedData = JSON.parse(saved)
834
+ app.data[k] = v for k, v of savedData
835
+ catch
836
+ null
837
+ # Auto-save: debounce 2s after any stash write. Also save on unload.
838
+ _save = ->
839
+ try _storage.setItem _storageKey, JSON.stringify(raw(app.data))
840
+ catch then null
841
+ __effect ->
842
+ _writeVersion.value
843
+ t = setTimeout _save, 2000
844
+ -> clearTimeout t
845
+ window.addEventListener 'beforeunload', _save
846
+
847
+ # Create components store and load component sources
848
+ appComponents = createComponents()
849
+ appComponents.load(bundle.components) if bundle.components
850
+
851
+ # Build component resolver — name-to-path map + app-scoped class registry
852
+ classesKey = "__rip_#{appBase.replace(/\//g, '_') or 'app'}"
853
+ resolver = { map: buildComponentMap(appComponents), classes: {}, key: classesKey }
854
+ globalThis[classesKey] = resolver.classes if typeof globalThis isnt 'undefined'
855
+
856
+ # Set document title
857
+ document.title = app.data.title if app.data.title and typeof document isnt 'undefined'
858
+
859
+ # Create router
860
+ router = createRouter appComponents,
861
+ root: 'components'
862
+ base: appBase
863
+ onError: (err) -> console.error "[Rip] Error #{err.status}: #{err.message or err.path}"
864
+
865
+ # Create renderer
866
+ renderer = createRenderer
867
+ router: router
868
+ app: app
869
+ components: appComponents
870
+ resolver: resolver
871
+ compile: compile
872
+ target: target
873
+ onError: (err) -> console.error "[Rip] #{err.message}", err.error
874
+
875
+ # Start
876
+ renderer.start()
877
+
878
+ # Connect SSE watch if enabled
879
+ if bundle.data?.watch
880
+ connectWatch appComponents, router, renderer, "#{appBase}/watch", appBase
881
+
882
+ # Expose for console and dev tools
883
+ if typeof window isnt 'undefined'
884
+ window.app = app
885
+ window.__RIP__ =
886
+ app: app
887
+ components: appComponents
888
+ router: router
889
+ renderer: renderer
890
+ cache: renderer.cache
891
+ version: '0.3.0'
892
+
893
+ { app, components: appComponents, router, renderer }
894
+
895
+ # ==============================================================================
896
+ # SSE Watch — hot-reload connection
897
+ # ==============================================================================
898
+
899
+ connectWatch = (components, router, renderer, url, base = '') ->
900
+ retryDelay = 1000
901
+ maxDelay = 30000
902
+
903
+ connect = ->
904
+ es = new EventSource(url)
905
+
906
+ es.addEventListener 'connected', ->
907
+ retryDelay = 1000 # reset backoff on successful connection
908
+ console.log '[Rip] Hot reload connected'
909
+
910
+ es.addEventListener 'changed', (e) ->
911
+ { paths } = JSON.parse(e.data)
912
+ components.del(path) for path in paths
913
+ router.rebuild()
914
+
915
+ current = router.current
916
+ toFetch = paths.filter (p) ->
917
+ p is current.route?.file or current.layouts?.includes(p)
918
+
919
+ if toFetch.length > 0
920
+ results = await Promise.allSettled(toFetch.map (path) ->
921
+ res = await fetch(base + '/' + path)
922
+ content = res.text!
923
+ components.write path, content
924
+ )
925
+ failed = results.filter (r) -> r.status is 'rejected'
926
+ console.error '[Rip] Hot reload fetch error:', r.reason for r in failed
927
+ renderer.remount()
928
+
929
+ es.onerror = ->
930
+ es.close()
931
+ console.log "[Rip] Hot reload reconnecting in #{retryDelay / 1000}s..."
932
+ setTimeout connect, retryDelay
933
+ retryDelay = Math.min(retryDelay * 2, maxDelay)
934
+
935
+ connect()