@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/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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
395
+ }
396
+
397
+ export default createRenderer;