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