@rip-lang/ui 0.1.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 +694 -0
- package/package.json +61 -0
- package/renderer.js +397 -0
- package/router.js +325 -0
- package/serve.rip +143 -0
- package/stash.js +413 -0
- package/ui.js +208 -0
- package/vfs.js +215 -0
package/stash.js
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// Reactive Stash — Deep reactive state tree with path navigation
|
|
3
|
+
//
|
|
4
|
+
// Combines:
|
|
5
|
+
// - Stash's deep path navigation (get/set/has/del/inc/merge/keys/values)
|
|
6
|
+
// - Vue-style deep Proxy reactivity (every nested property is tracked)
|
|
7
|
+
// - Rip's signal model (state/computed/effect with auto-tracking)
|
|
8
|
+
//
|
|
9
|
+
// Usage:
|
|
10
|
+
// const app = stash({ user: null, theme: 'light', cart: { items: [] } })
|
|
11
|
+
// app.user = { name: "Alice" } // triggers reactive updates
|
|
12
|
+
// app.get("cart.items[0].price") // deep path access
|
|
13
|
+
// app.set("user.name", "Bob") // deep path write, triggers updates
|
|
14
|
+
// effect(() => console.log(app.theme)) // re-runs when theme changes
|
|
15
|
+
//
|
|
16
|
+
// Author: Steve Shreeve <steve.shreeve@gmail.com>
|
|
17
|
+
// Date: February 2026
|
|
18
|
+
// =============================================================================
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Reactive core — same algorithm as Rip's runtime, standalone for the browser
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
let currentEffect = null;
|
|
25
|
+
let pendingEffects = new Set();
|
|
26
|
+
let batching = false;
|
|
27
|
+
|
|
28
|
+
function flushEffects() {
|
|
29
|
+
const effects = [...pendingEffects];
|
|
30
|
+
pendingEffects.clear();
|
|
31
|
+
for (const e of effects) e.run();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Create a reactive signal for a single value
|
|
35
|
+
function signal(initialValue) {
|
|
36
|
+
let value = initialValue;
|
|
37
|
+
const subscribers = new Set();
|
|
38
|
+
let notifying = false;
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
get() {
|
|
42
|
+
if (currentEffect) {
|
|
43
|
+
subscribers.add(currentEffect);
|
|
44
|
+
currentEffect.dependencies.add(subscribers);
|
|
45
|
+
}
|
|
46
|
+
return value;
|
|
47
|
+
},
|
|
48
|
+
set(newValue) {
|
|
49
|
+
if (newValue === value || notifying) return;
|
|
50
|
+
value = newValue;
|
|
51
|
+
notifying = true;
|
|
52
|
+
for (const sub of subscribers) {
|
|
53
|
+
if (sub.markDirty) sub.markDirty();
|
|
54
|
+
else pendingEffects.add(sub);
|
|
55
|
+
}
|
|
56
|
+
if (!batching) flushEffects();
|
|
57
|
+
notifying = false;
|
|
58
|
+
},
|
|
59
|
+
peek() { return value; },
|
|
60
|
+
subscribers
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Create a computed value that auto-tracks dependencies
|
|
65
|
+
export function computed(fn) {
|
|
66
|
+
let value;
|
|
67
|
+
let dirty = true;
|
|
68
|
+
const subscribers = new Set();
|
|
69
|
+
|
|
70
|
+
const comp = {
|
|
71
|
+
dependencies: new Set(),
|
|
72
|
+
markDirty() {
|
|
73
|
+
if (dirty) return;
|
|
74
|
+
dirty = true;
|
|
75
|
+
for (const sub of subscribers) {
|
|
76
|
+
if (sub.markDirty) sub.markDirty();
|
|
77
|
+
else pendingEffects.add(sub);
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
get value() {
|
|
81
|
+
if (currentEffect) {
|
|
82
|
+
subscribers.add(currentEffect);
|
|
83
|
+
currentEffect.dependencies.add(subscribers);
|
|
84
|
+
}
|
|
85
|
+
if (dirty) {
|
|
86
|
+
for (const dep of comp.dependencies) dep.delete(comp);
|
|
87
|
+
comp.dependencies.clear();
|
|
88
|
+
const prev = currentEffect;
|
|
89
|
+
currentEffect = comp;
|
|
90
|
+
try { value = fn(); } finally { currentEffect = prev; }
|
|
91
|
+
dirty = false;
|
|
92
|
+
}
|
|
93
|
+
return value;
|
|
94
|
+
},
|
|
95
|
+
peek() { return value; }
|
|
96
|
+
};
|
|
97
|
+
return comp;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Create a side effect that re-runs when its dependencies change
|
|
101
|
+
export function effect(fn) {
|
|
102
|
+
const eff = {
|
|
103
|
+
dependencies: new Set(),
|
|
104
|
+
run() {
|
|
105
|
+
for (const dep of eff.dependencies) dep.delete(eff);
|
|
106
|
+
eff.dependencies.clear();
|
|
107
|
+
const prev = currentEffect;
|
|
108
|
+
currentEffect = eff;
|
|
109
|
+
try { fn(); } finally { currentEffect = prev; }
|
|
110
|
+
},
|
|
111
|
+
stop() {
|
|
112
|
+
for (const dep of eff.dependencies) dep.delete(eff);
|
|
113
|
+
eff.dependencies.clear();
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
eff.run();
|
|
117
|
+
return eff;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Group multiple updates — effects only run once at the end
|
|
121
|
+
export function batch(fn) {
|
|
122
|
+
if (batching) return fn();
|
|
123
|
+
batching = true;
|
|
124
|
+
try { return fn(); }
|
|
125
|
+
finally { batching = false; flushEffects(); }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// Path navigation — adapted from Stash
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
const PATH_RE = /([./][^./\[\s]+|\[[-+]?\d+\]|\[(?:"[^"]+"|'[^']+')\])/;
|
|
133
|
+
const isNum = (v) => /^-?\d+$/.test(v);
|
|
134
|
+
|
|
135
|
+
function walk(path) {
|
|
136
|
+
const list = ('.' + path).split(PATH_RE);
|
|
137
|
+
list.shift();
|
|
138
|
+
const result = [];
|
|
139
|
+
for (let i = 0; i < list.length; i += 2) {
|
|
140
|
+
const part = list[i];
|
|
141
|
+
const chr = part[0];
|
|
142
|
+
if (chr === '.' || chr === '/') result.push(part.slice(1));
|
|
143
|
+
else if (chr === '[') {
|
|
144
|
+
if (part[1] === '"' || part[1] === "'") result.push(part.slice(2, -2));
|
|
145
|
+
else result.push(+(part.slice(1, -1)));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return result;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
// Reactive Stash — deep Proxy wrapping with signal-per-property
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
const STASH = Symbol('stash');
|
|
156
|
+
const SIGNALS = Symbol('signals');
|
|
157
|
+
const RAW = Symbol('raw');
|
|
158
|
+
|
|
159
|
+
// Get or create a signal for a property on a target
|
|
160
|
+
function getSignal(target, prop) {
|
|
161
|
+
let signals = target[SIGNALS];
|
|
162
|
+
if (!signals) {
|
|
163
|
+
signals = new Map();
|
|
164
|
+
Object.defineProperty(target, SIGNALS, { value: signals, enumerable: false });
|
|
165
|
+
}
|
|
166
|
+
let sig = signals.get(prop);
|
|
167
|
+
if (!sig) {
|
|
168
|
+
sig = signal(target[prop]);
|
|
169
|
+
signals.set(prop, sig);
|
|
170
|
+
}
|
|
171
|
+
return sig;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Keys signal — tracks when keys are added/removed
|
|
175
|
+
function getKeysSignal(target) {
|
|
176
|
+
return getSignal(target, Symbol.for('keys'));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Recursively wrap nested objects/arrays in reactive proxies
|
|
180
|
+
function wrapDeep(value) {
|
|
181
|
+
if (value === null || typeof value !== 'object') return value;
|
|
182
|
+
if (value[STASH]) return value; // already wrapped
|
|
183
|
+
if (value instanceof Date || value instanceof RegExp || value instanceof Map ||
|
|
184
|
+
value instanceof Set || value instanceof WeakMap || value instanceof WeakSet ||
|
|
185
|
+
value instanceof Promise) return value;
|
|
186
|
+
return createProxy(value);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function createProxy(target) {
|
|
190
|
+
const proxy = new Proxy(target, {
|
|
191
|
+
get(target, prop, receiver) {
|
|
192
|
+
// Internal markers
|
|
193
|
+
if (prop === STASH) return true;
|
|
194
|
+
if (prop === RAW) return target;
|
|
195
|
+
|
|
196
|
+
// Stash API methods — don't track these
|
|
197
|
+
if (typeof prop === 'string' && STASH_METHODS.has(prop)) {
|
|
198
|
+
return stashMethods[prop].bind(null, proxy);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Symbol access — pass through
|
|
202
|
+
if (typeof prop === 'symbol') return Reflect.get(target, prop, receiver);
|
|
203
|
+
|
|
204
|
+
// Track key enumeration
|
|
205
|
+
if (prop === 'length' && Array.isArray(target)) {
|
|
206
|
+
getKeysSignal(target).get();
|
|
207
|
+
return target.length;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Get the signal for this property
|
|
211
|
+
const sig = getSignal(target, prop);
|
|
212
|
+
const value = sig.get();
|
|
213
|
+
|
|
214
|
+
// Wrap nested objects lazily
|
|
215
|
+
if (value !== null && typeof value === 'object' && !value[STASH]) {
|
|
216
|
+
const wrapped = wrapDeep(value);
|
|
217
|
+
if (wrapped !== value) {
|
|
218
|
+
target[prop] = wrapped;
|
|
219
|
+
sig.set(wrapped);
|
|
220
|
+
}
|
|
221
|
+
return wrapped;
|
|
222
|
+
}
|
|
223
|
+
return value;
|
|
224
|
+
},
|
|
225
|
+
|
|
226
|
+
set(target, prop, value, receiver) {
|
|
227
|
+
const sig = getSignal(target, prop);
|
|
228
|
+
const old = sig.peek();
|
|
229
|
+
|
|
230
|
+
// Wrap nested objects
|
|
231
|
+
const wrapped = wrapDeep(value);
|
|
232
|
+
target[prop] = wrapped;
|
|
233
|
+
sig.set(wrapped);
|
|
234
|
+
|
|
235
|
+
// If adding a new key, notify keys watchers
|
|
236
|
+
if (old === undefined && wrapped !== undefined) {
|
|
237
|
+
getKeysSignal(target).set(Object.keys(target));
|
|
238
|
+
}
|
|
239
|
+
return true;
|
|
240
|
+
},
|
|
241
|
+
|
|
242
|
+
deleteProperty(target, prop) {
|
|
243
|
+
const had = prop in target;
|
|
244
|
+
delete target[prop];
|
|
245
|
+
if (had) {
|
|
246
|
+
const sig = getSignal(target, prop);
|
|
247
|
+
sig.set(undefined);
|
|
248
|
+
getKeysSignal(target).set(Object.keys(target));
|
|
249
|
+
}
|
|
250
|
+
return true;
|
|
251
|
+
},
|
|
252
|
+
|
|
253
|
+
has(target, prop) {
|
|
254
|
+
if (prop === STASH || prop === RAW) return true;
|
|
255
|
+
getKeysSignal(target).get();
|
|
256
|
+
return prop in target;
|
|
257
|
+
},
|
|
258
|
+
|
|
259
|
+
ownKeys(target) {
|
|
260
|
+
getKeysSignal(target).get();
|
|
261
|
+
return Reflect.ownKeys(target);
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
return proxy;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
// Stash API methods — path-based access on the reactive tree
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
const stashMethods = {
|
|
273
|
+
// Get value at deep path
|
|
274
|
+
get(proxy, path, defaultValue) {
|
|
275
|
+
const list = walk(path);
|
|
276
|
+
let current = proxy;
|
|
277
|
+
for (const prop of list) {
|
|
278
|
+
if (current == null || typeof current !== 'object') return defaultValue;
|
|
279
|
+
const key = isNum(String(prop)) && Array.isArray(current[RAW] ?? current) && +prop < 0
|
|
280
|
+
? (current[RAW] ?? current).length + +prop
|
|
281
|
+
: prop;
|
|
282
|
+
current = current[key];
|
|
283
|
+
}
|
|
284
|
+
return current != null ? current : defaultValue;
|
|
285
|
+
},
|
|
286
|
+
|
|
287
|
+
// Set value at deep path, creating intermediate objects/arrays as needed
|
|
288
|
+
set(proxy, path, value) {
|
|
289
|
+
const list = walk(path);
|
|
290
|
+
const last = list.length - 1;
|
|
291
|
+
let current = proxy;
|
|
292
|
+
for (let i = 0; i < list.length; i++) {
|
|
293
|
+
const prop = list[i];
|
|
294
|
+
const key = isNum(String(prop)) && Array.isArray(current[RAW] ?? current) && +prop < 0
|
|
295
|
+
? (current[RAW] ?? current).length + +prop
|
|
296
|
+
: prop;
|
|
297
|
+
if (i === last) {
|
|
298
|
+
current[key] = value;
|
|
299
|
+
} else {
|
|
300
|
+
let next = current[key];
|
|
301
|
+
if (next == null || typeof next !== 'object') {
|
|
302
|
+
next = isNum(String(list[i + 1])) ? [] : {};
|
|
303
|
+
current[key] = next;
|
|
304
|
+
}
|
|
305
|
+
current = current[key]; // re-read to get proxy-wrapped version
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return value;
|
|
309
|
+
},
|
|
310
|
+
|
|
311
|
+
// Check if path exists
|
|
312
|
+
has(proxy, path) {
|
|
313
|
+
return stashMethods.get(proxy, path) != null;
|
|
314
|
+
},
|
|
315
|
+
|
|
316
|
+
// Delete value at path
|
|
317
|
+
del(proxy, path) {
|
|
318
|
+
const list = walk(path);
|
|
319
|
+
if (list.length === 0) return;
|
|
320
|
+
const parentPath = list.slice(0, -1);
|
|
321
|
+
const key = list[list.length - 1];
|
|
322
|
+
const parent = parentPath.length > 0
|
|
323
|
+
? stashMethods.get(proxy, parentPath.map(p => typeof p === 'number' ? `[${p}]` : p).join('.'))
|
|
324
|
+
: proxy;
|
|
325
|
+
if (parent != null && typeof parent === 'object') {
|
|
326
|
+
delete parent[key];
|
|
327
|
+
}
|
|
328
|
+
},
|
|
329
|
+
|
|
330
|
+
// Increment a numeric value at path
|
|
331
|
+
inc(proxy, path, step = 1, init = 0) {
|
|
332
|
+
const current = stashMethods.get(proxy, path);
|
|
333
|
+
const next = typeof current === 'number' ? current + step : init;
|
|
334
|
+
stashMethods.set(proxy, path, next);
|
|
335
|
+
return next;
|
|
336
|
+
},
|
|
337
|
+
|
|
338
|
+
// Get all keys at path (or root)
|
|
339
|
+
keys(proxy, path) {
|
|
340
|
+
const target = path ? stashMethods.get(proxy, path) : proxy;
|
|
341
|
+
if (target == null || typeof target !== 'object') return [];
|
|
342
|
+
return Object.keys(target[RAW] ?? target);
|
|
343
|
+
},
|
|
344
|
+
|
|
345
|
+
// Get all values at path (or root)
|
|
346
|
+
values(proxy, path) {
|
|
347
|
+
const target = path ? stashMethods.get(proxy, path) : proxy;
|
|
348
|
+
if (target == null || typeof target !== 'object') return [];
|
|
349
|
+
return Object.values(target[RAW] ?? target);
|
|
350
|
+
},
|
|
351
|
+
|
|
352
|
+
// Merge data into path (or root)
|
|
353
|
+
merge(proxy, pathOrObj, obj) {
|
|
354
|
+
if (typeof pathOrObj === 'object' && obj === undefined) {
|
|
355
|
+
obj = pathOrObj;
|
|
356
|
+
batch(() => { for (const [k, v] of Object.entries(obj)) proxy[k] = v; });
|
|
357
|
+
} else {
|
|
358
|
+
const target = stashMethods.get(proxy, pathOrObj);
|
|
359
|
+
if (target != null && typeof target === 'object') {
|
|
360
|
+
batch(() => { for (const [k, v] of Object.entries(obj)) target[k] = v; });
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return proxy;
|
|
364
|
+
},
|
|
365
|
+
|
|
366
|
+
// Run a function stored at path
|
|
367
|
+
run(proxy, path, ...args) {
|
|
368
|
+
const fn = stashMethods.get(proxy, path);
|
|
369
|
+
if (typeof fn === 'function') return fn.call(proxy, ...args);
|
|
370
|
+
console.warn(`stash.run: not a function at '${path}'`);
|
|
371
|
+
},
|
|
372
|
+
|
|
373
|
+
// Get raw (unwrapped) data
|
|
374
|
+
toJSON(proxy) {
|
|
375
|
+
return JSON.parse(JSON.stringify(proxy[RAW]));
|
|
376
|
+
},
|
|
377
|
+
|
|
378
|
+
// Pretty print
|
|
379
|
+
toString(proxy) {
|
|
380
|
+
return JSON.stringify(proxy[RAW], null, 2);
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
const STASH_METHODS = new Set(Object.keys(stashMethods));
|
|
385
|
+
|
|
386
|
+
// ---------------------------------------------------------------------------
|
|
387
|
+
// Factory — create a Reactive Stash
|
|
388
|
+
// ---------------------------------------------------------------------------
|
|
389
|
+
|
|
390
|
+
export function stash(data = {}) {
|
|
391
|
+
// Handle ES module default export
|
|
392
|
+
if (data.default != null) data = data.default;
|
|
393
|
+
return wrapDeep(data);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Alias
|
|
397
|
+
export const Stash = stash;
|
|
398
|
+
|
|
399
|
+
// Get the raw unwrapped object from a reactive stash
|
|
400
|
+
export function raw(proxy) {
|
|
401
|
+
return proxy?.[RAW] ?? proxy;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Check if something is a reactive stash
|
|
405
|
+
export function isStash(obj) {
|
|
406
|
+
return obj?.[STASH] === true;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Export reactive primitives not already exported inline
|
|
410
|
+
export { signal, walk };
|
|
411
|
+
|
|
412
|
+
// Default export
|
|
413
|
+
export default stash;
|
package/ui.js
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// @rip-lang/ui — Zero-build reactive web framework
|
|
3
|
+
//
|
|
4
|
+
// Combines:
|
|
5
|
+
// - Reactive Stash (deep state tree with path navigation)
|
|
6
|
+
// - Virtual File System (browser-local file storage)
|
|
7
|
+
// - File-Based Router (URL ↔ VFS paths)
|
|
8
|
+
// - Component Renderer (mounts compiled Rip components)
|
|
9
|
+
//
|
|
10
|
+
// Usage:
|
|
11
|
+
// import { createApp } from '@rip-lang/ui'
|
|
12
|
+
//
|
|
13
|
+
// const app = createApp({
|
|
14
|
+
// target: '#app',
|
|
15
|
+
// state: { user: null, theme: 'dark' },
|
|
16
|
+
// files: { 'pages/index.rip': '...' }
|
|
17
|
+
// })
|
|
18
|
+
//
|
|
19
|
+
// Or in the browser with the Rip compiler:
|
|
20
|
+
// <script type="module">
|
|
21
|
+
// import { createApp } from '/ui.js'
|
|
22
|
+
// import { compileToJS } from '/rip.browser.js'
|
|
23
|
+
// createApp({ compile: compileToJS, target: '#app' }).start()
|
|
24
|
+
// </script>
|
|
25
|
+
//
|
|
26
|
+
// Author: Steve Shreeve <steve.shreeve@gmail.com>
|
|
27
|
+
// Date: February 2026
|
|
28
|
+
// =============================================================================
|
|
29
|
+
|
|
30
|
+
// Re-export everything
|
|
31
|
+
export { stash, Stash, signal, computed, effect, batch, raw, isStash, walk } from './stash.js';
|
|
32
|
+
export { vfs } from './vfs.js';
|
|
33
|
+
export { createRouter, fileToPattern, patternToRegex, matchRoute } from './router.js';
|
|
34
|
+
export { createRenderer } from './renderer.js';
|
|
35
|
+
|
|
36
|
+
// Import for internal use
|
|
37
|
+
import { stash } from './stash.js';
|
|
38
|
+
import { vfs } from './vfs.js';
|
|
39
|
+
import { createRouter } from './router.js';
|
|
40
|
+
import { createRenderer } from './renderer.js';
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// createApp — one-call setup for a Rip application
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
export function createApp(options = {}) {
|
|
47
|
+
const {
|
|
48
|
+
target = '#app', // DOM mount target
|
|
49
|
+
state = {}, // Initial app state
|
|
50
|
+
files = {}, // Initial VFS files { path: content }
|
|
51
|
+
root = 'pages', // Pages directory in VFS
|
|
52
|
+
compile, // Rip compiler function (compileToJS)
|
|
53
|
+
transition, // Route transition config
|
|
54
|
+
onError, // Global error handler
|
|
55
|
+
onNavigate // Navigation callback
|
|
56
|
+
} = options;
|
|
57
|
+
|
|
58
|
+
// 1. Create the reactive stash (app-level state)
|
|
59
|
+
const app = stash(state);
|
|
60
|
+
|
|
61
|
+
// 2. Create the virtual file system
|
|
62
|
+
const fs = vfs(files);
|
|
63
|
+
|
|
64
|
+
// 3. Create the router
|
|
65
|
+
const router = createRouter(fs, {
|
|
66
|
+
root,
|
|
67
|
+
compile,
|
|
68
|
+
onError: onError || defaultErrorHandler
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// 4. Create the renderer
|
|
72
|
+
const renderer = createRenderer({
|
|
73
|
+
router,
|
|
74
|
+
fs,
|
|
75
|
+
stash: app,
|
|
76
|
+
compile,
|
|
77
|
+
target,
|
|
78
|
+
onError: onError || defaultErrorHandler,
|
|
79
|
+
transition
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Navigation hook
|
|
83
|
+
if (onNavigate) router.onNavigate(onNavigate);
|
|
84
|
+
|
|
85
|
+
// Public API
|
|
86
|
+
const instance = {
|
|
87
|
+
// Core systems
|
|
88
|
+
app, // Reactive stash (app state)
|
|
89
|
+
fs, // Virtual file system
|
|
90
|
+
router, // File-based router
|
|
91
|
+
renderer, // Component renderer
|
|
92
|
+
|
|
93
|
+
// Start the application
|
|
94
|
+
start() {
|
|
95
|
+
renderer.start();
|
|
96
|
+
return instance;
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
// Stop the application
|
|
100
|
+
stop() {
|
|
101
|
+
instance._eventSource?.close();
|
|
102
|
+
instance._eventSource = null;
|
|
103
|
+
renderer.stop();
|
|
104
|
+
router.destroy();
|
|
105
|
+
return instance;
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
// Load files into the VFS from URLs
|
|
109
|
+
async load(manifest) {
|
|
110
|
+
await fs.fetchManifest(manifest);
|
|
111
|
+
router.rebuild();
|
|
112
|
+
return instance;
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
// Load a bundled manifest (all page sources in one JSON response)
|
|
116
|
+
async loadBundle(url) {
|
|
117
|
+
const res = await fetch(url);
|
|
118
|
+
const bundle = await res.json();
|
|
119
|
+
fs.load(bundle);
|
|
120
|
+
router.rebuild();
|
|
121
|
+
return instance;
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
// Connect to SSE watch endpoint for hot reload (notify + invalidate + refetch)
|
|
125
|
+
watch(url, opts = {}) {
|
|
126
|
+
const eagerFiles = new Set(opts.eager || []);
|
|
127
|
+
const es = new EventSource(url);
|
|
128
|
+
|
|
129
|
+
es.addEventListener('changed', async (e) => {
|
|
130
|
+
const { paths } = JSON.parse(e.data);
|
|
131
|
+
|
|
132
|
+
// Invalidate all changed files in VFS
|
|
133
|
+
for (const path of paths) {
|
|
134
|
+
fs.delete(path);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Check if any affect the current route (page or layouts)
|
|
138
|
+
const current = router.current;
|
|
139
|
+
const toFetch = paths.filter(p =>
|
|
140
|
+
eagerFiles.has(p) ||
|
|
141
|
+
p === current.route?.file ||
|
|
142
|
+
current.layouts?.includes(p)
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
// Rebuild router (handles new/deleted pages)
|
|
146
|
+
router.rebuild();
|
|
147
|
+
|
|
148
|
+
// Refetch and remount only if current view is affected
|
|
149
|
+
if (toFetch.length > 0) {
|
|
150
|
+
try {
|
|
151
|
+
await Promise.all(toFetch.map(p => fs.fetch(p)));
|
|
152
|
+
renderer.remount();
|
|
153
|
+
} catch (err) {
|
|
154
|
+
console.error('[Rip] Hot reload fetch error:', err);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
es.addEventListener('connected', () => {
|
|
160
|
+
console.log('[Rip] Hot reload connected');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
es.onerror = () => {
|
|
164
|
+
console.log('[Rip] Hot reload reconnecting...');
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
instance._eventSource = es;
|
|
168
|
+
return instance;
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
// Navigate to a route
|
|
172
|
+
go(path) {
|
|
173
|
+
router.push(path);
|
|
174
|
+
return instance;
|
|
175
|
+
},
|
|
176
|
+
|
|
177
|
+
// Add a page to the VFS and navigate to it
|
|
178
|
+
async addPage(path, source) {
|
|
179
|
+
fs.write(`${root}/${path}`, source);
|
|
180
|
+
router.rebuild();
|
|
181
|
+
return instance;
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
// Get/set app state via path
|
|
185
|
+
get(path, defaultValue) { return app.get(path, defaultValue); },
|
|
186
|
+
set(path, value) { app.set(path, value); return instance; }
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
return instance;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
// Default error handler
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
function defaultErrorHandler({ status, message, path, error }) {
|
|
197
|
+
const prefix = status === 404 ? '404 Not Found' : `Error ${status}`;
|
|
198
|
+
console.error(`[Rip] ${prefix}: ${message || path || 'unknown error'}`);
|
|
199
|
+
if (error?.stack) console.error(error.stack);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
// Version
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
export const VERSION = '0.1.1';
|
|
207
|
+
|
|
208
|
+
export default createApp;
|