rip-lang 3.15.4 → 3.16.1
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 +6 -4
- package/bin/rip +167 -12
- package/docs/AGENTS.md +1 -1
- package/docs/RIP-APP.md +808 -0
- package/docs/RIP-DUCKDB.md +477 -0
- package/docs/RIP-INTRO.md +396 -0
- package/docs/RIP-LANG.md +59 -5
- package/docs/RIP-SCHEMA.md +191 -8
- package/docs/RIP-TYPES.md +74 -103
- package/docs/demo/README.md +4 -3
- package/docs/dist/rip.js +3627 -1470
- package/docs/dist/rip.min.js +671 -244
- package/docs/dist/rip.min.js.br +0 -0
- package/docs/example/index.json +7 -7
- package/docs/example/index.json.br +0 -0
- package/docs/extensions/duckdb/manifest.json +1 -1
- package/docs/extensions/duckdb/v1.5.2/linux_amd64/ripdb.duckdb_extension.gz +0 -0
- package/docs/extensions/duckdb/v1.5.2/osx_arm64/ripdb.duckdb_extension.gz +0 -0
- package/docs/extensions/vscode/print/index.html +2 -1
- package/docs/extensions/vscode/print/print-1.0.13.vsix +0 -0
- package/docs/extensions/vscode/print/print-1.0.14.vsix +0 -0
- package/docs/extensions/vscode/print/print-latest.vsix +0 -0
- package/docs/extensions/vscode/rip/rip-0.5.15.vsix +0 -0
- package/docs/extensions/vscode/rip/rip-latest.vsix +0 -0
- package/docs/ui/bundle.json +61 -0
- package/docs/ui/bundle.json.br +0 -0
- package/docs/ui/hljs-rip.js +0 -7
- package/docs/ui/index.css +66 -23
- package/docs/ui/index.html +6 -6
- package/package.json +9 -3
- package/rip-loader.js +64 -2
- package/src/AGENTS.md +63 -36
- package/src/browser.js +96 -14
- package/src/compiler.js +960 -143
- package/src/components.js +794 -88
- package/src/{types-emit.js → dts.js} +181 -71
- package/src/grammar/README.md +1 -1
- package/src/grammar/grammar.rip +111 -97
- package/src/lexer.js +132 -18
- package/src/parser.js +203 -205
- package/src/repl.js +74 -6
- package/src/schema/runtime-orm.js +168 -4
- package/src/schema/runtime-validate.js +146 -2
- package/src/schema/runtime.generated.js +314 -6
- package/src/schema/schema.js +5 -5
- package/src/sourcemaps.js +277 -1
- package/src/stdlib.js +253 -0
- package/src/typecheck.js +2023 -106
- package/src/types.js +127 -7
- package/docs/ui/accordion.rip +0 -103
- package/docs/ui/alert-dialog.rip +0 -53
- package/docs/ui/autocomplete.rip +0 -115
- package/docs/ui/avatar.rip +0 -37
- package/docs/ui/badge.rip +0 -15
- package/docs/ui/breadcrumb.rip +0 -47
- package/docs/ui/button-group.rip +0 -26
- package/docs/ui/button.rip +0 -23
- package/docs/ui/card.rip +0 -25
- package/docs/ui/carousel.rip +0 -110
- package/docs/ui/checkbox-group.rip +0 -61
- package/docs/ui/checkbox.rip +0 -33
- package/docs/ui/collapsible.rip +0 -50
- package/docs/ui/combobox.rip +0 -130
- package/docs/ui/context-menu.rip +0 -88
- package/docs/ui/date-picker.rip +0 -206
- package/docs/ui/dialog.rip +0 -60
- package/docs/ui/drawer.rip +0 -58
- package/docs/ui/editable-value.rip +0 -82
- package/docs/ui/field.rip +0 -53
- package/docs/ui/fieldset.rip +0 -22
- package/docs/ui/form.rip +0 -39
- package/docs/ui/grid.rip +0 -901
- package/docs/ui/input-group.rip +0 -28
- package/docs/ui/input.rip +0 -36
- package/docs/ui/label.rip +0 -16
- package/docs/ui/menu.rip +0 -134
- package/docs/ui/menubar.rip +0 -151
- package/docs/ui/meter.rip +0 -36
- package/docs/ui/multi-select.rip +0 -203
- package/docs/ui/native-select.rip +0 -33
- package/docs/ui/nav-menu.rip +0 -126
- package/docs/ui/number-field.rip +0 -162
- package/docs/ui/otp-field.rip +0 -89
- package/docs/ui/pagination.rip +0 -123
- package/docs/ui/popover.rip +0 -93
- package/docs/ui/preview-card.rip +0 -75
- package/docs/ui/progress.rip +0 -25
- package/docs/ui/radio-group.rip +0 -57
- package/docs/ui/resizable.rip +0 -123
- package/docs/ui/scroll-area.rip +0 -145
- package/docs/ui/select.rip +0 -151
- package/docs/ui/separator.rip +0 -17
- package/docs/ui/skeleton.rip +0 -22
- package/docs/ui/slider.rip +0 -165
- package/docs/ui/spinner.rip +0 -17
- package/docs/ui/table.rip +0 -27
- package/docs/ui/tabs.rip +0 -113
- package/docs/ui/textarea.rip +0 -48
- package/docs/ui/toast.rip +0 -87
- package/docs/ui/toggle-group.rip +0 -71
- package/docs/ui/toggle.rip +0 -24
- package/docs/ui/toolbar.rip +0 -38
- package/docs/ui/tooltip.rip +0 -85
- package/src/app.rip +0 -1571
- package/src/sourcemap-merge.js +0 -287
- /package/docs/demo/{components → routes}/_layout.rip +0 -0
- /package/docs/demo/{components → routes}/about.rip +0 -0
- /package/docs/demo/{components → routes}/card.rip +0 -0
- /package/docs/demo/{components → routes}/counter.rip +0 -0
- /package/docs/demo/{components → routes}/index.rip +0 -0
- /package/docs/demo/{components → routes}/todos.rip +0 -0
- /package/src/schema/{dts-emit.js → dts.js} +0 -0
package/src/app.rip
DELETED
|
@@ -1,1571 +0,0 @@
|
|
|
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 bracket access (e.g., app['data/theme/title']) and 7 reserved methods
|
|
34
|
-
# for atomic/structural operations: inc, dec, flip, join, keys, has, del.
|
|
35
|
-
# ==============================================================================
|
|
36
|
-
|
|
37
|
-
PROXIES = new WeakMap()
|
|
38
|
-
METHODS = new WeakMap()
|
|
39
|
-
|
|
40
|
-
_keysVersion = 0
|
|
41
|
-
_writeVersion = __state(0)
|
|
42
|
-
_depth = 0
|
|
43
|
-
|
|
44
|
-
isPathKey = (prop) -> typeof prop is 'string' and (prop.indexOf('.') isnt -1 or prop.indexOf('/') isnt -1 or prop.indexOf('[') isnt -1)
|
|
45
|
-
|
|
46
|
-
isNum = (val) -> /^-?\d+$/.test val
|
|
47
|
-
|
|
48
|
-
getSignal = (target, prop) ->
|
|
49
|
-
unless target[:signals]
|
|
50
|
-
Object.defineProperty target, :signals, { value: new Map(), enumerable: false }
|
|
51
|
-
sig = target[:signals].get(prop)
|
|
52
|
-
unless sig
|
|
53
|
-
sig = __state(target[prop])
|
|
54
|
-
target[:signals].set(prop, sig)
|
|
55
|
-
sig
|
|
56
|
-
|
|
57
|
-
keysSignal = (target) -> getSignal(target, :keys)
|
|
58
|
-
|
|
59
|
-
wrapDeep = (value) ->
|
|
60
|
-
return value unless value? and typeof value is 'object'
|
|
61
|
-
return value if value[:stash]
|
|
62
|
-
return value if value instanceof Date or value instanceof RegExp or value instanceof Map or value instanceof Set or value instanceof Promise
|
|
63
|
-
existing = PROXIES.get(value)
|
|
64
|
-
return existing if existing
|
|
65
|
-
makeProxy(value)
|
|
66
|
-
|
|
67
|
-
makeProxy = (target) ->
|
|
68
|
-
proxy = null
|
|
69
|
-
handler =
|
|
70
|
-
get: (target, prop) ->
|
|
71
|
-
return true if prop is :stash
|
|
72
|
-
return target if prop is :raw
|
|
73
|
-
return Reflect.get(target, prop) if typeof prop is 'symbol'
|
|
74
|
-
|
|
75
|
-
if prop is 'length' and Array.isArray(target)
|
|
76
|
-
keysSignal(target).value
|
|
77
|
-
return target.length
|
|
78
|
-
|
|
79
|
-
if not _depth and isPathKey(prop)
|
|
80
|
-
return stashGet(proxy, prop)
|
|
81
|
-
|
|
82
|
-
fn = stashMethodFn(proxy, prop)
|
|
83
|
-
return fn if fn
|
|
84
|
-
|
|
85
|
-
sig = getSignal(target, prop)
|
|
86
|
-
val = sig.value
|
|
87
|
-
|
|
88
|
-
return wrapDeep(val) if val? and typeof val is 'object'
|
|
89
|
-
val
|
|
90
|
-
|
|
91
|
-
set: (target, prop, value) ->
|
|
92
|
-
if not _depth and isPathKey(prop)
|
|
93
|
-
stashSet(proxy, prop, value)
|
|
94
|
-
return true
|
|
95
|
-
|
|
96
|
-
old = target[prop]
|
|
97
|
-
r = if value?[:raw] then value[:raw] else value
|
|
98
|
-
return true if r is old
|
|
99
|
-
target[prop] = r
|
|
100
|
-
|
|
101
|
-
if target[:signals]?.has(prop)
|
|
102
|
-
target[:signals].get(prop).value = r
|
|
103
|
-
if old is undefined and r isnt undefined
|
|
104
|
-
keysSignal(target).value = ++_keysVersion
|
|
105
|
-
_writeVersion.value++
|
|
106
|
-
|
|
107
|
-
true
|
|
108
|
-
|
|
109
|
-
deleteProperty: (target, prop) ->
|
|
110
|
-
delete target[prop]
|
|
111
|
-
sig = target[:signals]?.get(prop)
|
|
112
|
-
sig?.value = undefined
|
|
113
|
-
keysSignal(target).value = ++_keysVersion
|
|
114
|
-
_writeVersion.value++
|
|
115
|
-
true
|
|
116
|
-
|
|
117
|
-
ownKeys: (target) ->
|
|
118
|
-
keysSignal(target).value
|
|
119
|
-
Reflect.ownKeys(target)
|
|
120
|
-
|
|
121
|
-
proxy = new Proxy(target, handler)
|
|
122
|
-
PROXIES.set(target, proxy)
|
|
123
|
-
proxy
|
|
124
|
-
|
|
125
|
-
# Path navigation
|
|
126
|
-
PATH_RE = /([./][^./\[\s]+|\[[-+]?\d+\]|\[(?:"[^"]+"|'[^']+')\])/
|
|
127
|
-
|
|
128
|
-
walk = (path) ->
|
|
129
|
-
list = ('.' + path).split(PATH_RE)
|
|
130
|
-
list.shift()
|
|
131
|
-
result = []
|
|
132
|
-
i = 0
|
|
133
|
-
while i < list.length
|
|
134
|
-
part = list[i]
|
|
135
|
-
chr = part[0]
|
|
136
|
-
if chr is '.' or chr is '/'
|
|
137
|
-
result.push part.slice(1)
|
|
138
|
-
else if chr is '['
|
|
139
|
-
if part[1] is '"' or part[1] is "'"
|
|
140
|
-
result.push part.slice(2, -2)
|
|
141
|
-
else
|
|
142
|
-
result.push +(part.slice(1, -1))
|
|
143
|
-
i += 2
|
|
144
|
-
result
|
|
145
|
-
|
|
146
|
-
resolveIndex = (seg, obj) ->
|
|
147
|
-
if typeof seg is 'number' and seg < 0
|
|
148
|
-
t = raw(obj)
|
|
149
|
-
return t.length + seg if Array.isArray(t)
|
|
150
|
-
seg
|
|
151
|
-
|
|
152
|
-
stashGet = (proxy, path) ->
|
|
153
|
-
segs = walk(path)
|
|
154
|
-
obj = proxy
|
|
155
|
-
_depth++
|
|
156
|
-
try
|
|
157
|
-
for seg in segs
|
|
158
|
-
return undefined unless obj?
|
|
159
|
-
obj = obj[resolveIndex(seg, obj)]
|
|
160
|
-
obj
|
|
161
|
-
finally
|
|
162
|
-
_depth--
|
|
163
|
-
|
|
164
|
-
stashSet = (proxy, path, value) ->
|
|
165
|
-
segs = walk(path)
|
|
166
|
-
obj = proxy
|
|
167
|
-
_depth++
|
|
168
|
-
try
|
|
169
|
-
for seg, i in segs
|
|
170
|
-
key = resolveIndex(seg, obj)
|
|
171
|
-
if i is segs.length - 1
|
|
172
|
-
obj[key] = value
|
|
173
|
-
else
|
|
174
|
-
unless obj[key]?
|
|
175
|
-
nextSeg = segs[i + 1]
|
|
176
|
-
obj[key] = if typeof nextSeg is 'number' or isNum(nextSeg) then [] else {}
|
|
177
|
-
obj = obj[key]
|
|
178
|
-
value
|
|
179
|
-
finally
|
|
180
|
-
_depth--
|
|
181
|
-
|
|
182
|
-
# Stash methods — reserved names for atomic/structural operations
|
|
183
|
-
STASH_METHOD_NAMES = { inc: true, dec: true, flip: true, join: true, keys: true, has: true, del: true }
|
|
184
|
-
|
|
185
|
-
stashMethodFn = (proxy, prop) ->
|
|
186
|
-
return undefined unless STASH_METHOD_NAMES[prop]
|
|
187
|
-
cache = METHODS.get(proxy)
|
|
188
|
-
unless cache
|
|
189
|
-
cache = {}
|
|
190
|
-
METHODS.set(proxy, cache)
|
|
191
|
-
return cache[prop] if cache[prop]
|
|
192
|
-
fn = switch prop
|
|
193
|
-
when 'inc' then (path, step = 1) -> cur = stashGet(proxy, path) ?? 0; stashSet(proxy, path, cur + step)
|
|
194
|
-
when 'dec' then (path, step = 1) -> cur = stashGet(proxy, path) ?? 0; stashSet(proxy, path, cur - step)
|
|
195
|
-
when 'flip' then (path) -> cur = stashGet(proxy, path) ?? false; stashSet(proxy, path, not cur)
|
|
196
|
-
when 'join' then (path, obj) ->
|
|
197
|
-
__batch ->
|
|
198
|
-
target = stashGet(proxy, path)
|
|
199
|
-
unless target? and typeof target is 'object'
|
|
200
|
-
stashSet(proxy, path, {})
|
|
201
|
-
target = stashGet(proxy, path)
|
|
202
|
-
_depth++
|
|
203
|
-
try
|
|
204
|
-
target[k] = v for k, v of obj
|
|
205
|
-
finally
|
|
206
|
-
_depth--
|
|
207
|
-
when 'keys' then (path) ->
|
|
208
|
-
_depth++
|
|
209
|
-
try
|
|
210
|
-
segs = walk(path)
|
|
211
|
-
obj = proxy
|
|
212
|
-
for seg in segs
|
|
213
|
-
return [] unless obj?
|
|
214
|
-
obj = obj[resolveIndex(seg, obj)]
|
|
215
|
-
return [] unless obj? and typeof obj is 'object'
|
|
216
|
-
t = raw(obj)
|
|
217
|
-
keysSignal(t).value
|
|
218
|
-
Object.keys(t)
|
|
219
|
-
finally
|
|
220
|
-
_depth--
|
|
221
|
-
when 'has' then (path) ->
|
|
222
|
-
_depth++
|
|
223
|
-
try
|
|
224
|
-
segs = walk(path)
|
|
225
|
-
return false unless segs.length > 0
|
|
226
|
-
obj = proxy
|
|
227
|
-
for seg, i in segs
|
|
228
|
-
key = resolveIndex(seg, obj)
|
|
229
|
-
if i is segs.length - 1
|
|
230
|
-
t = raw(obj)
|
|
231
|
-
keysSignal(t).value
|
|
232
|
-
return Object.prototype.hasOwnProperty.call(t, key)
|
|
233
|
-
return false unless obj?
|
|
234
|
-
obj = obj[key]
|
|
235
|
-
false
|
|
236
|
-
finally
|
|
237
|
-
_depth--
|
|
238
|
-
when 'del' then (path) ->
|
|
239
|
-
_depth++
|
|
240
|
-
try
|
|
241
|
-
segs = walk(path)
|
|
242
|
-
return unless segs.length > 0
|
|
243
|
-
obj = proxy
|
|
244
|
-
for seg, i in segs
|
|
245
|
-
key = resolveIndex(seg, obj)
|
|
246
|
-
if i is segs.length - 1
|
|
247
|
-
delete obj[key]
|
|
248
|
-
return
|
|
249
|
-
return unless obj?
|
|
250
|
-
obj = obj[key]
|
|
251
|
-
finally
|
|
252
|
-
_depth--
|
|
253
|
-
cache[prop] = fn
|
|
254
|
-
fn
|
|
255
|
-
|
|
256
|
-
export stash = (data = {}) -> makeProxy(data)
|
|
257
|
-
|
|
258
|
-
export raw = (proxy) -> if proxy?[:raw] then proxy[:raw] else proxy
|
|
259
|
-
|
|
260
|
-
export isStash = (obj) -> obj?[:stash] is true
|
|
261
|
-
|
|
262
|
-
export persistStash = (app, opts = {}) ->
|
|
263
|
-
target = raw(app) or app
|
|
264
|
-
return if target[:persisted]
|
|
265
|
-
target[:persisted] = true
|
|
266
|
-
storage = if opts.local then localStorage else sessionStorage
|
|
267
|
-
storageKey = opts.key or '__rip_app'
|
|
268
|
-
try
|
|
269
|
-
saved = storage.getItem(storageKey)
|
|
270
|
-
if saved
|
|
271
|
-
savedData = JSON.parse(saved)
|
|
272
|
-
app.data[k] = v for k, v of savedData
|
|
273
|
-
catch
|
|
274
|
-
null
|
|
275
|
-
_save = ->
|
|
276
|
-
try storage.setItem storageKey, JSON.stringify(raw(app.data))
|
|
277
|
-
catch then null
|
|
278
|
-
__effect ->
|
|
279
|
-
_writeVersion.value
|
|
280
|
-
t = setTimeout _save, 2000
|
|
281
|
-
-> clearTimeout t
|
|
282
|
-
window.addEventListener 'beforeunload', _save
|
|
283
|
-
|
|
284
|
-
# ==============================================================================
|
|
285
|
-
# Resource — async data loading with reactive loading/error/data states
|
|
286
|
-
#
|
|
287
|
-
# Wraps an async function and exposes reactive .data, .loading, and .error
|
|
288
|
-
# properties. Calls the function immediately unless opts.lazy is set.
|
|
289
|
-
# Call .refetch() to reload.
|
|
290
|
-
# ==============================================================================
|
|
291
|
-
|
|
292
|
-
export createResource = (fn, opts = {}) ->
|
|
293
|
-
_data = __state(opts.initial or null)
|
|
294
|
-
_loading = __state(false)
|
|
295
|
-
_error = __state(null)
|
|
296
|
-
|
|
297
|
-
load = ->
|
|
298
|
-
_loading.value = true
|
|
299
|
-
_error.value = null
|
|
300
|
-
try
|
|
301
|
-
result = await fn()
|
|
302
|
-
_data.value = result
|
|
303
|
-
catch err
|
|
304
|
-
_error.value = err
|
|
305
|
-
finally
|
|
306
|
-
_loading.value = false
|
|
307
|
-
|
|
308
|
-
resource =
|
|
309
|
-
data: undefined
|
|
310
|
-
loading: undefined
|
|
311
|
-
error: undefined
|
|
312
|
-
refetch: load
|
|
313
|
-
|
|
314
|
-
Object.defineProperty resource, 'data', get: -> _data.value
|
|
315
|
-
Object.defineProperty resource, 'loading', get: -> _loading.value
|
|
316
|
-
Object.defineProperty resource, 'error', get: -> _error.value
|
|
317
|
-
|
|
318
|
-
load() unless opts.lazy
|
|
319
|
-
resource
|
|
320
|
-
|
|
321
|
-
# ==============================================================================
|
|
322
|
-
# Timing — reactive timing primitives
|
|
323
|
-
#
|
|
324
|
-
# Each takes a duration (ms) and a reactive source (signal or function).
|
|
325
|
-
# Returns a new signal whose value is derived from the source with a
|
|
326
|
-
# time-based transformation. Cleanup is automatic via effect disposal.
|
|
327
|
-
# ==============================================================================
|
|
328
|
-
|
|
329
|
-
_toFn = (source) ->
|
|
330
|
-
if typeof source is 'function' then source else -> source.value
|
|
331
|
-
|
|
332
|
-
_proxy = (out, source) ->
|
|
333
|
-
obj = read: -> out.read()
|
|
334
|
-
Object.defineProperty obj, 'value',
|
|
335
|
-
get: -> out.value
|
|
336
|
-
set: (v) -> source.value = v
|
|
337
|
-
obj
|
|
338
|
-
|
|
339
|
-
# delay(ms, source) — truthy waits ms, falsy immediate
|
|
340
|
-
# Usage: showLoading := delay 200 -> loading
|
|
341
|
-
# navigating = delay 100, __state(false)
|
|
342
|
-
export delay = (ms, source) ->
|
|
343
|
-
fn = _toFn(source)
|
|
344
|
-
out = __state(!!fn())
|
|
345
|
-
__effect ->
|
|
346
|
-
if fn()
|
|
347
|
-
t = setTimeout (-> out.value = true), ms
|
|
348
|
-
-> clearTimeout t
|
|
349
|
-
else
|
|
350
|
-
out.value = false
|
|
351
|
-
if typeof source isnt 'function' then _proxy(out, source) else out
|
|
352
|
-
|
|
353
|
-
# debounce(ms, source) — waits ms after last change, then propagates
|
|
354
|
-
# Usage: debouncedQuery := debounce 300 -> query
|
|
355
|
-
export debounce = (ms, source) ->
|
|
356
|
-
fn = _toFn(source)
|
|
357
|
-
out = __state(fn())
|
|
358
|
-
__effect ->
|
|
359
|
-
val = fn()
|
|
360
|
-
t = setTimeout (-> out.value = val), ms
|
|
361
|
-
-> clearTimeout t
|
|
362
|
-
if typeof source isnt 'function' then _proxy(out, source) else out
|
|
363
|
-
|
|
364
|
-
# throttle(ms, source) — propagates at most once per ms
|
|
365
|
-
# Usage: smoothScroll := throttle 100 -> scrollY
|
|
366
|
-
export throttle = (ms, source) ->
|
|
367
|
-
fn = _toFn(source)
|
|
368
|
-
out = __state(fn())
|
|
369
|
-
last = 0
|
|
370
|
-
__effect ->
|
|
371
|
-
val = fn()
|
|
372
|
-
now = Date.now()
|
|
373
|
-
remaining = ms - (now - last)
|
|
374
|
-
if remaining <= 0
|
|
375
|
-
out.value = val
|
|
376
|
-
last = now
|
|
377
|
-
else
|
|
378
|
-
t = setTimeout (->
|
|
379
|
-
out.value = fn()
|
|
380
|
-
last = Date.now()
|
|
381
|
-
), remaining
|
|
382
|
-
-> clearTimeout t
|
|
383
|
-
if typeof source isnt 'function' then _proxy(out, source) else out
|
|
384
|
-
|
|
385
|
-
# hold(ms, source) — once truthy, stays true for at least ms
|
|
386
|
-
# Usage: showSaved := hold 2000 -> saved
|
|
387
|
-
export hold = (ms, source) ->
|
|
388
|
-
fn = _toFn(source)
|
|
389
|
-
out = __state(!!fn())
|
|
390
|
-
__effect ->
|
|
391
|
-
if fn()
|
|
392
|
-
out.value = true
|
|
393
|
-
else
|
|
394
|
-
t = setTimeout (-> out.value = false), ms
|
|
395
|
-
-> clearTimeout t
|
|
396
|
-
if typeof source isnt 'function' then _proxy(out, source) else out
|
|
397
|
-
|
|
398
|
-
# ==============================================================================
|
|
399
|
-
# Components — in-memory file store with watchers
|
|
400
|
-
#
|
|
401
|
-
# A virtual filesystem for .rip component sources. Supports read, write,
|
|
402
|
-
# delete, listing, and glob-style operations. Watchers are notified on
|
|
403
|
-
# create/change/delete. Compiled modules are cached alongside sources.
|
|
404
|
-
# ==============================================================================
|
|
405
|
-
|
|
406
|
-
export createComponents = ->
|
|
407
|
-
files = new Map()
|
|
408
|
-
watchers = []
|
|
409
|
-
compiled = new Map()
|
|
410
|
-
|
|
411
|
-
notify = (event, path) ->
|
|
412
|
-
for watcher in watchers
|
|
413
|
-
watcher(event, path)
|
|
414
|
-
|
|
415
|
-
read: (path) -> files.get(path)
|
|
416
|
-
write: (path, content) ->
|
|
417
|
-
isNew = not files.has(path)
|
|
418
|
-
files.set(path, content)
|
|
419
|
-
compiled.delete(path)
|
|
420
|
-
notify (if isNew then 'create' else 'change'), path
|
|
421
|
-
|
|
422
|
-
del: (path) ->
|
|
423
|
-
files.delete(path)
|
|
424
|
-
compiled.delete(path)
|
|
425
|
-
notify 'delete', path
|
|
426
|
-
|
|
427
|
-
exists: (path) -> files.has(path)
|
|
428
|
-
size: -> files.size
|
|
429
|
-
|
|
430
|
-
list: (dir = '') ->
|
|
431
|
-
result = []
|
|
432
|
-
prefix = if dir then dir + '/' else ''
|
|
433
|
-
for [path] in files
|
|
434
|
-
if path.startsWith(prefix)
|
|
435
|
-
rest = path.slice(prefix.length)
|
|
436
|
-
continue if rest.includes('/')
|
|
437
|
-
result.push path
|
|
438
|
-
result
|
|
439
|
-
|
|
440
|
-
listAll: (dir = '') ->
|
|
441
|
-
result = []
|
|
442
|
-
prefix = if dir then dir + '/' else ''
|
|
443
|
-
for [path] in files
|
|
444
|
-
result.push path if path.startsWith(prefix)
|
|
445
|
-
result
|
|
446
|
-
|
|
447
|
-
load: (obj) ->
|
|
448
|
-
for key, content of obj
|
|
449
|
-
files.set(key, content)
|
|
450
|
-
|
|
451
|
-
watch: (fn) ->
|
|
452
|
-
watchers.push fn
|
|
453
|
-
-> watchers.splice(watchers.indexOf(fn), 1)
|
|
454
|
-
|
|
455
|
-
getCompiled: (path) -> compiled.get(path)
|
|
456
|
-
setCompiled: (path, result) -> compiled.set(path, result)
|
|
457
|
-
|
|
458
|
-
# ==============================================================================
|
|
459
|
-
# Router — file-based URL routing with reactive state
|
|
460
|
-
#
|
|
461
|
-
# Maps URL paths to component files using a file-system convention:
|
|
462
|
-
# components/index.rip → /
|
|
463
|
-
# components/about.rip → /about
|
|
464
|
-
# components/users/[id].rip → /users/:id
|
|
465
|
-
# components/[...rest].rip → /* (catch-all)
|
|
466
|
-
#
|
|
467
|
-
# Supports nested layouts via _layout.rip files, hash-mode routing,
|
|
468
|
-
# base path prefixes, and query/hash tracking.
|
|
469
|
-
# ==============================================================================
|
|
470
|
-
|
|
471
|
-
fileToPattern = (rel) ->
|
|
472
|
-
pattern = rel.replace(/\.rip$/, '')
|
|
473
|
-
pattern = pattern.replace(/\[\.\.\.(\w+)\]/g, '*$1')
|
|
474
|
-
pattern = pattern.replace(/\[(\w+)\]/g, ':$1')
|
|
475
|
-
return '/' if pattern is 'index'
|
|
476
|
-
pattern = pattern.replace(/\/index$/, '')
|
|
477
|
-
'/' + pattern
|
|
478
|
-
|
|
479
|
-
patternToRegex = (pattern) ->
|
|
480
|
-
names = []
|
|
481
|
-
str = pattern
|
|
482
|
-
.replace /\*(\w+)/g, (_, name) -> names.push(name); '(.+)'
|
|
483
|
-
.replace /:(\w+)/g, (_, name) -> names.push(name); '([^/]+)'
|
|
484
|
-
{ regex: new RegExp('^' + str + '$'), names }
|
|
485
|
-
|
|
486
|
-
matchRoute = (path, routes) ->
|
|
487
|
-
for route in routes
|
|
488
|
-
match = path.match(route.regex.regex)
|
|
489
|
-
if match
|
|
490
|
-
params = {}
|
|
491
|
-
for name, i in route.regex.names
|
|
492
|
-
params[name] = decodeURIComponent(match[i + 1])
|
|
493
|
-
return { route, params }
|
|
494
|
-
null
|
|
495
|
-
|
|
496
|
-
buildRoutes = (components, root = 'components') ->
|
|
497
|
-
routes = []
|
|
498
|
-
layouts = new Map()
|
|
499
|
-
allFiles = components.listAll(root)
|
|
500
|
-
|
|
501
|
-
for filePath in allFiles
|
|
502
|
-
rel = filePath.slice(root.length + 1)
|
|
503
|
-
continue unless rel.endsWith('.rip')
|
|
504
|
-
name = rel.split('/').pop()
|
|
505
|
-
|
|
506
|
-
if name is '_layout.rip'
|
|
507
|
-
dir = if rel is '_layout.rip' then '' else rel.slice(0, -'/_layout.rip'.length)
|
|
508
|
-
layouts.set dir, filePath
|
|
509
|
-
continue
|
|
510
|
-
|
|
511
|
-
continue if name.startsWith('_')
|
|
512
|
-
|
|
513
|
-
# Skip files in _-prefixed directories (shared components, not pages)
|
|
514
|
-
segs = rel.split('/')
|
|
515
|
-
continue if segs.length > 1 and segs.some((s, i) -> i < segs.length - 1 and s.startsWith('_'))
|
|
516
|
-
|
|
517
|
-
urlPattern = fileToPattern(rel)
|
|
518
|
-
regex = patternToRegex(urlPattern)
|
|
519
|
-
routes.push { pattern: urlPattern, regex, file: filePath, rel }
|
|
520
|
-
|
|
521
|
-
# Sort: static first, then fewest dynamic segments, catch-all last
|
|
522
|
-
routes.sort (a, b) ->
|
|
523
|
-
aDyn = (a.pattern.match(/:/g) or []).length
|
|
524
|
-
bDyn = (b.pattern.match(/:/g) or []).length
|
|
525
|
-
aCatch = if a.pattern.includes('*') then 1 else 0
|
|
526
|
-
bCatch = if b.pattern.includes('*') then 1 else 0
|
|
527
|
-
return aCatch - bCatch if aCatch isnt bCatch
|
|
528
|
-
return aDyn - bDyn if aDyn isnt bDyn
|
|
529
|
-
a.pattern.localeCompare(b.pattern)
|
|
530
|
-
|
|
531
|
-
{ routes, layouts }
|
|
532
|
-
|
|
533
|
-
getLayoutChain = (routeFile, root, layouts) ->
|
|
534
|
-
chain = []
|
|
535
|
-
rel = routeFile.slice(root.length + 1)
|
|
536
|
-
segments = rel.split('/')
|
|
537
|
-
dir = ''
|
|
538
|
-
|
|
539
|
-
chain.push layouts.get('') if layouts.has('')
|
|
540
|
-
for seg, i in segments
|
|
541
|
-
break if i is segments.length - 1
|
|
542
|
-
dir = if dir then dir + '/' + seg else seg
|
|
543
|
-
chain.push layouts.get(dir) if layouts.has(dir)
|
|
544
|
-
chain
|
|
545
|
-
|
|
546
|
-
export createRouter = (components, opts = {}) ->
|
|
547
|
-
root = opts.root or 'components'
|
|
548
|
-
base = opts.base or ''
|
|
549
|
-
hashMode = opts.hash or false
|
|
550
|
-
onError = opts.onError or null
|
|
551
|
-
|
|
552
|
-
stripBase = (url) ->
|
|
553
|
-
if base and url.startsWith(base) then url.slice(base.length) or '/' else url
|
|
554
|
-
|
|
555
|
-
addBase = (path) ->
|
|
556
|
-
if base then base + path else path
|
|
557
|
-
|
|
558
|
-
readUrl = ->
|
|
559
|
-
if hashMode
|
|
560
|
-
h = location.hash.slice(1)
|
|
561
|
-
return '/' unless h
|
|
562
|
-
if h[0] is '/' then h else '/' + h
|
|
563
|
-
else
|
|
564
|
-
location.pathname + location.search + location.hash
|
|
565
|
-
|
|
566
|
-
writeUrl = (path) ->
|
|
567
|
-
if hashMode
|
|
568
|
-
if path is '/' then location.pathname else '#' + path.slice(1)
|
|
569
|
-
else
|
|
570
|
-
addBase(path)
|
|
571
|
-
|
|
572
|
-
_path = __state(stripBase(if hashMode then readUrl() else location.pathname))
|
|
573
|
-
_params = __state({})
|
|
574
|
-
_route = __state(null)
|
|
575
|
-
_layouts = __state([])
|
|
576
|
-
_query = __state({})
|
|
577
|
-
_hash = __state('')
|
|
578
|
-
_navigating = delay 100, __state(false)
|
|
579
|
-
|
|
580
|
-
tree = buildRoutes(components, root)
|
|
581
|
-
navCallbacks = new Set()
|
|
582
|
-
|
|
583
|
-
components.watch (event, path) ->
|
|
584
|
-
return unless path.startsWith(root + '/')
|
|
585
|
-
tree = buildRoutes(components, root)
|
|
586
|
-
|
|
587
|
-
resolve = (url) ->
|
|
588
|
-
rawPath = url.split('?')[0].split('#')[0]
|
|
589
|
-
path = stripBase(rawPath)
|
|
590
|
-
path = if path[0] is '/' then path else '/' + path
|
|
591
|
-
queryStr = url.split('?')[1]?.split('#')[0] or ''
|
|
592
|
-
hash = if url.includes('#') then url.split('#')[1] else ''
|
|
593
|
-
|
|
594
|
-
result = matchRoute(path, tree.routes)
|
|
595
|
-
if result
|
|
596
|
-
__batch ->
|
|
597
|
-
_path.value = path
|
|
598
|
-
_params.value = result.params
|
|
599
|
-
_route.value = result.route
|
|
600
|
-
_layouts.value = getLayoutChain(result.route.file, root, tree.layouts)
|
|
601
|
-
_query.value = Object.fromEntries(new URLSearchParams(queryStr))
|
|
602
|
-
_hash.value = hash
|
|
603
|
-
cb(router.current) for cb in navCallbacks
|
|
604
|
-
return true
|
|
605
|
-
|
|
606
|
-
onError({ status: 404, path }) if onError
|
|
607
|
-
false
|
|
608
|
-
|
|
609
|
-
onPopState = -> resolve(readUrl())
|
|
610
|
-
window.addEventListener 'popstate', onPopState if typeof window isnt 'undefined'
|
|
611
|
-
|
|
612
|
-
onClick = (e) ->
|
|
613
|
-
return if e.button isnt 0 or e.metaKey or e.ctrlKey or e.shiftKey or e.altKey
|
|
614
|
-
target = e.target
|
|
615
|
-
target = target.parentElement while target and target.tagName isnt 'A'
|
|
616
|
-
return unless target?.href
|
|
617
|
-
url = new URL(target.href, location.origin)
|
|
618
|
-
return if url.origin isnt location.origin
|
|
619
|
-
return if target.target is '_blank' or target.hasAttribute('data-external')
|
|
620
|
-
e.preventDefault()
|
|
621
|
-
dest = if hashMode and url.hash then (url.hash.slice(1) or '/') else (url.pathname + url.search + url.hash)
|
|
622
|
-
router.push dest
|
|
623
|
-
|
|
624
|
-
document.addEventListener 'click', onClick if typeof document isnt 'undefined'
|
|
625
|
-
|
|
626
|
-
router =
|
|
627
|
-
push: (url) ->
|
|
628
|
-
if resolve(url)
|
|
629
|
-
history.pushState null, '', writeUrl(_path.read())
|
|
630
|
-
|
|
631
|
-
replace: (url) ->
|
|
632
|
-
if resolve(url)
|
|
633
|
-
history.replaceState null, '', writeUrl(_path.read())
|
|
634
|
-
|
|
635
|
-
back: -> history.back()
|
|
636
|
-
forward: -> history.forward()
|
|
637
|
-
|
|
638
|
-
current: undefined
|
|
639
|
-
path: undefined
|
|
640
|
-
params: undefined
|
|
641
|
-
route: undefined
|
|
642
|
-
layouts: undefined
|
|
643
|
-
query: undefined
|
|
644
|
-
hash: undefined
|
|
645
|
-
navigating: undefined
|
|
646
|
-
|
|
647
|
-
onNavigate: (cb) ->
|
|
648
|
-
navCallbacks.add cb
|
|
649
|
-
-> navCallbacks.delete cb
|
|
650
|
-
|
|
651
|
-
rebuild: -> tree = buildRoutes(components, root)
|
|
652
|
-
|
|
653
|
-
routes: undefined
|
|
654
|
-
|
|
655
|
-
init: ->
|
|
656
|
-
resolve readUrl()
|
|
657
|
-
router
|
|
658
|
-
|
|
659
|
-
destroy: ->
|
|
660
|
-
window.removeEventListener 'popstate', onPopState if typeof window isnt 'undefined'
|
|
661
|
-
document.removeEventListener 'click', onClick if typeof document isnt 'undefined'
|
|
662
|
-
navCallbacks.clear()
|
|
663
|
-
|
|
664
|
-
Object.defineProperty router, 'current', get: ->
|
|
665
|
-
{ path: _path.value, params: _params.value, route: _route.value, layouts: _layouts.value, query: _query.value, hash: _hash.value }
|
|
666
|
-
|
|
667
|
-
Object.defineProperty router, 'path', get: -> _path.value
|
|
668
|
-
Object.defineProperty router, 'params', get: -> _params.value
|
|
669
|
-
Object.defineProperty router, 'route', get: -> _route.value
|
|
670
|
-
Object.defineProperty router, 'layouts', get: -> _layouts.value
|
|
671
|
-
Object.defineProperty router, 'query', get: -> _query.value
|
|
672
|
-
Object.defineProperty router, 'hash', get: -> _hash.value
|
|
673
|
-
Object.defineProperty router, 'navigating',
|
|
674
|
-
get: -> _navigating.value
|
|
675
|
-
set: (v) -> _navigating.value = v
|
|
676
|
-
Object.defineProperty router, 'routes', get: -> tree.routes
|
|
677
|
-
|
|
678
|
-
router
|
|
679
|
-
|
|
680
|
-
# ==============================================================================
|
|
681
|
-
# Renderer — compile, import, mount, unmount, and manage components
|
|
682
|
-
#
|
|
683
|
-
# Handles the full lifecycle: compile Rip source to JS, import as an ES
|
|
684
|
-
# module, instantiate, mount into the DOM. Supports nested layouts with
|
|
685
|
-
# slot-based composition, component caching for back/forward navigation,
|
|
686
|
-
# generation tracking to prevent stale mounts, and error boundaries.
|
|
687
|
-
# ==============================================================================
|
|
688
|
-
|
|
689
|
-
arraysEqual = (a, b) ->
|
|
690
|
-
return false if a.length isnt b.length
|
|
691
|
-
for item, i in a
|
|
692
|
-
return false if item isnt b[i]
|
|
693
|
-
true
|
|
694
|
-
|
|
695
|
-
findComponent = (mod) ->
|
|
696
|
-
for key, val of mod
|
|
697
|
-
return val if typeof val is 'function' and (val.prototype?.mount or val.prototype?._create)
|
|
698
|
-
mod.default if typeof mod.default is 'function'
|
|
699
|
-
|
|
700
|
-
# --------------------------------------------------------------------------
|
|
701
|
-
# Component resolution — name discovery, lazy compilation, class registry
|
|
702
|
-
# --------------------------------------------------------------------------
|
|
703
|
-
|
|
704
|
-
findAllComponents = (mod) ->
|
|
705
|
-
result = {}
|
|
706
|
-
for key, val of mod
|
|
707
|
-
if typeof val is 'function' and (val.prototype?.mount or val.prototype?._create)
|
|
708
|
-
result[key] = val
|
|
709
|
-
result
|
|
710
|
-
|
|
711
|
-
# Convert file path to PascalCase component name
|
|
712
|
-
# components/card.rip → Card, components/todo-item.rip → TodoItem
|
|
713
|
-
fileToComponentName = (filePath) ->
|
|
714
|
-
name = filePath.split('/').pop().replace(/\.rip$/, '')
|
|
715
|
-
name.replace /(^|[-_])([a-z])/g, (_, sep, ch) -> ch.toUpperCase()
|
|
716
|
-
|
|
717
|
-
# Build name-to-path map from component store
|
|
718
|
-
buildComponentMap = (components, root = 'components') ->
|
|
719
|
-
map = {}
|
|
720
|
-
for path in components.listAll(root)
|
|
721
|
-
continue unless path.endsWith('.rip')
|
|
722
|
-
fileName = path.split('/').pop()
|
|
723
|
-
continue if fileName.startsWith('_')
|
|
724
|
-
name = fileToComponentName(path)
|
|
725
|
-
if map[name]
|
|
726
|
-
console.warn "[Rip] Component name collision: #{name} (#{map[name]} vs #{path})"
|
|
727
|
-
map[name] = path
|
|
728
|
-
map
|
|
729
|
-
|
|
730
|
-
# Resolve a .rip import specifier to a path in the component store
|
|
731
|
-
resolveStorePath = (specifier, currentPath, components) ->
|
|
732
|
-
clean = specifier.replace(/^(\.\.\/|\.\/)+/, '')
|
|
733
|
-
basename = clean.split('/').pop()
|
|
734
|
-
# Direct resolution relative to current file in the store
|
|
735
|
-
if currentPath
|
|
736
|
-
parts = currentPath.split('/')
|
|
737
|
-
parts.pop()
|
|
738
|
-
for seg in specifier.split('/')
|
|
739
|
-
if seg is '..'
|
|
740
|
-
parts.pop()
|
|
741
|
-
else unless seg is '.'
|
|
742
|
-
parts.push seg
|
|
743
|
-
candidate = parts.join('/')
|
|
744
|
-
return candidate if components.exists(candidate)
|
|
745
|
-
# Try common store prefixes
|
|
746
|
-
return "components/_lib/#{clean}" if components.exists("components/_lib/#{clean}")
|
|
747
|
-
return "components/#{clean}" if components.exists("components/#{clean}")
|
|
748
|
-
# Basename search as last resort
|
|
749
|
-
for p in components.listAll('components')
|
|
750
|
-
return p if p.endsWith("/#{basename}")
|
|
751
|
-
null
|
|
752
|
-
|
|
753
|
-
compileAndImport = (source, compile, components = null, path = null, resolver = null) ->
|
|
754
|
-
# Check compilation cache
|
|
755
|
-
if components and path
|
|
756
|
-
cached = components.getCompiled(path)
|
|
757
|
-
return cached if cached
|
|
758
|
-
|
|
759
|
-
# Mark as in-progress to prevent circular dependency loops
|
|
760
|
-
if resolver and path
|
|
761
|
-
resolver.compiling ?= {}
|
|
762
|
-
resolver.compiling[path] = true
|
|
763
|
-
|
|
764
|
-
# Browser-debugger support — when enabled, compile with an inline source
|
|
765
|
-
# map keyed to the component's logical path. We still need to compensate
|
|
766
|
-
# for line shifts introduced by the header + preamble we prepend below;
|
|
767
|
-
# `prefixLines` tracks that and we apply `offsetSourceMap` once at the end.
|
|
768
|
-
debug = globalThis?.__ripDebug?.enabled and path
|
|
769
|
-
prefixLines = 0
|
|
770
|
-
js = if debug
|
|
771
|
-
compile(source, sourceMap: 'inline', filename: path)
|
|
772
|
-
else
|
|
773
|
-
compile(source)
|
|
774
|
-
|
|
775
|
-
if resolver
|
|
776
|
-
importedNames = new Set()
|
|
777
|
-
|
|
778
|
-
# Step 1: Rewrite .rip imports to blob URLs
|
|
779
|
-
if components
|
|
780
|
-
ripImportRe = /^(\s*import\s+(?:\{([^}]+)\}\s+from\s+|.*?\s+from\s+)?['"])([^'"]*\.rip)(['"];?\s*)$/gm
|
|
781
|
-
matches = Array.from(js.matchAll(ripImportRe))
|
|
782
|
-
# Walk matches in reverse so string indices stay valid after each splice
|
|
783
|
-
for m in matches by -1
|
|
784
|
-
[full, pre, namedImports, specifier, post] = m
|
|
785
|
-
storePath = resolveStorePath(specifier, path, components)
|
|
786
|
-
continue if storePath is path
|
|
787
|
-
unless storePath
|
|
788
|
-
msg = "[Rip] Could not resolve import: #{specifier}"
|
|
789
|
-
msg += " (from #{path})" if path
|
|
790
|
-
console.warn msg
|
|
791
|
-
continue
|
|
792
|
-
# Guard against circular imports — skip if already being compiled
|
|
793
|
-
unless resolver.blobUrls?[storePath]
|
|
794
|
-
continue if resolver.compiling?[storePath]
|
|
795
|
-
depSource = components.read(storePath)
|
|
796
|
-
if depSource
|
|
797
|
-
compileAndImport! depSource, compile, components, storePath, resolver
|
|
798
|
-
blobUrl = resolver.blobUrls?[storePath]
|
|
799
|
-
if blobUrl
|
|
800
|
-
replacement = "#{pre}#{blobUrl}#{post}"
|
|
801
|
-
js = js.slice(0, m.index) + replacement + js.slice(m.index + full.length)
|
|
802
|
-
if namedImports
|
|
803
|
-
for n in namedImports.split(',')
|
|
804
|
-
importedNames.add(n.trim().split(/\s+as\s+/).pop().trim())
|
|
805
|
-
|
|
806
|
-
# Step 2: Implicit component resolution — for template-used components not explicitly imported
|
|
807
|
-
needed = {}
|
|
808
|
-
for name, depPath of resolver.map
|
|
809
|
-
continue if importedNames.has(name)
|
|
810
|
-
if depPath isnt path and js.includes("new #{name}(")
|
|
811
|
-
unless resolver.classes[name]
|
|
812
|
-
depSource = components.read(depPath)
|
|
813
|
-
if depSource
|
|
814
|
-
depMod = compileAndImport! depSource, compile, components, depPath, resolver
|
|
815
|
-
found = findAllComponents(depMod)
|
|
816
|
-
resolver.classes[k] = v for k, v of found
|
|
817
|
-
needed[name] = true if resolver.classes[name]
|
|
818
|
-
|
|
819
|
-
# Inject resolved components into scope via preamble
|
|
820
|
-
names = Object.keys(needed)
|
|
821
|
-
if names.length > 0
|
|
822
|
-
preamble = "const {#{names.join(', ')}} = globalThis['#{resolver.key}'];\n"
|
|
823
|
-
js = preamble + js
|
|
824
|
-
prefixLines += 1
|
|
825
|
-
|
|
826
|
-
header = if path then "// #{path}\n" else ''
|
|
827
|
-
prefixLines += 1 if header
|
|
828
|
-
finalJs = header + js
|
|
829
|
-
|
|
830
|
-
# Source-map post-processing: prepend N semicolons to map.mappings so
|
|
831
|
-
# generated lines line up with `header + preamble + js`. The Blob URL
|
|
832
|
-
# itself becomes the source identity in DevTools, so an explicit
|
|
833
|
-
# //# sourceURL pragma isn't needed here (only for eval'd code).
|
|
834
|
-
if debug and prefixLines > 0
|
|
835
|
-
offset = globalThis?.__ripDebug?.offsetSourceMap
|
|
836
|
-
finalJs = offset(finalJs, prefixLines) if offset
|
|
837
|
-
|
|
838
|
-
blob = new Blob([finalJs], { type: 'application/javascript' })
|
|
839
|
-
url = URL.createObjectURL(blob)
|
|
840
|
-
|
|
841
|
-
# Cache blob URL so other files can rewrite imports to point here
|
|
842
|
-
if resolver and path
|
|
843
|
-
resolver.blobUrls ?= {}
|
|
844
|
-
resolver.blobUrls[path] = url
|
|
845
|
-
delete resolver.compiling[path] if resolver.compiling
|
|
846
|
-
|
|
847
|
-
mod = await import(url)
|
|
848
|
-
|
|
849
|
-
# Register any components from this module
|
|
850
|
-
if resolver
|
|
851
|
-
found = findAllComponents(mod)
|
|
852
|
-
resolver.classes[k] = v for k, v of found
|
|
853
|
-
|
|
854
|
-
# Store in cache
|
|
855
|
-
components.setCompiled(path, mod) if components and path
|
|
856
|
-
mod
|
|
857
|
-
|
|
858
|
-
export createRenderer = (opts = {}) ->
|
|
859
|
-
{ router, app, components, resolver, compile, target, onError } = opts
|
|
860
|
-
|
|
861
|
-
container = if typeof target is 'string'
|
|
862
|
-
document.querySelector(target)
|
|
863
|
-
else
|
|
864
|
-
target or document.getElementById('app')
|
|
865
|
-
|
|
866
|
-
unless container
|
|
867
|
-
container = document.createElement('div')
|
|
868
|
-
container.id = 'app'
|
|
869
|
-
document.body.appendChild container
|
|
870
|
-
|
|
871
|
-
currentComponent = null
|
|
872
|
-
currentRoute = null
|
|
873
|
-
currentParams = null
|
|
874
|
-
currentLayouts = []
|
|
875
|
-
layoutInstances = []
|
|
876
|
-
mountPoint = container
|
|
877
|
-
generation = 0
|
|
878
|
-
disposeEffect = null
|
|
879
|
-
componentCache = new Map()
|
|
880
|
-
maxCacheSize = opts.cacheSize or 10
|
|
881
|
-
|
|
882
|
-
cacheComponent = ->
|
|
883
|
-
if currentComponent and currentRoute
|
|
884
|
-
currentComponent.beforeUnmount() if currentComponent.beforeUnmount
|
|
885
|
-
componentCache.set currentRoute, currentComponent
|
|
886
|
-
# Evict oldest if over limit
|
|
887
|
-
if componentCache.size > maxCacheSize
|
|
888
|
-
oldest = componentCache.keys().next().value
|
|
889
|
-
evicted = componentCache.get(oldest)
|
|
890
|
-
evicted.unmounted() if evicted.unmounted
|
|
891
|
-
componentCache.delete oldest
|
|
892
|
-
# Don't remove _root here — leave visible until new content is ready
|
|
893
|
-
currentComponent = null
|
|
894
|
-
currentRoute = null
|
|
895
|
-
|
|
896
|
-
unmount = ->
|
|
897
|
-
cacheComponent()
|
|
898
|
-
for inst in layoutInstances by -1
|
|
899
|
-
inst.beforeUnmount() if inst.beforeUnmount
|
|
900
|
-
inst.unmounted() if inst.unmounted
|
|
901
|
-
inst._target?.remove()
|
|
902
|
-
layoutInstances = []
|
|
903
|
-
mountPoint = container
|
|
904
|
-
|
|
905
|
-
# Invalidate cached components when their source changes (HMR)
|
|
906
|
-
components.watch (event, path) ->
|
|
907
|
-
if componentCache.has(path)
|
|
908
|
-
evicted = componentCache.get(path)
|
|
909
|
-
evicted.unmounted() if evicted.unmounted
|
|
910
|
-
componentCache.delete path
|
|
911
|
-
|
|
912
|
-
mountRoute = (info) ->
|
|
913
|
-
{ route, params, layouts: layoutFiles, query } = info
|
|
914
|
-
return unless route
|
|
915
|
-
if route.file is currentRoute and JSON.stringify(params) is JSON.stringify(currentParams)
|
|
916
|
-
return
|
|
917
|
-
currentParams = params
|
|
918
|
-
|
|
919
|
-
gen = ++generation
|
|
920
|
-
router.navigating = true
|
|
921
|
-
|
|
922
|
-
try
|
|
923
|
-
source = components.read(route.file)
|
|
924
|
-
unless source
|
|
925
|
-
onError({ status: 404, message: "File not found: #{route.file}" }) if onError
|
|
926
|
-
router.navigating = false
|
|
927
|
-
return
|
|
928
|
-
|
|
929
|
-
mod = compileAndImport! source, compile, components, route.file, resolver
|
|
930
|
-
if gen isnt generation then router.navigating = false; return
|
|
931
|
-
|
|
932
|
-
Component = findComponent(mod)
|
|
933
|
-
unless Component
|
|
934
|
-
onError({ status: 500, message: "No component found in #{route.file}" }) if onError
|
|
935
|
-
router.navigating = false
|
|
936
|
-
return
|
|
937
|
-
|
|
938
|
-
layoutsChanged = not arraysEqual(layoutFiles, currentLayouts)
|
|
939
|
-
oldTarget = currentComponent?._target
|
|
940
|
-
|
|
941
|
-
if layoutsChanged
|
|
942
|
-
unmount()
|
|
943
|
-
else
|
|
944
|
-
cacheComponent()
|
|
945
|
-
|
|
946
|
-
mp = if layoutsChanged then container else mountPoint
|
|
947
|
-
|
|
948
|
-
if layoutsChanged and layoutFiles.length > 0
|
|
949
|
-
container.innerHTML = ''
|
|
950
|
-
mp = container
|
|
951
|
-
|
|
952
|
-
for layoutFile in layoutFiles
|
|
953
|
-
layoutSource = components.read(layoutFile)
|
|
954
|
-
continue unless layoutSource
|
|
955
|
-
layoutMod = compileAndImport! layoutSource, compile, components, layoutFile, resolver
|
|
956
|
-
if gen isnt generation then router.navigating = false; return
|
|
957
|
-
|
|
958
|
-
LayoutClass = findComponent(layoutMod)
|
|
959
|
-
continue unless LayoutClass
|
|
960
|
-
|
|
961
|
-
inst = new LayoutClass { app, params, router }
|
|
962
|
-
inst.beforeMount() if inst.beforeMount
|
|
963
|
-
wrapper = document.createElement('div')
|
|
964
|
-
wrapper.setAttribute 'data-layout', layoutFile
|
|
965
|
-
mp.appendChild wrapper
|
|
966
|
-
inst.mount wrapper
|
|
967
|
-
layoutInstances.push inst
|
|
968
|
-
|
|
969
|
-
slot = wrapper.querySelector('#content') or wrapper
|
|
970
|
-
mp = slot
|
|
971
|
-
|
|
972
|
-
currentLayouts = [...layoutFiles]
|
|
973
|
-
mountPoint = mp
|
|
974
|
-
else if layoutsChanged
|
|
975
|
-
container.innerHTML = ''
|
|
976
|
-
currentLayouts = []
|
|
977
|
-
mountPoint = container
|
|
978
|
-
|
|
979
|
-
# Check component cache for a preserved instance
|
|
980
|
-
cached = componentCache.get(route.file)
|
|
981
|
-
if cached
|
|
982
|
-
componentCache.delete route.file
|
|
983
|
-
mp.appendChild cached._target
|
|
984
|
-
currentComponent = cached
|
|
985
|
-
currentRoute = route.file
|
|
986
|
-
cached.params = params if params
|
|
987
|
-
cached.query = query if query
|
|
988
|
-
cached.mounted() if cached.mounted
|
|
989
|
-
cached.load!(params, query) if cached.load
|
|
990
|
-
else
|
|
991
|
-
pageWrapper = document.createElement('div')
|
|
992
|
-
pageWrapper.setAttribute 'data-component', route.file
|
|
993
|
-
mp.appendChild pageWrapper
|
|
994
|
-
|
|
995
|
-
instance = new Component { app, params, query, router }
|
|
996
|
-
instance.beforeMount() if instance.beforeMount
|
|
997
|
-
instance.mount pageWrapper
|
|
998
|
-
currentComponent = instance
|
|
999
|
-
currentRoute = route.file
|
|
1000
|
-
|
|
1001
|
-
instance.load!(params, query) if instance.load
|
|
1002
|
-
oldTarget?.remove()
|
|
1003
|
-
router.navigating = false
|
|
1004
|
-
|
|
1005
|
-
catch err
|
|
1006
|
-
router.navigating = false
|
|
1007
|
-
console.error "Renderer: error mounting #{route.file}:", err
|
|
1008
|
-
onError({ status: 500, message: err.message, error: err }) if onError
|
|
1009
|
-
|
|
1010
|
-
# Walk layout chain for an error boundary (component with onError method)
|
|
1011
|
-
handled = false
|
|
1012
|
-
for inst in layoutInstances by -1
|
|
1013
|
-
if inst.onError
|
|
1014
|
-
try
|
|
1015
|
-
inst.onError(err)
|
|
1016
|
-
handled = true
|
|
1017
|
-
break
|
|
1018
|
-
catch boundaryErr
|
|
1019
|
-
console.error "Renderer: error boundary failed:", boundaryErr
|
|
1020
|
-
|
|
1021
|
-
unless handled
|
|
1022
|
-
pre = document.createElement('pre')
|
|
1023
|
-
pre.style.cssText = 'color:red;padding:1em'
|
|
1024
|
-
pre.textContent = err.stack or err.message
|
|
1025
|
-
container.innerHTML = ''
|
|
1026
|
-
container.appendChild pre
|
|
1027
|
-
|
|
1028
|
-
renderer =
|
|
1029
|
-
start: ->
|
|
1030
|
-
disposeEffect = __effect ->
|
|
1031
|
-
current = router.current
|
|
1032
|
-
mountRoute(current) if current.route
|
|
1033
|
-
router.init()
|
|
1034
|
-
renderer
|
|
1035
|
-
|
|
1036
|
-
stop: ->
|
|
1037
|
-
unmount()
|
|
1038
|
-
if disposeEffect
|
|
1039
|
-
disposeEffect()
|
|
1040
|
-
disposeEffect = null
|
|
1041
|
-
container.innerHTML = ''
|
|
1042
|
-
|
|
1043
|
-
remount: ->
|
|
1044
|
-
current = router.current
|
|
1045
|
-
mountRoute(current) if current.route
|
|
1046
|
-
|
|
1047
|
-
cache: componentCache
|
|
1048
|
-
|
|
1049
|
-
renderer
|
|
1050
|
-
|
|
1051
|
-
# ==============================================================================
|
|
1052
|
-
# SSE Watch — hot-reload connection with exponential backoff
|
|
1053
|
-
# ==============================================================================
|
|
1054
|
-
|
|
1055
|
-
connectWatch = (url) ->
|
|
1056
|
-
retryDelay = 1000
|
|
1057
|
-
maxDelay = 30000
|
|
1058
|
-
|
|
1059
|
-
connect = ->
|
|
1060
|
-
es = new EventSource(url)
|
|
1061
|
-
|
|
1062
|
-
es.addEventListener 'connected', ->
|
|
1063
|
-
retryDelay = 1000
|
|
1064
|
-
console.log '[Rip] Hot reload connected'
|
|
1065
|
-
|
|
1066
|
-
es.addEventListener 'reload', ->
|
|
1067
|
-
console.log '[Rip] Reloading...'
|
|
1068
|
-
location.reload()
|
|
1069
|
-
|
|
1070
|
-
es.addEventListener 'css', ->
|
|
1071
|
-
for link as document.querySelectorAll('link[rel="stylesheet"]')
|
|
1072
|
-
url = new URL(link.href)
|
|
1073
|
-
url.searchParams.set('_t', Date.now())
|
|
1074
|
-
link.href = url.toString()
|
|
1075
|
-
|
|
1076
|
-
es.onerror = ->
|
|
1077
|
-
es.close()
|
|
1078
|
-
setTimeout connect, retryDelay
|
|
1079
|
-
retryDelay = Math.min(retryDelay * 2, maxDelay)
|
|
1080
|
-
|
|
1081
|
-
connect()
|
|
1082
|
-
|
|
1083
|
-
# ==============================================================================
|
|
1084
|
-
# Launch — fetch bundle, create stash, wire router + renderer, start app
|
|
1085
|
-
#
|
|
1086
|
-
# Entry point for Rip applications. Handles two bundle sources:
|
|
1087
|
-
# 1. Explicit bundle object (opts.bundle)
|
|
1088
|
-
# 2. Fetch from URL (opts.bundleUrl) — used by serve middleware's data-launch
|
|
1089
|
-
#
|
|
1090
|
-
# Creates the app stash, sets up persistence if configured, builds the
|
|
1091
|
-
# component resolver, initializes the router and renderer, and optionally
|
|
1092
|
-
# connects SSE hot-reload.
|
|
1093
|
-
# ==============================================================================
|
|
1094
|
-
|
|
1095
|
-
export launch = (appBase = '', opts = {}) ->
|
|
1096
|
-
globalThis.__ripLaunched = true
|
|
1097
|
-
if typeof appBase is 'object'
|
|
1098
|
-
opts = appBase
|
|
1099
|
-
appBase = ''
|
|
1100
|
-
appBase = appBase.replace(/\/+$/, '') # strip trailing slashes
|
|
1101
|
-
target = opts.target or '#app'
|
|
1102
|
-
compile = opts.compile or null
|
|
1103
|
-
persist = opts.persist or false
|
|
1104
|
-
hash = opts.hash or false
|
|
1105
|
-
|
|
1106
|
-
# Auto-detect compile function from the global rip.js module
|
|
1107
|
-
unless compile
|
|
1108
|
-
compile = globalThis?.compileToJS or null
|
|
1109
|
-
|
|
1110
|
-
# Auto-create target element
|
|
1111
|
-
if typeof document isnt 'undefined' and not document.querySelector(target)
|
|
1112
|
-
el = document.createElement('div')
|
|
1113
|
-
el.id = target.replace(/^#/, '')
|
|
1114
|
-
document.body.prepend el
|
|
1115
|
-
|
|
1116
|
-
# Get the app bundle — explicit object or fetch from URL
|
|
1117
|
-
if opts.bundle
|
|
1118
|
-
bundle = opts.bundle
|
|
1119
|
-
else if opts.bundleUrl
|
|
1120
|
-
headers = {}
|
|
1121
|
-
etagKey = "__rip_etag_#{opts.bundleUrl}"
|
|
1122
|
-
cached = sessionStorage.getItem(etagKey)
|
|
1123
|
-
headers['If-None-Match'] = cached if cached
|
|
1124
|
-
res = await fetch(opts.bundleUrl, { headers })
|
|
1125
|
-
if res.status is 304
|
|
1126
|
-
bundle = JSON.parse(sessionStorage.getItem("#{etagKey}_data"))
|
|
1127
|
-
else if res.ok
|
|
1128
|
-
bundle = res.json!
|
|
1129
|
-
etag = res.headers.get('etag')
|
|
1130
|
-
if etag
|
|
1131
|
-
sessionStorage.setItem(etagKey, etag)
|
|
1132
|
-
sessionStorage.setItem("#{etagKey}_data", JSON.stringify(bundle))
|
|
1133
|
-
else
|
|
1134
|
-
throw new Error "launch: #{opts.bundleUrl} (#{res.status})"
|
|
1135
|
-
else
|
|
1136
|
-
throw new Error "launch: no bundle or bundleUrl provided"
|
|
1137
|
-
|
|
1138
|
-
# Create the unified stash
|
|
1139
|
-
app = stash { components: {}, routes: {}, data: {} }
|
|
1140
|
-
globalThis.__ripApp = app
|
|
1141
|
-
|
|
1142
|
-
# Hydrate from bundle — any keys populate the stash
|
|
1143
|
-
app.data = bundle.data if bundle.data
|
|
1144
|
-
if bundle.routes
|
|
1145
|
-
app.routes = bundle.routes
|
|
1146
|
-
|
|
1147
|
-
# Restore persisted state (overrides bundle defaults with saved user state)
|
|
1148
|
-
if persist and typeof sessionStorage isnt 'undefined'
|
|
1149
|
-
persistStash app, local: persist is 'local', key: "__rip_#{appBase}"
|
|
1150
|
-
|
|
1151
|
-
# Create components store and load component sources
|
|
1152
|
-
appComponents = createComponents()
|
|
1153
|
-
appComponents.load(bundle.components) if bundle.components
|
|
1154
|
-
|
|
1155
|
-
# Build component resolver — name-to-path map + app-scoped class registry
|
|
1156
|
-
classesKey = "__rip_#{appBase.replace(/\//g, '_') or 'app'}"
|
|
1157
|
-
resolver = { map: buildComponentMap(appComponents), classes: {}, key: classesKey }
|
|
1158
|
-
globalThis[classesKey] = resolver.classes if typeof globalThis isnt 'undefined'
|
|
1159
|
-
|
|
1160
|
-
# Set document title
|
|
1161
|
-
document.title = app.data.title if app.data.title and typeof document isnt 'undefined'
|
|
1162
|
-
|
|
1163
|
-
# Create router
|
|
1164
|
-
router = createRouter appComponents,
|
|
1165
|
-
root: 'components'
|
|
1166
|
-
base: appBase
|
|
1167
|
-
hash: hash
|
|
1168
|
-
onError: (err) -> console.error "[Rip] Error #{err.status}: #{err.message or err.path}"
|
|
1169
|
-
|
|
1170
|
-
# Create renderer
|
|
1171
|
-
renderer = createRenderer
|
|
1172
|
-
router: router
|
|
1173
|
-
app: app
|
|
1174
|
-
components: appComponents
|
|
1175
|
-
resolver: resolver
|
|
1176
|
-
compile: compile
|
|
1177
|
-
target: target
|
|
1178
|
-
onError: (err) -> console.error "[Rip] #{err.message}", err.error
|
|
1179
|
-
|
|
1180
|
-
# Start
|
|
1181
|
-
renderer.start()
|
|
1182
|
-
|
|
1183
|
-
# Connect SSE watch if enabled
|
|
1184
|
-
if bundle.data?.watch
|
|
1185
|
-
connectWatch "#{appBase}/watch"
|
|
1186
|
-
|
|
1187
|
-
# Expose for console and dev tools
|
|
1188
|
-
if typeof window isnt 'undefined'
|
|
1189
|
-
window.app = app
|
|
1190
|
-
window.__RIP__ =
|
|
1191
|
-
app: app
|
|
1192
|
-
components: appComponents
|
|
1193
|
-
router: router
|
|
1194
|
-
renderer: renderer
|
|
1195
|
-
resolver: resolver
|
|
1196
|
-
cache: renderer.cache
|
|
1197
|
-
version: '0.3.0'
|
|
1198
|
-
|
|
1199
|
-
{ app, components: appComponents, router, renderer }
|
|
1200
|
-
|
|
1201
|
-
# ==============================================================================
|
|
1202
|
-
# ARIA — keyboard navigation and popup lifecycle utilities for UI components
|
|
1203
|
-
#
|
|
1204
|
-
# Provides the WAI-ARIA keyboard interaction patterns used by headless widgets.
|
|
1205
|
-
# Registered on globalThis so any component can use them without explicit imports.
|
|
1206
|
-
#
|
|
1207
|
-
# ARIA.listNav(e, handlers) — popup lists (listbox, menu, combobox)
|
|
1208
|
-
# ARIA.rovingNav(e, handlers, orient) — inline composites (radiogroup, tabs, toolbar)
|
|
1209
|
-
# ARIA.popupDismiss(open, popup, close, els, repos) — dismiss on outside click; reposition or close on scroll
|
|
1210
|
-
#
|
|
1211
|
-
# Both nav handlers support an optional `char:` handler for typeahead search.
|
|
1212
|
-
# APG gaps handled per-component (not in ARIA helpers):
|
|
1213
|
-
# - Submenu ArrowRight/Left: belongs in the Menu component (menu.rip)
|
|
1214
|
-
# - Grid 2D navigation: belongs in the Grid component (grid.rip)
|
|
1215
|
-
# - F2 cell edit mode: belongs in the Grid component (grid.rip)
|
|
1216
|
-
#
|
|
1217
|
-
# Both nav handlers:
|
|
1218
|
-
# - Guard against IME composition events (e.isComposing, CJK input)
|
|
1219
|
-
# - Call e.preventDefault() + e.stopPropagation() for handled keys
|
|
1220
|
-
# - Only invoke a handler if it is provided (all keys are optional)
|
|
1221
|
-
# - Alias PageUp/PageDown to first/last (fn+Up/Down on macOS)
|
|
1222
|
-
#
|
|
1223
|
-
# ARIA: https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/
|
|
1224
|
-
# ==============================================================================
|
|
1225
|
-
|
|
1226
|
-
_ariaNAV = (e, fn) ->
|
|
1227
|
-
return unless fn
|
|
1228
|
-
e.preventDefault()
|
|
1229
|
-
e.stopPropagation()
|
|
1230
|
-
fn()
|
|
1231
|
-
|
|
1232
|
-
globalThis.__ariaLastFocusedEl ?= null
|
|
1233
|
-
if typeof document isnt 'undefined' and not globalThis.__ariaFocusTrackerBound
|
|
1234
|
-
document.addEventListener 'focusin', (e) -> globalThis.__ariaLastFocusedEl = e.target, true
|
|
1235
|
-
globalThis.__ariaFocusTrackerBound = true
|
|
1236
|
-
|
|
1237
|
-
_ariaListNav = (e, h) ->
|
|
1238
|
-
return if e.isComposing # IME guard: ignore events during CJK composition
|
|
1239
|
-
switch e.key
|
|
1240
|
-
when 'ArrowDown' then _ariaNAV e, h.next
|
|
1241
|
-
when 'ArrowUp' then _ariaNAV e, h.prev
|
|
1242
|
-
when 'Home', 'PageUp' then _ariaNAV e, h.first
|
|
1243
|
-
when 'End', 'PageDown' then _ariaNAV e, h.last
|
|
1244
|
-
when 'Enter', ' ' then _ariaNAV e, h.select
|
|
1245
|
-
when 'Escape' then _ariaNAV e, h.dismiss
|
|
1246
|
-
when 'Tab' then h.tab?() # no preventDefault: allow natural focus movement
|
|
1247
|
-
else h.char?(e.key) if e.key.length is 1 # printable chars: typeahead
|
|
1248
|
-
|
|
1249
|
-
_ariaPopupDismiss = (open, popup, close, els = [], repos = null) ->
|
|
1250
|
-
return unless open
|
|
1251
|
-
get = (x) -> if typeof x is 'function' then x() else x
|
|
1252
|
-
onDown = (e) => close() unless [get(popup), ...els.map(get)].some (el) -> el?.contains(e.target)
|
|
1253
|
-
onScroll = (e) =>
|
|
1254
|
-
return if get(popup)?.contains(e.target)
|
|
1255
|
-
if repos then repos() else close()
|
|
1256
|
-
document.addEventListener 'mousedown', onDown
|
|
1257
|
-
window.addEventListener 'scroll', onScroll, true
|
|
1258
|
-
->
|
|
1259
|
-
document.removeEventListener 'mousedown', onDown
|
|
1260
|
-
window.removeEventListener 'scroll', onScroll, true
|
|
1261
|
-
|
|
1262
|
-
# popupGuard — per-component reopen suppression for pointer-driven popup closes.
|
|
1263
|
-
# Use when the same gesture that closes a popup can otherwise refocus/reclick a
|
|
1264
|
-
# trigger and reopen it immediately on the tail end of that sequence.
|
|
1265
|
-
_ariaPopupGuard = (delay = 250) ->
|
|
1266
|
-
blockedUntil = 0
|
|
1267
|
-
{
|
|
1268
|
-
block: (ms = delay) ->
|
|
1269
|
-
blockedUntil = Date.now() + ms
|
|
1270
|
-
canOpen: ->
|
|
1271
|
-
Date.now() >= blockedUntil
|
|
1272
|
-
}
|
|
1273
|
-
|
|
1274
|
-
# bindPopover — sync reactive open state with native Popover API
|
|
1275
|
-
# open: reactive boolean
|
|
1276
|
-
# popover: element or lazy getter (=> el)
|
|
1277
|
-
# setOpen: callback that receives actual native open state (true/false)
|
|
1278
|
-
# source: optional control element or lazy getter used as popover invoker
|
|
1279
|
-
_ariaBindPopover = (open, popover, setOpen, source = null) ->
|
|
1280
|
-
get = (x) -> if typeof x is 'function' then x() else x
|
|
1281
|
-
currentFocus = ->
|
|
1282
|
-
active = document.activeElement
|
|
1283
|
-
return active if active and active isnt document.body
|
|
1284
|
-
last = globalThis.__ariaLastFocusedEl
|
|
1285
|
-
return last if last?.isConnected isnt false
|
|
1286
|
-
null
|
|
1287
|
-
el = get(popover)
|
|
1288
|
-
return unless el
|
|
1289
|
-
return unless Object.hasOwn(HTMLElement.prototype, 'togglePopover')
|
|
1290
|
-
restoreEl = null
|
|
1291
|
-
|
|
1292
|
-
syncState = (isOpen) ->
|
|
1293
|
-
if isOpen
|
|
1294
|
-
el.hidden = false
|
|
1295
|
-
try el.inert = false
|
|
1296
|
-
catch then null
|
|
1297
|
-
el.removeAttribute 'aria-hidden'
|
|
1298
|
-
else
|
|
1299
|
-
try el.inert = true
|
|
1300
|
-
catch then null
|
|
1301
|
-
el.setAttribute 'aria-hidden', 'true'
|
|
1302
|
-
el.hidden = true
|
|
1303
|
-
|
|
1304
|
-
restoreFocus = ->
|
|
1305
|
-
target = restoreEl
|
|
1306
|
-
restoreEl = null
|
|
1307
|
-
return unless target?.focus?
|
|
1308
|
-
focusAttempt = (tries = 6) ->
|
|
1309
|
-
return unless target.isConnected isnt false
|
|
1310
|
-
try target.focus preventScroll: true
|
|
1311
|
-
catch then target.focus()
|
|
1312
|
-
return if document.activeElement is target or tries <= 1
|
|
1313
|
-
setTimeout (-> focusAttempt(tries - 1)), 16
|
|
1314
|
-
requestAnimationFrame -> focusAttempt()
|
|
1315
|
-
|
|
1316
|
-
onToggle = (e) ->
|
|
1317
|
-
isOpen = e.newState is 'open'
|
|
1318
|
-
if isOpen
|
|
1319
|
-
restoreEl = get(source) or currentFocus()
|
|
1320
|
-
syncState(true)
|
|
1321
|
-
else
|
|
1322
|
-
syncState(false)
|
|
1323
|
-
restoreFocus()
|
|
1324
|
-
setOpen?(isOpen)
|
|
1325
|
-
el.addEventListener 'toggle', onToggle
|
|
1326
|
-
|
|
1327
|
-
shown = el.matches(':popover-open')
|
|
1328
|
-
desired = !!open
|
|
1329
|
-
if shown isnt desired
|
|
1330
|
-
src = get(source)
|
|
1331
|
-
if desired
|
|
1332
|
-
restoreEl = src or currentFocus()
|
|
1333
|
-
syncState(true)
|
|
1334
|
-
opts = if src and desired then { force: desired, source: src } else { force: desired }
|
|
1335
|
-
try
|
|
1336
|
-
el.togglePopover(opts)
|
|
1337
|
-
catch
|
|
1338
|
-
# If the element cannot be toggled right now (for example detached),
|
|
1339
|
-
# keep runtime stable; effect will retry on the next reactive pass.
|
|
1340
|
-
null
|
|
1341
|
-
else
|
|
1342
|
-
syncState(desired)
|
|
1343
|
-
|
|
1344
|
-
-> el.removeEventListener 'toggle', onToggle
|
|
1345
|
-
|
|
1346
|
-
# bindDialog — sync reactive open state with native <dialog>
|
|
1347
|
-
# open: reactive boolean
|
|
1348
|
-
# dialog: dialog element or lazy getter (=> el)
|
|
1349
|
-
# setOpen: callback invoked when native state changes
|
|
1350
|
-
# dismissable: false prevents Esc/cancel close
|
|
1351
|
-
_ariaBindDialog = (open, dialog, setOpen, dismissable = true) ->
|
|
1352
|
-
get = (x) -> if typeof x is 'function' then x() else x
|
|
1353
|
-
currentFocus = ->
|
|
1354
|
-
active = document.activeElement
|
|
1355
|
-
return active if active and active isnt document.body
|
|
1356
|
-
last = globalThis.__ariaLastFocusedEl
|
|
1357
|
-
return last if last?.isConnected isnt false
|
|
1358
|
-
null
|
|
1359
|
-
el = get(dialog)
|
|
1360
|
-
return unless el?.showModal?
|
|
1361
|
-
restoreEl = null
|
|
1362
|
-
|
|
1363
|
-
syncState = (isOpen) ->
|
|
1364
|
-
if isOpen
|
|
1365
|
-
el.hidden = false
|
|
1366
|
-
try el.inert = false
|
|
1367
|
-
catch then null
|
|
1368
|
-
el.removeAttribute 'aria-hidden'
|
|
1369
|
-
else
|
|
1370
|
-
try el.inert = true
|
|
1371
|
-
catch then null
|
|
1372
|
-
el.setAttribute 'aria-hidden', 'true'
|
|
1373
|
-
el.hidden = true
|
|
1374
|
-
|
|
1375
|
-
restoreFocus = ->
|
|
1376
|
-
target = restoreEl
|
|
1377
|
-
restoreEl = null
|
|
1378
|
-
return unless target?.focus?
|
|
1379
|
-
focusAttempt = (tries = 6) ->
|
|
1380
|
-
return unless target.isConnected isnt false
|
|
1381
|
-
try target.focus preventScroll: true
|
|
1382
|
-
catch then target.focus()
|
|
1383
|
-
return if document.activeElement is target or tries <= 1
|
|
1384
|
-
setTimeout (-> focusAttempt(tries - 1)), 16
|
|
1385
|
-
requestAnimationFrame -> focusAttempt()
|
|
1386
|
-
|
|
1387
|
-
onCancel = (e) ->
|
|
1388
|
-
unless dismissable
|
|
1389
|
-
e.preventDefault()
|
|
1390
|
-
return
|
|
1391
|
-
setOpen?(false)
|
|
1392
|
-
|
|
1393
|
-
onClose = ->
|
|
1394
|
-
setOpen?(false)
|
|
1395
|
-
syncState(false)
|
|
1396
|
-
restoreFocus()
|
|
1397
|
-
el.addEventListener 'cancel', onCancel
|
|
1398
|
-
el.addEventListener 'close', onClose
|
|
1399
|
-
|
|
1400
|
-
if open and not el.open
|
|
1401
|
-
restoreEl = currentFocus() unless restoreEl
|
|
1402
|
-
syncState(true)
|
|
1403
|
-
try el.showModal()
|
|
1404
|
-
catch then null
|
|
1405
|
-
else if not open and el.open
|
|
1406
|
-
el.close()
|
|
1407
|
-
else
|
|
1408
|
-
syncState(!!open)
|
|
1409
|
-
|
|
1410
|
-
->
|
|
1411
|
-
el.removeEventListener 'cancel', onCancel
|
|
1412
|
-
el.removeEventListener 'close', onClose
|
|
1413
|
-
|
|
1414
|
-
_ariaRovingNav = (e, h, orientation = 'vertical') ->
|
|
1415
|
-
return if e.isComposing # IME guard
|
|
1416
|
-
vert = orientation isnt 'horizontal'
|
|
1417
|
-
horz = orientation isnt 'vertical'
|
|
1418
|
-
switch e.key
|
|
1419
|
-
when 'ArrowDown' then _ariaNAV e, h.next if vert
|
|
1420
|
-
when 'ArrowUp' then _ariaNAV e, h.prev if vert
|
|
1421
|
-
when 'ArrowRight' then _ariaNAV e, h.next if horz
|
|
1422
|
-
when 'ArrowLeft' then _ariaNAV e, h.prev if horz
|
|
1423
|
-
when 'Home', 'PageUp' then _ariaNAV e, h.first
|
|
1424
|
-
when 'End', 'PageDown' then _ariaNAV e, h.last
|
|
1425
|
-
when 'Enter', ' ' then _ariaNAV e, h.select
|
|
1426
|
-
when 'Escape' then _ariaNAV e, h.dismiss
|
|
1427
|
-
else h.char?(e.key) if e.key.length is 1 # printable chars: typeahead (e.g. tabs)
|
|
1428
|
-
|
|
1429
|
-
# positionBelow — position a fixed popup below its trigger with viewport flip
|
|
1430
|
-
# gap: pixels between trigger bottom and popup top (default 4)
|
|
1431
|
-
# setVisible: call visibility:visible after positioning (default true)
|
|
1432
|
-
_ariaPositionBelow = (trigger, popup, gap = 4, setVisible = true) ->
|
|
1433
|
-
return unless trigger and popup
|
|
1434
|
-
tr = trigger.getBoundingClientRect()
|
|
1435
|
-
popup.style.position = 'fixed'
|
|
1436
|
-
popup.style.left = "#{tr.left}px"
|
|
1437
|
-
popup.style.top = "#{tr.bottom + gap}px"
|
|
1438
|
-
popup.style.minWidth = "#{tr.width}px"
|
|
1439
|
-
fl = popup.getBoundingClientRect()
|
|
1440
|
-
popup.style.top = "#{tr.top - fl.height - gap}px" if fl.bottom > window.innerHeight
|
|
1441
|
-
popup.style.left = "#{window.innerWidth - fl.width - gap}px" if fl.right > window.innerWidth
|
|
1442
|
-
popup.style.visibility = 'visible' if setVisible
|
|
1443
|
-
|
|
1444
|
-
# trapFocus — trap Tab key inside a panel (focus wraps first↔last)
|
|
1445
|
-
# Returns a cleanup function; call it when the panel closes
|
|
1446
|
-
_FOCUSABLE =! 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])'
|
|
1447
|
-
_ariaTrapFocus = (panel) ->
|
|
1448
|
-
handler = (e) ->
|
|
1449
|
-
return unless e.key is 'Tab'
|
|
1450
|
-
list = Array.from(panel.querySelectorAll(_FOCUSABLE)).filter (f) -> f.offsetParent isnt null
|
|
1451
|
-
return unless list.length
|
|
1452
|
-
first = list[0]; last = list[list.length - 1]
|
|
1453
|
-
if e.shiftKey
|
|
1454
|
-
if document.activeElement is first then (e.preventDefault(); last.focus())
|
|
1455
|
-
else
|
|
1456
|
-
if document.activeElement is last then (e.preventDefault(); first.focus())
|
|
1457
|
-
panel.addEventListener 'keydown', handler
|
|
1458
|
-
-> panel.removeEventListener 'keydown', handler
|
|
1459
|
-
|
|
1460
|
-
# wireAria — auto-label a panel from its first heading and first paragraph
|
|
1461
|
-
_ariaWireAria = (panel, id) ->
|
|
1462
|
-
return unless panel
|
|
1463
|
-
heading = panel.querySelector('h1,h2,h3,h4,h5,h6')
|
|
1464
|
-
if heading
|
|
1465
|
-
heading.id ?= "#{id}-title"
|
|
1466
|
-
panel.setAttribute 'aria-labelledby', heading.id
|
|
1467
|
-
desc = panel.querySelector('p')
|
|
1468
|
-
if desc
|
|
1469
|
-
desc.id ?= "#{id}-desc"
|
|
1470
|
-
panel.setAttribute 'aria-describedby', desc.id
|
|
1471
|
-
|
|
1472
|
-
# Shared modal scroll-lock stack — fixes race when Dialog + AlertDialog both open
|
|
1473
|
-
# lockScroll(instance): locks body scroll
|
|
1474
|
-
# unlockScroll(instance): removes from stack, restores scroll if stack empty
|
|
1475
|
-
_ariaModalStack = []
|
|
1476
|
-
_ariaLockScroll = (instance) ->
|
|
1477
|
-
scrollY = window.scrollY
|
|
1478
|
-
_ariaModalStack.push { instance, scrollY }
|
|
1479
|
-
if _ariaModalStack.length is 1
|
|
1480
|
-
document.body.style.position = 'fixed'
|
|
1481
|
-
document.body.style.top = "-#{scrollY}px"
|
|
1482
|
-
document.body.style.width = '100%'
|
|
1483
|
-
|
|
1484
|
-
_ariaUnlockScroll = (instance) ->
|
|
1485
|
-
idx = _ariaModalStack.findIndex (m) -> m.instance is instance
|
|
1486
|
-
return if idx < 0
|
|
1487
|
-
{ scrollY } = _ariaModalStack.splice(idx, 1)[0]
|
|
1488
|
-
unless _ariaModalStack.length
|
|
1489
|
-
document.body.style.position = ''
|
|
1490
|
-
document.body.style.top = ''
|
|
1491
|
-
document.body.style.width = ''
|
|
1492
|
-
window.scrollTo 0, scrollY
|
|
1493
|
-
|
|
1494
|
-
_ariaHasAnchor = do ->
|
|
1495
|
-
try
|
|
1496
|
-
return false unless document?.createElement
|
|
1497
|
-
anchor = document.createElement('div')
|
|
1498
|
-
floating = document.createElement('div')
|
|
1499
|
-
anchor.style.cssText = 'position:fixed;top:100px;left:100px;width:10px;height:10px;anchor-name:--probe'
|
|
1500
|
-
floating.style.cssText = 'position:fixed;inset:auto;margin:0;position-anchor:--probe;position-area:bottom start;width:10px;height:10px'
|
|
1501
|
-
document.body.appendChild(anchor)
|
|
1502
|
-
document.body.appendChild(floating)
|
|
1503
|
-
rect = floating.getBoundingClientRect()
|
|
1504
|
-
anchor.remove()
|
|
1505
|
-
floating.remove()
|
|
1506
|
-
rect.top > 50
|
|
1507
|
-
catch
|
|
1508
|
-
false
|
|
1509
|
-
|
|
1510
|
-
_ariaPosition = (trigger, floating, opts = {}) ->
|
|
1511
|
-
return unless trigger and floating
|
|
1512
|
-
placement = opts.placement ?? 'bottom start'
|
|
1513
|
-
offset = opts.offset ?? 4
|
|
1514
|
-
matchWidth = opts.matchWidth ?? false
|
|
1515
|
-
if _ariaHasAnchor
|
|
1516
|
-
name = "--anchor-#{floating.id or Math.random().toString(36).slice(2, 8)}"
|
|
1517
|
-
trigger.style.anchorName = name
|
|
1518
|
-
floating.style.positionAnchor = name
|
|
1519
|
-
floating.style.position = 'fixed'
|
|
1520
|
-
floating.style.inset = 'auto'
|
|
1521
|
-
floating.style.margin = '0'
|
|
1522
|
-
floating.style.positionArea = placement
|
|
1523
|
-
floating.style.positionTry = 'flip-block, flip-inline, flip-block flip-inline'
|
|
1524
|
-
floating.style.positionVisibility = 'anchors-visible'
|
|
1525
|
-
[side] = placement.split(' ')
|
|
1526
|
-
floating.style.marginTop = ''
|
|
1527
|
-
floating.style.marginBottom = ''
|
|
1528
|
-
floating.style.marginLeft = ''
|
|
1529
|
-
floating.style.marginRight = ''
|
|
1530
|
-
switch side
|
|
1531
|
-
when 'bottom' then floating.style.marginTop = "#{offset}px"
|
|
1532
|
-
when 'top' then floating.style.marginBottom = "#{offset}px"
|
|
1533
|
-
when 'left' then floating.style.marginRight = "#{offset}px"
|
|
1534
|
-
when 'right' then floating.style.marginLeft = "#{offset}px"
|
|
1535
|
-
floating.style.minWidth = 'anchor-size(width)' if matchWidth
|
|
1536
|
-
else
|
|
1537
|
-
rect = trigger.getBoundingClientRect()
|
|
1538
|
-
floating.style.position = 'fixed'
|
|
1539
|
-
floating.style.inset = 'auto'
|
|
1540
|
-
floating.style.margin = '0'
|
|
1541
|
-
[side, align] = placement.split(' ')
|
|
1542
|
-
align ??= 'start'
|
|
1543
|
-
switch side
|
|
1544
|
-
when 'bottom'
|
|
1545
|
-
floating.style.top = "#{rect.bottom + offset}px"
|
|
1546
|
-
when 'top'
|
|
1547
|
-
floating.style.bottom = "#{window.innerHeight - rect.top + offset}px"
|
|
1548
|
-
when 'left'
|
|
1549
|
-
floating.style.right = "#{window.innerWidth - rect.left + offset}px"
|
|
1550
|
-
when 'right'
|
|
1551
|
-
floating.style.left = "#{rect.right + offset}px"
|
|
1552
|
-
if side in ['bottom', 'top']
|
|
1553
|
-
switch align
|
|
1554
|
-
when 'start' then floating.style.left = "#{rect.left}px"
|
|
1555
|
-
when 'center' then floating.style.left = "#{rect.left + rect.width / 2}px"; floating.style.transform = 'translateX(-50%)'
|
|
1556
|
-
when 'end' then floating.style.right = "#{window.innerWidth - rect.right}px"
|
|
1557
|
-
else
|
|
1558
|
-
switch align
|
|
1559
|
-
when 'start' then floating.style.top = "#{rect.top}px"
|
|
1560
|
-
when 'center' then floating.style.top = "#{rect.top + rect.height / 2}px"; floating.style.transform = 'translateY(-50%)'
|
|
1561
|
-
when 'end' then floating.style.bottom = "#{window.innerHeight - rect.bottom}px"
|
|
1562
|
-
floating.style.minWidth = "#{rect.width}px" if matchWidth
|
|
1563
|
-
|
|
1564
|
-
globalThis.__aria ??= {
|
|
1565
|
-
listNav: _ariaListNav, rovingNav: _ariaRovingNav, popupDismiss: _ariaPopupDismiss, popupGuard: _ariaPopupGuard,
|
|
1566
|
-
bindPopover: _ariaBindPopover, bindDialog: _ariaBindDialog,
|
|
1567
|
-
positionBelow: _ariaPositionBelow, trapFocus: _ariaTrapFocus, wireAria: _ariaWireAria,
|
|
1568
|
-
lockScroll: _ariaLockScroll, unlockScroll: _ariaUnlockScroll,
|
|
1569
|
-
position: _ariaPosition, hasAnchor: _ariaHasAnchor,
|
|
1570
|
-
}
|
|
1571
|
-
globalThis.ARIA ??= globalThis.__aria
|