@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/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rip-lang/ui",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Zero-build reactive web framework — VFS, file-based routing, reactive stash",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "ui.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./ui.js",
|
|
9
|
+
"./stash": "./stash.js",
|
|
10
|
+
"./vfs": "./vfs.js",
|
|
11
|
+
"./router": "./router.js",
|
|
12
|
+
"./renderer": "./renderer.js",
|
|
13
|
+
"./serve": "./serve.rip"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"test": "echo \"Tests coming soon\" && exit 0"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"ui",
|
|
20
|
+
"framework",
|
|
21
|
+
"reactive",
|
|
22
|
+
"vfs",
|
|
23
|
+
"router",
|
|
24
|
+
"stash",
|
|
25
|
+
"signals",
|
|
26
|
+
"no-build",
|
|
27
|
+
"rip",
|
|
28
|
+
"rip-lang"
|
|
29
|
+
],
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/shreeve/rip-lang.git",
|
|
33
|
+
"directory": "packages/ui"
|
|
34
|
+
},
|
|
35
|
+
"homepage": "https://github.com/shreeve/rip-lang/tree/main/packages/ui#readme",
|
|
36
|
+
"bugs": {
|
|
37
|
+
"url": "https://github.com/shreeve/rip-lang/issues"
|
|
38
|
+
},
|
|
39
|
+
"author": "Steve Shreeve <steve.shreeve@gmail.com>",
|
|
40
|
+
"license": "MIT",
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"rip-lang": "^3.1.1"
|
|
43
|
+
},
|
|
44
|
+
"files": [
|
|
45
|
+
"ui.js",
|
|
46
|
+
"stash.js",
|
|
47
|
+
"vfs.js",
|
|
48
|
+
"router.js",
|
|
49
|
+
"renderer.js",
|
|
50
|
+
"serve.rip",
|
|
51
|
+
"README.md"
|
|
52
|
+
],
|
|
53
|
+
"peerDependencies": {
|
|
54
|
+
"@rip-lang/api": ">=1.1.4"
|
|
55
|
+
},
|
|
56
|
+
"peerDependenciesMeta": {
|
|
57
|
+
"@rip-lang/api": {
|
|
58
|
+
"optional": true
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
package/renderer.js
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// Component Renderer — Mounts compiled Rip components into the DOM
|
|
3
|
+
//
|
|
4
|
+
// Orchestrates the lifecycle of routed components:
|
|
5
|
+
// - Compiles .rip source to JS component classes (via Rip compiler)
|
|
6
|
+
// - Mounts/unmounts components on route changes
|
|
7
|
+
// - Nests layout components (wrapping page content)
|
|
8
|
+
// - Provides app-level stash context to all components
|
|
9
|
+
// - Manages transitions between route changes
|
|
10
|
+
//
|
|
11
|
+
// The Rip compiler generates component classes with:
|
|
12
|
+
// _create() — builds DOM nodes
|
|
13
|
+
// _setup() — wires reactive effects
|
|
14
|
+
// mount(el) — appends to DOM, runs lifecycle hooks
|
|
15
|
+
// unmount() — removes from DOM, runs cleanup
|
|
16
|
+
//
|
|
17
|
+
// This renderer manages WHEN and WHERE those components appear.
|
|
18
|
+
//
|
|
19
|
+
// Author: Steve Shreeve <steve.shreeve@gmail.com>
|
|
20
|
+
// Date: February 2026
|
|
21
|
+
// =============================================================================
|
|
22
|
+
|
|
23
|
+
import { effect } from './stash.js';
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Layout Manager — nests layout components around page content
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
// Build a nested layout chain: outermost layout wraps the next, etc.
|
|
30
|
+
// Returns the mount point element where the page content goes
|
|
31
|
+
function createLayoutShell(layouts, container) {
|
|
32
|
+
if (!layouts || layouts.length === 0) return container;
|
|
33
|
+
|
|
34
|
+
let current = container;
|
|
35
|
+
const instances = [];
|
|
36
|
+
|
|
37
|
+
for (const layout of layouts) {
|
|
38
|
+
// Create a slot div for this layout's content
|
|
39
|
+
const slot = document.createElement('div');
|
|
40
|
+
slot.setAttribute('data-layout-slot', '');
|
|
41
|
+
|
|
42
|
+
// Mount the layout, passing the slot as its content target
|
|
43
|
+
const instance = new layout({ slot });
|
|
44
|
+
const root = instance._create();
|
|
45
|
+
current.appendChild(root);
|
|
46
|
+
if (instance._setup) instance._setup();
|
|
47
|
+
if (instance.mounted) instance.mounted();
|
|
48
|
+
instances.push(instance);
|
|
49
|
+
|
|
50
|
+
// The next layout (or the page) mounts into this layout's slot
|
|
51
|
+
current = root.querySelector('[data-slot]') || slot;
|
|
52
|
+
if (current === slot) {
|
|
53
|
+
// Layout didn't define a slot — append slot to layout root
|
|
54
|
+
root.appendChild(slot);
|
|
55
|
+
current = slot;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return { mountPoint: current, instances };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// Renderer — ties router, VFS, compiler, and stash together
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
export function createRenderer(options = {}) {
|
|
67
|
+
const {
|
|
68
|
+
router,
|
|
69
|
+
fs,
|
|
70
|
+
stash: appStash,
|
|
71
|
+
compile, // (source: string) => string — Rip compiler function
|
|
72
|
+
target, // DOM element or selector
|
|
73
|
+
onError, // error handler
|
|
74
|
+
transition // optional transition config
|
|
75
|
+
} = options;
|
|
76
|
+
|
|
77
|
+
// Resolve target element
|
|
78
|
+
let container = typeof target === 'string'
|
|
79
|
+
? document.querySelector(target)
|
|
80
|
+
: target || document.getElementById('app');
|
|
81
|
+
|
|
82
|
+
if (!container) {
|
|
83
|
+
container = document.createElement('div');
|
|
84
|
+
container.id = 'app';
|
|
85
|
+
document.body.appendChild(container);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Current state
|
|
89
|
+
let currentComponent = null;
|
|
90
|
+
let currentLayouts = [];
|
|
91
|
+
let layoutInstances = [];
|
|
92
|
+
let routeStash = null; // per-route state
|
|
93
|
+
let disposeEffect = null; // cleanup for route-watching effect
|
|
94
|
+
let mountGeneration = 0; // guard against overlapping async mounts
|
|
95
|
+
let currentMountPoint = container; // slot within layouts where pages mount
|
|
96
|
+
|
|
97
|
+
// Unmount current component and layouts
|
|
98
|
+
function unmountCurrent() {
|
|
99
|
+
if (currentComponent) {
|
|
100
|
+
if (currentComponent.unmounted) currentComponent.unmounted();
|
|
101
|
+
if (currentComponent._root?.parentNode) {
|
|
102
|
+
currentComponent._root.parentNode.removeChild(currentComponent._root);
|
|
103
|
+
}
|
|
104
|
+
currentComponent = null;
|
|
105
|
+
}
|
|
106
|
+
for (const layout of layoutInstances.reverse()) {
|
|
107
|
+
if (layout.unmounted) layout.unmounted();
|
|
108
|
+
if (layout._root?.parentNode) {
|
|
109
|
+
layout._root.parentNode.removeChild(layout._root);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
layoutInstances = [];
|
|
113
|
+
routeStash = null;
|
|
114
|
+
currentMountPoint = container;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Mount a component for the current route
|
|
118
|
+
async function mountRoute(routeInfo) {
|
|
119
|
+
const { route, params, layouts: layoutFiles } = routeInfo;
|
|
120
|
+
if (!route) return;
|
|
121
|
+
|
|
122
|
+
// Guard against overlapping async mounts — only the latest wins
|
|
123
|
+
const generation = ++mountGeneration;
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
// Read and compile the page component
|
|
127
|
+
const source = fs.read(route.file);
|
|
128
|
+
if (!source) {
|
|
129
|
+
if (onError) onError({ status: 404, message: `File not found: ${route.file}` });
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const js = compile(source);
|
|
134
|
+
|
|
135
|
+
// Execute the compiled JS
|
|
136
|
+
const blob = new Blob([js], { type: 'application/javascript' });
|
|
137
|
+
const url = URL.createObjectURL(blob);
|
|
138
|
+
let module;
|
|
139
|
+
try {
|
|
140
|
+
module = await import(url);
|
|
141
|
+
} finally {
|
|
142
|
+
URL.revokeObjectURL(url);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Bail if a newer navigation started while we were loading
|
|
146
|
+
if (generation !== mountGeneration) return;
|
|
147
|
+
|
|
148
|
+
// Find the component class (first class export, or default)
|
|
149
|
+
const ComponentClass = findComponentClass(module);
|
|
150
|
+
if (!ComponentClass) {
|
|
151
|
+
if (onError) onError({ status: 500, message: `No component found in ${route.file}` });
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Check if layouts changed
|
|
156
|
+
const layoutsChanged = !arraysEqual(
|
|
157
|
+
layoutFiles.map(f => f),
|
|
158
|
+
currentLayouts.map(f => f)
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
// Unmount previous
|
|
162
|
+
if (layoutsChanged) {
|
|
163
|
+
unmountCurrent();
|
|
164
|
+
} else if (currentComponent) {
|
|
165
|
+
// Same layouts, different page — just swap the page
|
|
166
|
+
if (currentComponent.unmounted) currentComponent.unmounted();
|
|
167
|
+
if (currentComponent._root?.parentNode) {
|
|
168
|
+
currentComponent._root.parentNode.removeChild(currentComponent._root);
|
|
169
|
+
}
|
|
170
|
+
currentComponent = null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Determine mount point — reuse existing layout slot when layouts haven't changed
|
|
174
|
+
let mountPoint = layoutsChanged ? container : currentMountPoint;
|
|
175
|
+
|
|
176
|
+
// Compile and mount layouts if they changed
|
|
177
|
+
if (layoutsChanged && layoutFiles.length > 0) {
|
|
178
|
+
container.innerHTML = '';
|
|
179
|
+
mountPoint = container;
|
|
180
|
+
|
|
181
|
+
for (const layoutFile of layoutFiles) {
|
|
182
|
+
const layoutSource = fs.read(layoutFile);
|
|
183
|
+
if (!layoutSource) continue;
|
|
184
|
+
|
|
185
|
+
const layoutJs = compile(layoutSource);
|
|
186
|
+
const layoutBlob = new Blob([layoutJs], { type: 'application/javascript' });
|
|
187
|
+
const layoutUrl = URL.createObjectURL(layoutBlob);
|
|
188
|
+
let layoutModule;
|
|
189
|
+
try {
|
|
190
|
+
layoutModule = await import(layoutUrl);
|
|
191
|
+
} finally {
|
|
192
|
+
URL.revokeObjectURL(layoutUrl);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Bail if a newer navigation started while we were loading
|
|
196
|
+
if (generation !== mountGeneration) return;
|
|
197
|
+
|
|
198
|
+
const LayoutClass = findComponentClass(layoutModule);
|
|
199
|
+
if (!LayoutClass) continue;
|
|
200
|
+
|
|
201
|
+
const layoutInstance = new LayoutClass({
|
|
202
|
+
app: appStash,
|
|
203
|
+
params,
|
|
204
|
+
router
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
const wrapper = document.createElement('div');
|
|
208
|
+
wrapper.setAttribute('data-layout', layoutFile);
|
|
209
|
+
mountPoint.appendChild(wrapper);
|
|
210
|
+
layoutInstance.mount(wrapper);
|
|
211
|
+
layoutInstances.push(layoutInstance);
|
|
212
|
+
|
|
213
|
+
// Find the slot within this layout for nested content
|
|
214
|
+
const slot = wrapper.querySelector('[data-slot]')
|
|
215
|
+
|| wrapper.querySelector('.slot')
|
|
216
|
+
|| wrapper;
|
|
217
|
+
mountPoint = slot;
|
|
218
|
+
}
|
|
219
|
+
currentLayouts = [...layoutFiles];
|
|
220
|
+
currentMountPoint = mountPoint;
|
|
221
|
+
} else if (layoutsChanged) {
|
|
222
|
+
container.innerHTML = '';
|
|
223
|
+
currentLayouts = [];
|
|
224
|
+
currentMountPoint = container;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Apply transition (if configured)
|
|
228
|
+
if (transition && mountPoint.children.length > 0) {
|
|
229
|
+
mountPoint.classList.add('route-transition-exit');
|
|
230
|
+
await wait(transition.duration || 200);
|
|
231
|
+
mountPoint.classList.remove('route-transition-exit');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Mount the page component
|
|
235
|
+
const pageWrapper = document.createElement('div');
|
|
236
|
+
pageWrapper.setAttribute('data-page', route.file);
|
|
237
|
+
if (transition) pageWrapper.classList.add('route-transition-enter');
|
|
238
|
+
|
|
239
|
+
mountPoint.appendChild(pageWrapper);
|
|
240
|
+
|
|
241
|
+
const instance = new ComponentClass({
|
|
242
|
+
app: appStash,
|
|
243
|
+
params,
|
|
244
|
+
query: routeInfo.query,
|
|
245
|
+
router
|
|
246
|
+
});
|
|
247
|
+
instance.mount(pageWrapper);
|
|
248
|
+
currentComponent = instance;
|
|
249
|
+
|
|
250
|
+
// Run load function if defined (for data fetching)
|
|
251
|
+
if (instance.load) {
|
|
252
|
+
try {
|
|
253
|
+
await instance.load(params, routeInfo.query);
|
|
254
|
+
} catch (err) {
|
|
255
|
+
if (onError) onError({ status: 500, message: err.message, error: err });
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (transition) {
|
|
260
|
+
await wait(16); // next frame
|
|
261
|
+
pageWrapper.classList.remove('route-transition-enter');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
} catch (err) {
|
|
265
|
+
console.error(`Renderer: error mounting ${route.file}:`, err);
|
|
266
|
+
if (onError) onError({ status: 500, message: err.message, error: err });
|
|
267
|
+
renderError(container, err);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Start the renderer — watch for route changes
|
|
272
|
+
function start() {
|
|
273
|
+
// React to route changes
|
|
274
|
+
disposeEffect = effect(() => {
|
|
275
|
+
const current = router.current;
|
|
276
|
+
if (current.route) {
|
|
277
|
+
mountRoute(current);
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// Initialize the router (resolve current URL)
|
|
282
|
+
router.init();
|
|
283
|
+
|
|
284
|
+
return renderer;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Stop the renderer
|
|
288
|
+
function stop() {
|
|
289
|
+
unmountCurrent();
|
|
290
|
+
if (disposeEffect) {
|
|
291
|
+
disposeEffect.stop();
|
|
292
|
+
disposeEffect = null;
|
|
293
|
+
}
|
|
294
|
+
container.innerHTML = '';
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Public API
|
|
298
|
+
const renderer = {
|
|
299
|
+
start,
|
|
300
|
+
stop,
|
|
301
|
+
|
|
302
|
+
// Re-render the current route (for hot reload)
|
|
303
|
+
remount() {
|
|
304
|
+
const current = router.current;
|
|
305
|
+
if (current.route) {
|
|
306
|
+
mountRoute(current);
|
|
307
|
+
}
|
|
308
|
+
},
|
|
309
|
+
|
|
310
|
+
// Mount a specific component class to a target
|
|
311
|
+
mount(ComponentClass, target, props = {}) {
|
|
312
|
+
const el = typeof target === 'string' ? document.querySelector(target) : target;
|
|
313
|
+
if (!el) return null;
|
|
314
|
+
const instance = new ComponentClass({ app: appStash, ...props });
|
|
315
|
+
instance.mount(el);
|
|
316
|
+
return instance;
|
|
317
|
+
},
|
|
318
|
+
|
|
319
|
+
// Compile and mount a .rip source string
|
|
320
|
+
async mountSource(source, target, props = {}) {
|
|
321
|
+
const js = compile(source);
|
|
322
|
+
const blob = new Blob([js], { type: 'application/javascript' });
|
|
323
|
+
const url = URL.createObjectURL(blob);
|
|
324
|
+
let module;
|
|
325
|
+
try { module = await import(url); }
|
|
326
|
+
finally { URL.revokeObjectURL(url); }
|
|
327
|
+
const ComponentClass = findComponentClass(module);
|
|
328
|
+
if (!ComponentClass) return null;
|
|
329
|
+
return renderer.mount(ComponentClass, target, props);
|
|
330
|
+
},
|
|
331
|
+
|
|
332
|
+
get container() { return container; },
|
|
333
|
+
get current() { return currentComponent; }
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
return renderer;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ---------------------------------------------------------------------------
|
|
340
|
+
// Helpers
|
|
341
|
+
// ---------------------------------------------------------------------------
|
|
342
|
+
|
|
343
|
+
// Find the first component class in a module
|
|
344
|
+
function findComponentClass(module) {
|
|
345
|
+
// Check default export
|
|
346
|
+
if (module.default && isComponentClass(module.default)) return module.default;
|
|
347
|
+
|
|
348
|
+
// Check named exports
|
|
349
|
+
for (const key of Object.keys(module)) {
|
|
350
|
+
if (isComponentClass(module[key])) return module[key];
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Check if module itself is a class (direct eval result)
|
|
354
|
+
if (isComponentClass(module)) return module;
|
|
355
|
+
|
|
356
|
+
return null;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Check if something looks like a Rip component class
|
|
360
|
+
function isComponentClass(obj) {
|
|
361
|
+
if (typeof obj !== 'function') return false;
|
|
362
|
+
const proto = obj.prototype;
|
|
363
|
+
return proto && (typeof proto.mount === 'function' || typeof proto._create === 'function');
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Simple array equality check
|
|
367
|
+
function arraysEqual(a, b) {
|
|
368
|
+
if (a.length !== b.length) return false;
|
|
369
|
+
for (let i = 0; i < a.length; i++) {
|
|
370
|
+
if (a[i] !== b[i]) return false;
|
|
371
|
+
}
|
|
372
|
+
return true;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Promise-based wait
|
|
376
|
+
function wait(ms) {
|
|
377
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Render an error message to the container
|
|
381
|
+
function renderError(container, error) {
|
|
382
|
+
const el = document.createElement('div');
|
|
383
|
+
el.style.cssText = 'padding:2rem;font-family:monospace;background:#1a1a2e;color:#e94560;border-radius:8px;margin:1rem;';
|
|
384
|
+
el.innerHTML = `
|
|
385
|
+
<h2 style="margin:0 0 1rem;color:#e94560;">Rip Error</h2>
|
|
386
|
+
<pre style="white-space:pre-wrap;color:#eee;background:#16213e;padding:1rem;border-radius:4px;overflow:auto;">${escapeHtml(error.message || String(error))}</pre>
|
|
387
|
+
${error.stack ? `<details style="margin-top:1rem"><summary style="cursor:pointer;color:#0f3460">Stack trace</summary><pre style="white-space:pre-wrap;color:#888;font-size:0.85em;margin-top:0.5rem">${escapeHtml(error.stack)}</pre></details>` : ''}
|
|
388
|
+
`;
|
|
389
|
+
container.innerHTML = '';
|
|
390
|
+
container.appendChild(el);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function escapeHtml(str) {
|
|
394
|
+
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export default createRenderer;
|