@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/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;