rip-lang 3.7.3 → 3.8.8

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.
@@ -0,0 +1,957 @@
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
+ hashMode = opts.hash or false
396
+ onError = opts.onError or null
397
+
398
+ stripBase = (url) ->
399
+ if base and url.startsWith(base) then url.slice(base.length) or '/' else url
400
+
401
+ addBase = (path) ->
402
+ if base then base + path else path
403
+
404
+ readUrl = ->
405
+ if hashMode
406
+ h = location.hash.slice(1)
407
+ return '/' unless h
408
+ if h[0] is '/' then h else '/' + h
409
+ else
410
+ location.pathname + location.search + location.hash
411
+
412
+ writeUrl = (path) ->
413
+ if hashMode
414
+ if path is '/' then location.pathname else '#' + path.slice(1)
415
+ else
416
+ addBase(path)
417
+
418
+ _path = __state(stripBase(if hashMode then readUrl() else location.pathname))
419
+ _params = __state({})
420
+ _route = __state(null)
421
+ _layouts = __state([])
422
+ _query = __state({})
423
+ _hash = __state('')
424
+ _navigating = delay 100, __state(false)
425
+
426
+ tree = buildRoutes(components, root)
427
+ navCallbacks = new Set()
428
+
429
+ components.watch (event, path) ->
430
+ return unless path.startsWith(root + '/')
431
+ tree = buildRoutes(components, root)
432
+
433
+ resolve = (url) ->
434
+ rawPath = url.split('?')[0].split('#')[0]
435
+ path = stripBase(rawPath)
436
+ path = if path[0] is '/' then path else '/' + path
437
+ queryStr = url.split('?')[1]?.split('#')[0] or ''
438
+ hash = if url.includes('#') then url.split('#')[1] else ''
439
+
440
+ result = matchRoute(path, tree.routes)
441
+ if result
442
+ __batch ->
443
+ _path.value = path
444
+ _params.value = result.params
445
+ _route.value = result.route
446
+ _layouts.value = getLayoutChain(result.route.file, root, tree.layouts)
447
+ _query.value = Object.fromEntries(new URLSearchParams(queryStr))
448
+ _hash.value = hash
449
+ cb(router.current) for cb in navCallbacks
450
+ return true
451
+
452
+ onError({ status: 404, path }) if onError
453
+ false
454
+
455
+ onPopState = -> resolve(readUrl())
456
+ window.addEventListener 'popstate', onPopState if typeof window isnt 'undefined'
457
+
458
+ onClick = (e) ->
459
+ return if e.button isnt 0 or e.metaKey or e.ctrlKey or e.shiftKey or e.altKey
460
+ target = e.target
461
+ target = target.parentElement while target and target.tagName isnt 'A'
462
+ return unless target?.href
463
+ url = new URL(target.href, location.origin)
464
+ return if url.origin isnt location.origin
465
+ return if target.target is '_blank' or target.hasAttribute('data-external')
466
+ e.preventDefault()
467
+ dest = if hashMode and url.hash then (url.hash.slice(1) or '/') else (url.pathname + url.search + url.hash)
468
+ router.push dest
469
+
470
+ document.addEventListener 'click', onClick if typeof document isnt 'undefined'
471
+
472
+ router =
473
+ push: (url) ->
474
+ if resolve(url)
475
+ history.pushState null, '', writeUrl(_path.read())
476
+
477
+ replace: (url) ->
478
+ if resolve(url)
479
+ history.replaceState null, '', writeUrl(_path.read())
480
+
481
+ back: -> history.back()
482
+ forward: -> history.forward()
483
+
484
+ current: undefined # overridden by getter
485
+ path: undefined
486
+ params: undefined
487
+ route: undefined
488
+ layouts: undefined
489
+ query: undefined
490
+ hash: undefined
491
+ navigating: undefined
492
+
493
+ onNavigate: (cb) ->
494
+ navCallbacks.add cb
495
+ -> navCallbacks.delete cb
496
+
497
+ rebuild: -> tree = buildRoutes(components, root)
498
+
499
+ routes: undefined # overridden by getter
500
+
501
+ init: ->
502
+ resolve readUrl()
503
+ router
504
+
505
+ destroy: ->
506
+ window.removeEventListener 'popstate', onPopState if typeof window isnt 'undefined'
507
+ document.removeEventListener 'click', onClick if typeof document isnt 'undefined'
508
+ navCallbacks.clear()
509
+
510
+ Object.defineProperty router, 'current', get: ->
511
+ { path: _path.value, params: _params.value, route: _route.value, layouts: _layouts.value, query: _query.value, hash: _hash.value }
512
+
513
+ Object.defineProperty router, 'path', get: -> _path.value
514
+ Object.defineProperty router, 'params', get: -> _params.value
515
+ Object.defineProperty router, 'route', get: -> _route.value
516
+ Object.defineProperty router, 'layouts', get: -> _layouts.value
517
+ Object.defineProperty router, 'query', get: -> _query.value
518
+ Object.defineProperty router, 'hash', get: -> _hash.value
519
+ Object.defineProperty router, 'navigating',
520
+ get: -> _navigating.value
521
+ set: (v) -> _navigating.value = v
522
+ Object.defineProperty router, 'routes', get: -> tree.routes
523
+
524
+ router
525
+
526
+ # ==============================================================================
527
+ # Renderer — compile, import, mount/unmount, layouts, slots
528
+ # ==============================================================================
529
+
530
+ arraysEqual = (a, b) ->
531
+ return false if a.length isnt b.length
532
+ for item, i in a
533
+ return false if item isnt b[i]
534
+ true
535
+
536
+ findComponent = (mod) ->
537
+ for key, val of mod
538
+ return val if typeof val is 'function' and (val.prototype?.mount or val.prototype?._create)
539
+ mod.default if typeof mod.default is 'function'
540
+
541
+ # --------------------------------------------------------------------------
542
+ # Component resolution — name discovery, lazy compilation, class registry
543
+ # --------------------------------------------------------------------------
544
+
545
+ findAllComponents = (mod) ->
546
+ result = {}
547
+ for key, val of mod
548
+ if typeof val is 'function' and (val.prototype?.mount or val.prototype?._create)
549
+ result[key] = val
550
+ result
551
+
552
+ # Convert file path to PascalCase component name
553
+ # components/card.rip → Card, components/todo-item.rip → TodoItem
554
+ fileToComponentName = (filePath) ->
555
+ name = filePath.split('/').pop().replace(/\.rip$/, '')
556
+ name.replace /(^|[-_])([a-z])/g, (_, sep, ch) -> ch.toUpperCase()
557
+
558
+ # Build name-to-path map from component store
559
+ buildComponentMap = (components, root = 'components') ->
560
+ map = {}
561
+ for path in components.listAll(root)
562
+ continue unless path.endsWith('.rip')
563
+ fileName = path.split('/').pop()
564
+ continue if fileName.startsWith('_')
565
+ name = fileToComponentName(path)
566
+ if map[name]
567
+ console.warn "[Rip] Component name collision: #{name} (#{map[name]} vs #{path})"
568
+ map[name] = path
569
+ map
570
+
571
+ compileAndImport = (source, compile, components = null, path = null, resolver = null) ->
572
+ # Check compilation cache
573
+ if components and path
574
+ cached = components.getCompiled(path)
575
+ return cached if cached
576
+
577
+ js = compile(source)
578
+
579
+ # Resolve component dependencies — scan for PascalCase references in compiled JS
580
+ if resolver
581
+ needed = {}
582
+ for name, depPath of resolver.map
583
+ if depPath isnt path and js.includes("new #{name}(")
584
+ unless resolver.classes[name]
585
+ depSource = components.read(depPath)
586
+ if depSource
587
+ depMod = compileAndImport! depSource, compile, components, depPath, resolver
588
+ found = findAllComponents(depMod)
589
+ resolver.classes[k] = v for k, v of found
590
+ needed[name] = true if resolver.classes[name]
591
+
592
+ # Inject resolved components into scope via preamble
593
+ names = Object.keys(needed)
594
+ if names.length > 0
595
+ preamble = "const {#{names.join(', ')}} = globalThis['#{resolver.key}'];\n"
596
+ js = preamble + js
597
+
598
+ blob = new Blob([js], { type: 'application/javascript' })
599
+ url = URL.createObjectURL(blob)
600
+ try
601
+ mod = await import(url)
602
+ finally
603
+ URL.revokeObjectURL url
604
+
605
+ # Register any components from this module
606
+ if resolver
607
+ found = findAllComponents(mod)
608
+ resolver.classes[k] = v for k, v of found
609
+
610
+ # Store in cache
611
+ components.setCompiled(path, mod) if components and path
612
+ mod
613
+
614
+ export createRenderer = (opts = {}) ->
615
+ { router, app, components, resolver, compile, target, onError } = opts
616
+
617
+ container = if typeof target is 'string'
618
+ document.querySelector(target)
619
+ else
620
+ target or document.getElementById('app')
621
+
622
+ unless container
623
+ container = document.createElement('div')
624
+ container.id = 'app'
625
+ document.body.appendChild container
626
+
627
+ # Fade in after first mount (prevents layout-before-content flicker)
628
+ container.style.opacity = '0'
629
+
630
+ currentComponent = null
631
+ currentRoute = null
632
+ currentLayouts = []
633
+ layoutInstances = []
634
+ mountPoint = container
635
+ generation = 0
636
+ disposeEffect = null
637
+ componentCache = new Map()
638
+ maxCacheSize = opts.cacheSize or 10
639
+
640
+ cacheComponent = ->
641
+ if currentComponent and currentRoute
642
+ currentComponent.beforeUnmount() if currentComponent.beforeUnmount
643
+ componentCache.set currentRoute, currentComponent
644
+ # Evict oldest if over limit
645
+ if componentCache.size > maxCacheSize
646
+ oldest = componentCache.keys().next().value
647
+ evicted = componentCache.get(oldest)
648
+ evicted.unmounted() if evicted.unmounted
649
+ componentCache.delete oldest
650
+ # Don't remove _root here — leave visible until new content is ready
651
+ currentComponent = null
652
+ currentRoute = null
653
+
654
+ unmount = ->
655
+ cacheComponent()
656
+ for inst in layoutInstances by -1
657
+ inst.beforeUnmount() if inst.beforeUnmount
658
+ inst.unmounted() if inst.unmounted
659
+ inst._root?.remove()
660
+ layoutInstances = []
661
+ mountPoint = container
662
+
663
+ # Invalidate cached components when their source changes (HMR)
664
+ components.watch (event, path) ->
665
+ if componentCache.has(path)
666
+ evicted = componentCache.get(path)
667
+ evicted.unmounted() if evicted.unmounted
668
+ componentCache.delete path
669
+
670
+ mountRoute = (info) ->
671
+ { route, params, layouts: layoutFiles, query } = info
672
+ return unless route
673
+ return if route.file is currentRoute # already showing this route
674
+
675
+ gen = ++generation
676
+ router.navigating = true
677
+
678
+ try
679
+ source = components.read(route.file)
680
+ unless source
681
+ onError({ status: 404, message: "File not found: #{route.file}" }) if onError
682
+ router.navigating = false
683
+ return
684
+
685
+ mod = compileAndImport! source, compile, components, route.file, resolver
686
+ if gen isnt generation then router.navigating = false; return
687
+
688
+ Component = findComponent(mod)
689
+ unless Component
690
+ onError({ status: 500, message: "No component found in #{route.file}" }) if onError
691
+ router.navigating = false
692
+ return
693
+
694
+ layoutsChanged = not arraysEqual(layoutFiles, currentLayouts)
695
+ oldRoot = currentComponent?._root
696
+
697
+ if layoutsChanged
698
+ unmount()
699
+ else
700
+ cacheComponent()
701
+
702
+ mp = if layoutsChanged then container else mountPoint
703
+
704
+ if layoutsChanged and layoutFiles.length > 0
705
+ container.innerHTML = ''
706
+ mp = container
707
+
708
+ for layoutFile in layoutFiles
709
+ layoutSource = components.read(layoutFile)
710
+ continue unless layoutSource
711
+ layoutMod = compileAndImport! layoutSource, compile, components, layoutFile, resolver
712
+ if gen isnt generation then router.navigating = false; return
713
+
714
+ LayoutClass = findComponent(layoutMod)
715
+ continue unless LayoutClass
716
+
717
+ inst = new LayoutClass { app, params, router }
718
+ inst.beforeMount() if inst.beforeMount
719
+ wrapper = document.createElement('div')
720
+ wrapper.setAttribute 'data-layout', layoutFile
721
+ mp.appendChild wrapper
722
+ inst.mount wrapper
723
+ layoutInstances.push inst
724
+
725
+ slot = wrapper.querySelector('#content') or wrapper
726
+ mp = slot
727
+
728
+ currentLayouts = [...layoutFiles]
729
+ mountPoint = mp
730
+ else if layoutsChanged
731
+ container.innerHTML = ''
732
+ currentLayouts = []
733
+ mountPoint = container
734
+
735
+ # Check component cache for a preserved instance
736
+ cached = componentCache.get(route.file)
737
+ if cached
738
+ componentCache.delete route.file
739
+ mp.appendChild cached._root
740
+ currentComponent = cached
741
+ currentRoute = route.file
742
+ else
743
+ pageWrapper = document.createElement('div')
744
+ pageWrapper.setAttribute 'data-component', route.file
745
+ mp.appendChild pageWrapper
746
+
747
+ instance = new Component { app, params, query, router }
748
+ instance.beforeMount() if instance.beforeMount
749
+ instance.mount pageWrapper
750
+ currentComponent = instance
751
+ currentRoute = route.file
752
+
753
+ instance.load!(params, query) if instance.load
754
+ oldRoot?.remove()
755
+ router.navigating = false
756
+ if container.style.opacity is '0'
757
+ document.fonts.ready.then ->
758
+ requestAnimationFrame ->
759
+ container.style.transition = 'opacity 150ms ease-in'
760
+ container.style.opacity = '1'
761
+
762
+ catch err
763
+ router.navigating = false
764
+ container.style.opacity = '1'
765
+ console.error "Renderer: error mounting #{route.file}:", err
766
+ onError({ status: 500, message: err.message, error: err }) if onError
767
+
768
+ # Walk layout chain for an error boundary (component with onError method)
769
+ handled = false
770
+ for inst in layoutInstances by -1
771
+ if inst.onError
772
+ try
773
+ inst.onError(err)
774
+ handled = true
775
+ break
776
+ catch boundaryErr
777
+ console.error "Renderer: error boundary failed:", boundaryErr
778
+
779
+ unless handled
780
+ pre = document.createElement('pre')
781
+ pre.style.cssText = 'color:red;padding:1em'
782
+ pre.textContent = err.stack or err.message
783
+ container.innerHTML = ''
784
+ container.appendChild pre
785
+
786
+ renderer =
787
+ start: ->
788
+ disposeEffect = __effect ->
789
+ current = router.current
790
+ mountRoute(current) if current.route
791
+ router.init()
792
+ renderer
793
+
794
+ stop: ->
795
+ unmount()
796
+ if disposeEffect
797
+ disposeEffect()
798
+ disposeEffect = null
799
+ container.innerHTML = ''
800
+
801
+ remount: ->
802
+ current = router.current
803
+ mountRoute(current) if current.route
804
+
805
+ cache: componentCache
806
+
807
+ renderer
808
+
809
+ # ==============================================================================
810
+ # Launch — fetch an app bundle, populate the stash, start everything
811
+ # ==============================================================================
812
+
813
+ export launch = (appBase = '', opts = {}) ->
814
+ appBase = appBase.replace(/\/+$/, '') # strip trailing slashes
815
+ target = opts.target or '#app'
816
+ compile = opts.compile or null
817
+ persist = opts.persist or false
818
+ hash = opts.hash or false
819
+
820
+ # Auto-detect compile function from the global rip.js module
821
+ unless compile
822
+ compile = globalThis?.compileToJS or null
823
+
824
+ # Auto-create target element
825
+ if typeof document isnt 'undefined' and not document.querySelector(target)
826
+ el = document.createElement('div')
827
+ el.id = target.replace(/^#/, '')
828
+ document.body.prepend el
829
+
830
+ # Get the app bundle — inline, from DOM, or fetch from server
831
+ if opts.bundle
832
+ bundle = opts.bundle
833
+ else
834
+ bundleUrl = "#{appBase}/bundle"
835
+ res = await fetch(bundleUrl)
836
+ throw new Error "launch: #{bundleUrl} (#{res.status})" unless res.ok
837
+ bundle = res.json!
838
+
839
+ # Create the unified stash
840
+ app = stash { components: {}, routes: {}, data: {} }
841
+
842
+ # Hydrate from bundle — any keys populate the stash
843
+ app.data = bundle.data if bundle.data
844
+ if bundle.routes
845
+ app.routes = bundle.routes
846
+
847
+ # Restore persisted state (overrides bundle defaults with saved user state)
848
+ if persist and typeof sessionStorage isnt 'undefined'
849
+ _storageKey = "__rip_#{appBase}"
850
+ _storage = if persist is 'local' then localStorage else sessionStorage
851
+ try
852
+ saved = _storage.getItem(_storageKey)
853
+ if saved
854
+ savedData = JSON.parse(saved)
855
+ app.data[k] = v for k, v of savedData
856
+ catch
857
+ null
858
+ # Auto-save: debounce 2s after any stash write. Also save on unload.
859
+ _save = ->
860
+ try _storage.setItem _storageKey, JSON.stringify(raw(app.data))
861
+ catch then null
862
+ __effect ->
863
+ _writeVersion.value
864
+ t = setTimeout _save, 2000
865
+ -> clearTimeout t
866
+ window.addEventListener 'beforeunload', _save
867
+
868
+ # Create components store and load component sources
869
+ appComponents = createComponents()
870
+ appComponents.load(bundle.components) if bundle.components
871
+
872
+ # Build component resolver — name-to-path map + app-scoped class registry
873
+ classesKey = "__rip_#{appBase.replace(/\//g, '_') or 'app'}"
874
+ resolver = { map: buildComponentMap(appComponents), classes: {}, key: classesKey }
875
+ globalThis[classesKey] = resolver.classes if typeof globalThis isnt 'undefined'
876
+
877
+ # Set document title
878
+ document.title = app.data.title if app.data.title and typeof document isnt 'undefined'
879
+
880
+ # Create router
881
+ router = createRouter appComponents,
882
+ root: 'components'
883
+ base: appBase
884
+ hash: hash
885
+ onError: (err) -> console.error "[Rip] Error #{err.status}: #{err.message or err.path}"
886
+
887
+ # Create renderer
888
+ renderer = createRenderer
889
+ router: router
890
+ app: app
891
+ components: appComponents
892
+ resolver: resolver
893
+ compile: compile
894
+ target: target
895
+ onError: (err) -> console.error "[Rip] #{err.message}", err.error
896
+
897
+ # Start
898
+ renderer.start()
899
+
900
+ # Connect SSE watch if enabled
901
+ if bundle.data?.watch
902
+ connectWatch appComponents, router, renderer, "#{appBase}/watch", appBase
903
+
904
+ # Expose for console and dev tools
905
+ if typeof window isnt 'undefined'
906
+ window.app = app
907
+ window.__RIP__ =
908
+ app: app
909
+ components: appComponents
910
+ router: router
911
+ renderer: renderer
912
+ cache: renderer.cache
913
+ version: '0.3.0'
914
+
915
+ { app, components: appComponents, router, renderer }
916
+
917
+ # ==============================================================================
918
+ # SSE Watch — hot-reload connection
919
+ # ==============================================================================
920
+
921
+ connectWatch = (components, router, renderer, url, base = '') ->
922
+ retryDelay = 1000
923
+ maxDelay = 30000
924
+
925
+ connect = ->
926
+ es = new EventSource(url)
927
+
928
+ es.addEventListener 'connected', ->
929
+ retryDelay = 1000 # reset backoff on successful connection
930
+ console.log '[Rip] Hot reload connected'
931
+
932
+ es.addEventListener 'changed', (e) ->
933
+ { paths } = JSON.parse(e.data)
934
+ components.del(path) for path in paths
935
+ router.rebuild()
936
+
937
+ current = router.current
938
+ toFetch = paths.filter (p) ->
939
+ p is current.route?.file or current.layouts?.includes(p)
940
+
941
+ if toFetch.length > 0
942
+ results = await Promise.allSettled(toFetch.map (path) ->
943
+ res = await fetch(base + '/' + path)
944
+ content = res.text!
945
+ components.write path, content
946
+ )
947
+ failed = results.filter (r) -> r.status is 'rejected'
948
+ console.error '[Rip] Hot reload fetch error:', r.reason for r in failed
949
+ renderer.remount()
950
+
951
+ es.onerror = ->
952
+ es.close()
953
+ console.log "[Rip] Hot reload reconnecting in #{retryDelay / 1000}s..."
954
+ setTimeout connect, retryDelay
955
+ retryDelay = Math.min(retryDelay * 2, maxDelay)
956
+
957
+ connect()