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