@rip-lang/ui 0.3.20 → 0.3.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +442 -572
  2. package/accordion.rip +113 -0
  3. package/alert-dialog.rip +96 -0
  4. package/autocomplete.rip +141 -0
  5. package/avatar.rip +37 -0
  6. package/badge.rip +15 -0
  7. package/breadcrumb.rip +46 -0
  8. package/button-group.rip +26 -0
  9. package/button.rip +23 -0
  10. package/card.rip +25 -0
  11. package/carousel.rip +110 -0
  12. package/checkbox-group.rip +65 -0
  13. package/checkbox.rip +33 -0
  14. package/collapsible.rip +50 -0
  15. package/combobox.rip +155 -0
  16. package/context-menu.rip +105 -0
  17. package/date-picker.rip +214 -0
  18. package/dialog.rip +107 -0
  19. package/drawer.rip +79 -0
  20. package/editable-value.rip +80 -0
  21. package/field.rip +53 -0
  22. package/fieldset.rip +22 -0
  23. package/form.rip +39 -0
  24. package/grid.rip +901 -0
  25. package/index.rip +16 -0
  26. package/input-group.rip +28 -0
  27. package/input.rip +36 -0
  28. package/label.rip +16 -0
  29. package/menu.rip +162 -0
  30. package/menubar.rip +155 -0
  31. package/meter.rip +36 -0
  32. package/multi-select.rip +158 -0
  33. package/native-select.rip +32 -0
  34. package/nav-menu.rip +129 -0
  35. package/number-field.rip +162 -0
  36. package/otp-field.rip +89 -0
  37. package/package.json +18 -27
  38. package/pagination.rip +123 -0
  39. package/popover.rip +143 -0
  40. package/preview-card.rip +73 -0
  41. package/progress.rip +25 -0
  42. package/radio-group.rip +67 -0
  43. package/resizable.rip +123 -0
  44. package/scroll-area.rip +145 -0
  45. package/select.rip +184 -0
  46. package/separator.rip +17 -0
  47. package/skeleton.rip +22 -0
  48. package/slider.rip +165 -0
  49. package/spinner.rip +17 -0
  50. package/table.rip +27 -0
  51. package/tabs.rip +124 -0
  52. package/textarea.rip +48 -0
  53. package/toast.rip +87 -0
  54. package/toggle-group.rip +78 -0
  55. package/toggle.rip +24 -0
  56. package/toolbar.rip +46 -0
  57. package/tooltip.rip +115 -0
  58. package/dist/rip-ui.min.js +0 -522
  59. package/dist/rip-ui.min.js.br +0 -0
  60. package/serve.rip +0 -92
  61. package/ui.rip +0 -964
