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