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