package/ui.rip DELETED
@@ -1,964 +0,0 @@
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
- # Skip files in _-prefixed directories (shared components, not pages)
364
- segs = rel.split('/')
365
- continue if segs.length > 1 and segs.some((s, i) -> i < segs.length - 1 and s.startsWith('_'))
366
-
367
- urlPattern = fileToPattern(rel)
368
- regex = patternToRegex(urlPattern)
369
- routes.push { pattern: urlPattern, regex, file: filePath, rel }
370
-
371
- # Sort: static first, then fewest dynamic segments, catch-all last
372
- routes.sort (a, b) ->
373
- aDyn = (a.pattern.match(/:/g) or []).length
374
- bDyn = (b.pattern.match(/:/g) or []).length
375
- aCatch = if a.pattern.includes('*') then 1 else 0
376
- bCatch = if b.pattern.includes('*') then 1 else 0
377
- return aCatch - bCatch if aCatch isnt bCatch
378
- return aDyn - bDyn if aDyn isnt bDyn
379
- a.pattern.localeCompare(b.pattern)
380
-
381
- { routes, layouts }
382
-
383
- getLayoutChain = (routeFile, root, layouts) ->
384
- chain = []
385
- rel = routeFile.slice(root.length + 1)
386
- segments = rel.split('/')
387
- dir = ''
388
-
389
- chain.push layouts.get('') if layouts.has('')
390
- for seg, i in segments
391
- break if i is segments.length - 1
392
- dir = if dir then dir + '/' + seg else seg
393
- chain.push layouts.get(dir) if layouts.has(dir)
394
- chain
395
-
396
- export createRouter = (components, opts = {}) ->
397
- root = opts.root or 'components'
398
- base = opts.base or ''
399
- hashMode = opts.hash or false
400
- onError = opts.onError or null
401
-
402
- stripBase = (url) ->
403
- if base and url.startsWith(base) then url.slice(base.length) or '/' else url
404
-
405
- addBase = (path) ->
406
- if base then base + path else path
407
-
408
- readUrl = ->
409
- if hashMode
410
- h = location.hash.slice(1)
411
- return '/' unless h
412
- if h[0] is '/' then h else '/' + h
413
- else
414
- location.pathname + location.search + location.hash
415
-
416
- writeUrl = (path) ->
417
- if hashMode
418
- if path is '/' then location.pathname else '#' + path.slice(1)
419
- else
420
- addBase(path)
421
-
422
- _path = __state(stripBase(if hashMode then readUrl() else location.pathname))
423
- _params = __state({})
424
- _route = __state(null)
425
- _layouts = __state([])
426
- _query = __state({})
427
- _hash = __state('')
428
- _navigating = delay 100, __state(false)
429
-
430
- tree = buildRoutes(components, root)
431
- navCallbacks = new Set()
432
-
433
- components.watch (event, path) ->
434
- return unless path.startsWith(root + '/')
435
- tree = buildRoutes(components, root)
436
-
437
- resolve = (url) ->
438
- rawPath = url.split('?')[0].split('#')[0]
439
- path = stripBase(rawPath)
440
- path = if path[0] is '/' then path else '/' + path
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(readUrl())
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
- dest = if hashMode and url.hash then (url.hash.slice(1) or '/') else (url.pathname + url.search + url.hash)
472
- router.push dest
473
-
474
- document.addEventListener 'click', onClick if typeof document isnt 'undefined'
475
-
476
- router =
477
- push: (url) ->
478
- if resolve(url)
479
- history.pushState null, '', writeUrl(_path.read())
480
-
481
- replace: (url) ->
482
- if resolve(url)
483
- history.replaceState null, '', writeUrl(_path.read())
484
-
485
- back: -> history.back()
486
- forward: -> history.forward()
487
-
488
- current: undefined # overridden by getter
489
- path: undefined
490
- params: undefined
491
- route: undefined
492
- layouts: undefined
493
- query: undefined
494
- hash: undefined
495
- navigating: undefined
496
-
497
- onNavigate: (cb) ->
498
- navCallbacks.add cb
499
- -> navCallbacks.delete cb
500
-
501
- rebuild: -> tree = buildRoutes(components, root)
502
-
503
- routes: undefined # overridden by getter
504
-
505
- init: ->
506
- resolve readUrl()
507
- router
508
-
509
- destroy: ->
510
- window.removeEventListener 'popstate', onPopState if typeof window isnt 'undefined'
511
- document.removeEventListener 'click', onClick if typeof document isnt 'undefined'
512
- navCallbacks.clear()
513
-
514
- Object.defineProperty router, 'current', get: ->
515
- { path: _path.value, params: _params.value, route: _route.value, layouts: _layouts.value, query: _query.value, hash: _hash.value }
516
-
517
- Object.defineProperty router, 'path', get: -> _path.value
518
- Object.defineProperty router, 'params', get: -> _params.value
519
- Object.defineProperty router, 'route', get: -> _route.value
520
- Object.defineProperty router, 'layouts', get: -> _layouts.value
521
- Object.defineProperty router, 'query', get: -> _query.value
522
- Object.defineProperty router, 'hash', get: -> _hash.value
523
- Object.defineProperty router, 'navigating',
524
- get: -> _navigating.value
525
- set: (v) -> _navigating.value = v
526
- Object.defineProperty router, 'routes', get: -> tree.routes
527
-
528
- router
529
-
530
- # ==============================================================================
531
- # Renderer — compile, import, mount/unmount, layouts, slots
532
- # ==============================================================================
533
-
534
- arraysEqual = (a, b) ->
535
- return false if a.length isnt b.length
536
- for item, i in a
537
- return false if item isnt b[i]
538
- true
539
-
540
- findComponent = (mod) ->
541
- for key, val of mod
542
- return val if typeof val is 'function' and (val.prototype?.mount or val.prototype?._create)
543
- mod.default if typeof mod.default is 'function'
544
-
545
- # --------------------------------------------------------------------------
546
- # Component resolution — name discovery, lazy compilation, class registry
547
- # --------------------------------------------------------------------------
548
-
549
- findAllComponents = (mod) ->
550
- result = {}
551
- for key, val of mod
552
- if typeof val is 'function' and (val.prototype?.mount or val.prototype?._create)
553
- result[key] = val
554
- result
555
-
556
- # Convert file path to PascalCase component name
557
- # components/card.rip → Card, components/todo-item.rip → TodoItem
558
- fileToComponentName = (filePath) ->
559
- name = filePath.split('/').pop().replace(/\.rip$/, '')
560
- name.replace /(^|[-_])([a-z])/g, (_, sep, ch) -> ch.toUpperCase()
561
-
562
- # Build name-to-path map from component store
563
- buildComponentMap = (components, root = 'components') ->
564
- map = {}
565
- for path in components.listAll(root)
566
- continue unless path.endsWith('.rip')
567
- fileName = path.split('/').pop()
568
- continue if fileName.startsWith('_')
569
- name = fileToComponentName(path)
570
- if map[name]
571
- console.warn "[Rip] Component name collision: #{name} (#{map[name]} vs #{path})"
572
- map[name] = path
573
- map
574
-
575
- compileAndImport = (source, compile, components = null, path = null, resolver = null) ->
576
- # Check compilation cache
577
- if components and path
578
- cached = components.getCompiled(path)
579
- return cached if cached
580
-
581
- js = compile(source)
582
-
583
- # Resolve component dependencies — scan for PascalCase references in compiled JS
584
- if resolver
585
- needed = {}
586
- for name, depPath of resolver.map
587
- if depPath isnt path and js.includes("new #{name}(")
588
- unless resolver.classes[name]
589
- depSource = components.read(depPath)
590
- if depSource
591
- depMod = compileAndImport! depSource, compile, components, depPath, resolver
592
- found = findAllComponents(depMod)
593
- resolver.classes[k] = v for k, v of found
594
- needed[name] = true if resolver.classes[name]
595
-
596
- # Inject resolved components into scope via preamble
597
- names = Object.keys(needed)
598
- if names.length > 0
599
- preamble = "const {#{names.join(', ')}} = globalThis['#{resolver.key}'];\n"
600
- js = preamble + js
601
-
602
- blob = new Blob([js], { type: 'application/javascript' })
603
- url = URL.createObjectURL(blob)
604
- try
605
- mod = await import(url)
606
- finally
607
- URL.revokeObjectURL url
608
-
609
- # Register any components from this module
610
- if resolver
611
- found = findAllComponents(mod)
612
- resolver.classes[k] = v for k, v of found
613
-
614
- # Store in cache
615
- components.setCompiled(path, mod) if components and path
616
- mod
617
-
618
- export createRenderer = (opts = {}) ->
619
- { router, app, components, resolver, compile, target, onError } = opts
620
-
621
- container = if typeof target is 'string'
622
- document.querySelector(target)
623
- else
624
- target or document.getElementById('app')
625
-
626
- unless container
627
- container = document.createElement('div')
628
- container.id = 'app'
629
- document.body.appendChild container
630
-
631
- # Fade in after first mount (prevents layout-before-content flicker)
632
- container.style.opacity = '0'
633
-
634
- currentComponent = null
635
- currentRoute = null
636
- currentLayouts = []
637
- layoutInstances = []
638
- mountPoint = container
639
- generation = 0
640
- disposeEffect = null
641
- componentCache = new Map()
642
- maxCacheSize = opts.cacheSize or 10
643
-
644
- cacheComponent = ->
645
- if currentComponent and currentRoute
646
- currentComponent.beforeUnmount() if currentComponent.beforeUnmount
647
- componentCache.set currentRoute, currentComponent
648
- # Evict oldest if over limit
649
- if componentCache.size > maxCacheSize
650
- oldest = componentCache.keys().next().value
651
- evicted = componentCache.get(oldest)
652
- evicted.unmounted() if evicted.unmounted
653
- componentCache.delete oldest
654
- # Don't remove _root here — leave visible until new content is ready
655
- currentComponent = null
656
- currentRoute = null
657
-
658
- unmount = ->
659
- cacheComponent()
660
- for inst in layoutInstances by -1
661
- inst.beforeUnmount() if inst.beforeUnmount
662
- inst.unmounted() if inst.unmounted
663
- inst._root?.remove()
664
- layoutInstances = []
665
- mountPoint = container
666
-
667
- # Invalidate cached components when their source changes (HMR)
668
- components.watch (event, path) ->
669
- if componentCache.has(path)
670
- evicted = componentCache.get(path)
671
- evicted.unmounted() if evicted.unmounted
672
- componentCache.delete path
673
-
674
- mountRoute = (info) ->
675
- { route, params, layouts: layoutFiles, query } = info
676
- return unless route
677
- return if route.file is currentRoute # already showing this route
678
-
679
- gen = ++generation
680
- router.navigating = true
681
-
682
- try
683
- source = components.read(route.file)
684
- unless source
685
- onError({ status: 404, message: "File not found: #{route.file}" }) if onError
686
- router.navigating = false
687
- return
688
-
689
- mod = compileAndImport! source, compile, components, route.file, resolver
690
- if gen isnt generation then router.navigating = false; return
691
-
692
- Component = findComponent(mod)
693
- unless Component
694
- onError({ status: 500, message: "No component found in #{route.file}" }) if onError
695
- router.navigating = false
696
- return
697
-
698
- layoutsChanged = not arraysEqual(layoutFiles, currentLayouts)
699
- oldRoot = currentComponent?._root
700
-
701
- if layoutsChanged
702
- unmount()
703
- else
704
- cacheComponent()
705
-
706
- mp = if layoutsChanged then container else mountPoint
707
-
708
- if layoutsChanged and layoutFiles.length > 0
709
- container.innerHTML = ''
710
- mp = container
711
-
712
- for layoutFile in layoutFiles
713
- layoutSource = components.read(layoutFile)
714
- continue unless layoutSource
715
- layoutMod = compileAndImport! layoutSource, compile, components, layoutFile, resolver
716
- if gen isnt generation then router.navigating = false; return
717
-
718
- LayoutClass = findComponent(layoutMod)
719
- continue unless LayoutClass
720
-
721
- inst = new LayoutClass { app, params, router }
722
- inst.beforeMount() if inst.beforeMount
723
- wrapper = document.createElement('div')
724
- wrapper.setAttribute 'data-layout', layoutFile
725
- mp.appendChild wrapper
726
- inst.mount wrapper
727
- layoutInstances.push inst
728
-
729
- slot = wrapper.querySelector('#content') or wrapper
730
- mp = slot
731
-
732
- currentLayouts = [...layoutFiles]
733
- mountPoint = mp
734
- else if layoutsChanged
735
- container.innerHTML = ''
736
- currentLayouts = []
737
- mountPoint = container
738
-
739
- # Check component cache for a preserved instance
740
- cached = componentCache.get(route.file)
741
- if cached
742
- componentCache.delete route.file
743
- mp.appendChild cached._root
744
- currentComponent = cached
745
- currentRoute = route.file
746
- else
747
- pageWrapper = document.createElement('div')
748
- pageWrapper.setAttribute 'data-component', route.file
749
- mp.appendChild pageWrapper
750
-
751
- instance = new Component { app, params, query, router }
752
- instance.beforeMount() if instance.beforeMount
753
- instance.mount pageWrapper
754
- currentComponent = instance
755
- currentRoute = route.file
756
-
757
- instance.load!(params, query) if instance.load
758
- oldRoot?.remove()
759
- router.navigating = false
760
- if container.style.opacity is '0'
761
- document.fonts.ready.then ->
762
- requestAnimationFrame ->
763
- container.style.transition = 'opacity 150ms ease-in'
764
- container.style.opacity = '1'
765
-
766
- catch err
767
- router.navigating = false
768
- container.style.opacity = '1'
769
- console.error "Renderer: error mounting #{route.file}:", err
770
- onError({ status: 500, message: err.message, error: err }) if onError
771
-
772
- # Walk layout chain for an error boundary (component with onError method)
773
- handled = false
774
- for inst in layoutInstances by -1
775
- if inst.onError
776
- try
777
- inst.onError(err)
778
- handled = true
779
- break
780
- catch boundaryErr
781
- console.error "Renderer: error boundary failed:", boundaryErr
782
-
783
- unless handled
784
- pre = document.createElement('pre')
785
- pre.style.cssText = 'color:red;padding:1em'
786
- pre.textContent = err.stack or err.message
787
- container.innerHTML = ''
788
- container.appendChild pre
789
-
790
- renderer =
791
- start: ->
792
- disposeEffect = __effect ->
793
- current = router.current
794
- mountRoute(current) if current.route
795
- router.init()
796
- renderer
797
-
798
- stop: ->
799
- unmount()
800
- if disposeEffect
801
- disposeEffect()
802
- disposeEffect = null
803
- container.innerHTML = ''
804
-
805
- remount: ->
806
- current = router.current
807
- mountRoute(current) if current.route
808
-
809
- cache: componentCache
810
-
811
- renderer
812
-
813
- # ==============================================================================
814
- # Launch — fetch an app bundle, populate the stash, start everything
815
- # ==============================================================================
816
-
817
- export launch = (appBase = '', opts = {}) ->
818
- globalThis.__ripLaunched = true
819
- if typeof appBase is 'object'
820
- opts = appBase
821
- appBase = ''
822
- appBase = appBase.replace(/\/+$/, '') # strip trailing slashes
823
- target = opts.target or '#app'
824
- compile = opts.compile or null
825
- persist = opts.persist or false
826
- hash = opts.hash or false
827
-
828
- # Auto-detect compile function from the global rip.js module
829
- unless compile
830
- compile = globalThis?.compileToJS or null
831
-
832
- # Auto-create target element
833
- if typeof document isnt 'undefined' and not document.querySelector(target)
834
- el = document.createElement('div')
835
- el.id = target.replace(/^#/, '')
836
- document.body.prepend el
837
-
838
- # Get the app bundle — explicit, static files, inline DOM, or server fetch
839
- if opts.bundle
840
- bundle = opts.bundle
841
- else if opts.components and Array.isArray(opts.components)
842
- components = {}
843
- for url in opts.components
844
- res = await fetch(url)
845
- if res.ok
846
- name = url.split('/').pop()
847
- components["components/#{name}"] = await res.text()
848
- bundle = { components, data: {} }
849
- else if typeof document isnt 'undefined' and document.querySelectorAll('script[type="text/rip"][data-name]').length > 0
850
- components = {}
851
- for script in document.querySelectorAll('script[type="text/rip"][data-name]')
852
- name = script.getAttribute('data-name')
853
- name += '.rip' unless name.endsWith('.rip')
854
- components["components/#{name}"] = script.textContent
855
- bundle = { components, data: {} }
856
- else
857
- bundleUrl = "#{appBase}/bundle"
858
- res = await fetch(bundleUrl)
859
- throw new Error "launch: #{bundleUrl} (#{res.status})" unless res.ok
860
- bundle = res.json!
861
-
862
- # Create the unified stash
863
- app = stash { components: {}, routes: {}, data: {} }
864
-
865
- # Hydrate from bundle — any keys populate the stash
866
- app.data = bundle.data if bundle.data
867
- if bundle.routes
868
- app.routes = bundle.routes
869
-
870
- # Restore persisted state (overrides bundle defaults with saved user state)
871
- if persist and typeof sessionStorage isnt 'undefined'
872
- _storageKey = "__rip_#{appBase}"
873
- _storage = if persist is 'local' then localStorage else sessionStorage
874
- try
875
- saved = _storage.getItem(_storageKey)
876
- if saved
877
- savedData = JSON.parse(saved)
878
- app.data[k] = v for k, v of savedData
879
- catch
880
- null
881
- # Auto-save: debounce 2s after any stash write. Also save on unload.
882
- _save = ->
883
- try _storage.setItem _storageKey, JSON.stringify(raw(app.data))
884
- catch then null
885
- __effect ->
886
- _writeVersion.value
887
- t = setTimeout _save, 2000
888
- -> clearTimeout t
889
- window.addEventListener 'beforeunload', _save
890
-
891
- # Create components store and load component sources
892
- appComponents = createComponents()
893
- appComponents.load(bundle.components) if bundle.components
894
-
895
- # Build component resolver — name-to-path map + app-scoped class registry
896
- classesKey = "__rip_#{appBase.replace(/\//g, '_') or 'app'}"
897
- resolver = { map: buildComponentMap(appComponents), classes: {}, key: classesKey }
898
- globalThis[classesKey] = resolver.classes if typeof globalThis isnt 'undefined'
899
-
900
- # Set document title
901
- document.title = app.data.title if app.data.title and typeof document isnt 'undefined'
902
-
903
- # Create router
904
- router = createRouter appComponents,
905
- root: 'components'
906
- base: appBase
907
- hash: hash
908
- onError: (err) -> console.error "[Rip] Error #{err.status}: #{err.message or err.path}"
909
-
910
- # Create renderer
911
- renderer = createRenderer
912
- router: router
913
- app: app
914
- components: appComponents
915
- resolver: resolver
916
- compile: compile
917
- target: target
918
- onError: (err) -> console.error "[Rip] #{err.message}", err.error
919
-
920
- # Start
921
- renderer.start()
922
-
923
- # Connect SSE watch if enabled
924
- if bundle.data?.watch
925
- connectWatch "#{appBase}/watch"
926
-
927
- # Expose for console and dev tools
928
- if typeof window isnt 'undefined'
929
- window.app = app
930
- window.__RIP__ =
931
- app: app
932
- components: appComponents
933
- router: router
934
- renderer: renderer
935
- cache: renderer.cache
936
- version: '0.3.0'
937
-
938
- { app, components: appComponents, router, renderer }
939
-
940
- # ==============================================================================
941
- # SSE Watch — hot-reload connection
942
- # ==============================================================================
943
-
944
- connectWatch = (url) ->
945
- retryDelay = 1000
946
- maxDelay = 30000
947
-
948
- connect = ->
949
- es = new EventSource(url)
950
-
951
- es.addEventListener 'connected', ->
952
- retryDelay = 1000
953
- console.log '[Rip] Hot reload connected'
954
-
955
- es.addEventListener 'reload', ->
956
- console.log '[Rip] Reloading...'
957
- location.reload()
958
-
959
- es.onerror = ->
960
- es.close()
961
- setTimeout connect, retryDelay
962
- retryDelay = Math.min(retryDelay * 2, maxDelay)
963
-
964
- connect()