@rip-lang/ui 0.1.2 → 0.3.0
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 +127 -796
- package/package.json +6 -17
- package/serve.rip +70 -70
- package/ui.rip +935 -0
- package/renderer.js +0 -397
- package/router.js +0 -325
- package/stash.js +0 -413
- package/ui.js +0 -208
- package/vfs.js +0 -215
package/router.js
DELETED
|
@@ -1,325 +0,0 @@
|
|
|
1
|
-
// =============================================================================
|
|
2
|
-
// File-Based Router — Client-side routing from VFS paths
|
|
3
|
-
//
|
|
4
|
-
// Maps URLs to VFS file paths with support for:
|
|
5
|
-
// - File-based routing: pages/index.rip → /
|
|
6
|
-
// pages/about.rip → /about
|
|
7
|
-
// pages/users/[id].rip → /users/:id
|
|
8
|
-
// - Dynamic segments: [param] → :param
|
|
9
|
-
// - Catch-all routes: [...slug].rip → wildcard
|
|
10
|
-
// - Nested layouts: _layout.rip in each directory
|
|
11
|
-
// - Index routes: index.rip in any directory
|
|
12
|
-
//
|
|
13
|
-
// Usage:
|
|
14
|
-
// const router = createRouter(fs, { root: 'pages', compile })
|
|
15
|
-
// router.push('/users/42') // navigate
|
|
16
|
-
// router.replace('/login') // replace history
|
|
17
|
-
// router.current // reactive: { path, params, route }
|
|
18
|
-
// router.onNavigate(callback) // hook
|
|
19
|
-
//
|
|
20
|
-
// Author: Steve Shreeve <steve.shreeve@gmail.com>
|
|
21
|
-
// Date: February 2026
|
|
22
|
-
// =============================================================================
|
|
23
|
-
|
|
24
|
-
import { signal, effect, batch } from './stash.js';
|
|
25
|
-
|
|
26
|
-
// ---------------------------------------------------------------------------
|
|
27
|
-
// Route tree — file paths → route patterns
|
|
28
|
-
// ---------------------------------------------------------------------------
|
|
29
|
-
|
|
30
|
-
function buildRouteTree(fs, root = 'pages') {
|
|
31
|
-
const routes = [];
|
|
32
|
-
const layouts = new Map(); // dir → layout path
|
|
33
|
-
|
|
34
|
-
// Scan the VFS for .rip files under root
|
|
35
|
-
const allFiles = fs.listAll(root);
|
|
36
|
-
for (const filePath of allFiles) {
|
|
37
|
-
const rel = filePath.slice(root.length + 1); // strip root prefix + /
|
|
38
|
-
if (!rel.endsWith('.rip')) continue;
|
|
39
|
-
|
|
40
|
-
// Collect layouts
|
|
41
|
-
if (rel === '_layout.rip' || rel.endsWith('/_layout.rip')) {
|
|
42
|
-
const dir = rel === '_layout.rip' ? '' : rel.slice(0, -'/_layout.rip'.length);
|
|
43
|
-
layouts.set(dir, filePath);
|
|
44
|
-
continue;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
// Skip files starting with _ (private)
|
|
48
|
-
const name = rel.split('/').pop();
|
|
49
|
-
if (name.startsWith('_')) continue;
|
|
50
|
-
|
|
51
|
-
// Convert file path to URL pattern
|
|
52
|
-
const urlPattern = fileToPattern(rel);
|
|
53
|
-
const regex = patternToRegex(urlPattern);
|
|
54
|
-
|
|
55
|
-
routes.push({ pattern: urlPattern, regex, file: filePath, rel });
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// Sort: static routes first, then by specificity (fewer dynamic segments first)
|
|
59
|
-
routes.sort((a, b) => {
|
|
60
|
-
const aDynamic = (a.pattern.match(/:/g) || []).length;
|
|
61
|
-
const bDynamic = (b.pattern.match(/:/g) || []).length;
|
|
62
|
-
const aCatch = a.pattern.includes('*') ? 1 : 0;
|
|
63
|
-
const bCatch = b.pattern.includes('*') ? 1 : 0;
|
|
64
|
-
if (aCatch !== bCatch) return aCatch - bCatch; // catch-all last
|
|
65
|
-
if (aDynamic !== bDynamic) return aDynamic - bDynamic; // fewer dynamic first
|
|
66
|
-
return a.pattern.localeCompare(b.pattern); // alphabetical tiebreak
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
return { routes, layouts };
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// Convert file path to URL pattern
|
|
73
|
-
// index.rip → /
|
|
74
|
-
// about.rip → /about
|
|
75
|
-
// users/index.rip → /users
|
|
76
|
-
// users/[id].rip → /users/:id
|
|
77
|
-
// blog/[...slug].rip → /blog/*slug
|
|
78
|
-
function fileToPattern(rel) {
|
|
79
|
-
// Remove .rip extension
|
|
80
|
-
let pattern = rel.replace(/\.rip$/, '');
|
|
81
|
-
|
|
82
|
-
// Replace [param] segments
|
|
83
|
-
pattern = pattern.replace(/\[\.\.\.(\w+)\]/g, '*$1'); // catch-all
|
|
84
|
-
pattern = pattern.replace(/\[(\w+)\]/g, ':$1'); // dynamic
|
|
85
|
-
|
|
86
|
-
// Handle index routes
|
|
87
|
-
if (pattern === 'index') return '/';
|
|
88
|
-
pattern = pattern.replace(/\/index$/, '');
|
|
89
|
-
|
|
90
|
-
return '/' + pattern;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// Convert URL pattern to regex
|
|
94
|
-
function patternToRegex(pattern) {
|
|
95
|
-
const paramNames = [];
|
|
96
|
-
let regexStr = pattern
|
|
97
|
-
.replace(/\*(\w+)/g, (_, name) => { paramNames.push(name); return '(.+)'; })
|
|
98
|
-
.replace(/:(\w+)/g, (_, name) => { paramNames.push(name); return '([^/]+)'; });
|
|
99
|
-
return { regex: new RegExp('^' + regexStr + '$'), paramNames };
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// Match a URL path against routes
|
|
103
|
-
function matchRoute(url, routes) {
|
|
104
|
-
// Strip query string and hash
|
|
105
|
-
const path = url.split('?')[0].split('#')[0];
|
|
106
|
-
|
|
107
|
-
for (const route of routes) {
|
|
108
|
-
const { regex, paramNames } = route.regex;
|
|
109
|
-
const match = path.match(regex);
|
|
110
|
-
if (match) {
|
|
111
|
-
const params = {};
|
|
112
|
-
for (let i = 0; i < paramNames.length; i++) {
|
|
113
|
-
params[paramNames[i]] = decodeURIComponent(match[i + 1]);
|
|
114
|
-
}
|
|
115
|
-
return { route, params };
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
return null;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// Collect layout chain for a route
|
|
122
|
-
function getLayoutChain(routeFile, root, layouts) {
|
|
123
|
-
const chain = [];
|
|
124
|
-
const rel = routeFile.slice(root.length + 1);
|
|
125
|
-
const parts = rel.split('/');
|
|
126
|
-
let dir = '';
|
|
127
|
-
|
|
128
|
-
// Check root layout
|
|
129
|
-
if (layouts.has('')) chain.push(layouts.get(''));
|
|
130
|
-
|
|
131
|
-
// Check each directory level
|
|
132
|
-
for (let i = 0; i < parts.length - 1; i++) {
|
|
133
|
-
dir = dir ? dir + '/' + parts[i] : parts[i];
|
|
134
|
-
if (layouts.has(dir)) chain.push(layouts.get(dir));
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
return chain;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// ---------------------------------------------------------------------------
|
|
141
|
-
// Router — reactive navigation
|
|
142
|
-
// ---------------------------------------------------------------------------
|
|
143
|
-
|
|
144
|
-
export function createRouter(fs, options = {}) {
|
|
145
|
-
const { root = 'pages', compile, onError } = options;
|
|
146
|
-
|
|
147
|
-
// Reactive state
|
|
148
|
-
const _path = signal(location.pathname);
|
|
149
|
-
const _params = signal({});
|
|
150
|
-
const _route = signal(null);
|
|
151
|
-
const _layouts = signal([]);
|
|
152
|
-
const _query = signal({});
|
|
153
|
-
const _hash = signal('');
|
|
154
|
-
|
|
155
|
-
// Navigation callbacks
|
|
156
|
-
const navigateCallbacks = new Set();
|
|
157
|
-
|
|
158
|
-
// Route tree (rebuilt when VFS changes)
|
|
159
|
-
let tree = buildRouteTree(fs, root);
|
|
160
|
-
|
|
161
|
-
// Watch VFS for changes to pages
|
|
162
|
-
fs.watch(root, () => {
|
|
163
|
-
tree = buildRouteTree(fs, root);
|
|
164
|
-
// Re-resolve current route
|
|
165
|
-
resolve(_path.get());
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
// Resolve a URL to a route
|
|
169
|
-
function resolve(url) {
|
|
170
|
-
const path = url.split('?')[0].split('#')[0];
|
|
171
|
-
const queryStr = url.split('?')[1]?.split('#')[0] || '';
|
|
172
|
-
const hash = url.includes('#') ? url.split('#')[1] : '';
|
|
173
|
-
|
|
174
|
-
const result = matchRoute(path, tree.routes);
|
|
175
|
-
if (result) {
|
|
176
|
-
batch(() => {
|
|
177
|
-
_path.set(path);
|
|
178
|
-
_params.set(result.params);
|
|
179
|
-
_route.set(result.route);
|
|
180
|
-
_layouts.set(getLayoutChain(result.route.file, root, tree.layouts));
|
|
181
|
-
_query.set(Object.fromEntries(new URLSearchParams(queryStr)));
|
|
182
|
-
_hash.set(hash);
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
for (const cb of navigateCallbacks) cb(router.current);
|
|
186
|
-
return true;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
// No match — 404
|
|
190
|
-
if (onError) onError({ status: 404, path });
|
|
191
|
-
else console.warn(`Router: no route for '${path}'`);
|
|
192
|
-
return false;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
// Handle browser back/forward
|
|
196
|
-
function onPopState() {
|
|
197
|
-
resolve(location.pathname + location.search + location.hash);
|
|
198
|
-
}
|
|
199
|
-
if (typeof window !== 'undefined') {
|
|
200
|
-
window.addEventListener('popstate', onPopState);
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
// Intercept link clicks for SPA navigation
|
|
204
|
-
function onClick(e) {
|
|
205
|
-
// Only handle left-clicks without modifier keys
|
|
206
|
-
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
|
207
|
-
|
|
208
|
-
// Find the nearest anchor
|
|
209
|
-
let target = e.target;
|
|
210
|
-
while (target && target.tagName !== 'A') target = target.parentElement;
|
|
211
|
-
if (!target || !target.href) return;
|
|
212
|
-
|
|
213
|
-
// Only handle same-origin links
|
|
214
|
-
const url = new URL(target.href, location.origin);
|
|
215
|
-
if (url.origin !== location.origin) return;
|
|
216
|
-
|
|
217
|
-
// Skip links with explicit external targets
|
|
218
|
-
if (target.target === '_blank' || target.hasAttribute('data-external')) return;
|
|
219
|
-
|
|
220
|
-
e.preventDefault();
|
|
221
|
-
router.push(url.pathname + url.search + url.hash);
|
|
222
|
-
}
|
|
223
|
-
if (typeof document !== 'undefined') {
|
|
224
|
-
document.addEventListener('click', onClick);
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
// Public API
|
|
228
|
-
const router = {
|
|
229
|
-
// Navigate to a new URL
|
|
230
|
-
push(url) {
|
|
231
|
-
if (resolve(url)) {
|
|
232
|
-
history.pushState(null, '', url);
|
|
233
|
-
}
|
|
234
|
-
},
|
|
235
|
-
|
|
236
|
-
// Replace current URL (no history entry)
|
|
237
|
-
replace(url) {
|
|
238
|
-
if (resolve(url)) {
|
|
239
|
-
history.replaceState(null, '', url);
|
|
240
|
-
}
|
|
241
|
-
},
|
|
242
|
-
|
|
243
|
-
// Go back
|
|
244
|
-
back() { history.back(); },
|
|
245
|
-
|
|
246
|
-
// Go forward
|
|
247
|
-
forward() { history.forward(); },
|
|
248
|
-
|
|
249
|
-
// Current route state (reactive reads)
|
|
250
|
-
get current() {
|
|
251
|
-
return {
|
|
252
|
-
path: _path.get(),
|
|
253
|
-
params: _params.get(),
|
|
254
|
-
route: _route.get(),
|
|
255
|
-
layouts: _layouts.get(),
|
|
256
|
-
query: _query.get(),
|
|
257
|
-
hash: _hash.get()
|
|
258
|
-
};
|
|
259
|
-
},
|
|
260
|
-
|
|
261
|
-
// Individual reactive getters
|
|
262
|
-
get path() { return _path.get(); },
|
|
263
|
-
get params() { return _params.get(); },
|
|
264
|
-
get route() { return _route.get(); },
|
|
265
|
-
get layouts() { return _layouts.get(); },
|
|
266
|
-
get query() { return _query.get(); },
|
|
267
|
-
get hash() { return _hash.get(); },
|
|
268
|
-
|
|
269
|
-
// Subscribe to navigation events
|
|
270
|
-
onNavigate(callback) {
|
|
271
|
-
navigateCallbacks.add(callback);
|
|
272
|
-
return () => navigateCallbacks.delete(callback);
|
|
273
|
-
},
|
|
274
|
-
|
|
275
|
-
// Compile and execute a route's component
|
|
276
|
-
async load(route) {
|
|
277
|
-
if (!route) return null;
|
|
278
|
-
const source = fs.read(route.file);
|
|
279
|
-
if (!source) return null;
|
|
280
|
-
|
|
281
|
-
// Check compile cache
|
|
282
|
-
let cached = fs.getCompiled(route.file);
|
|
283
|
-
if (cached && cached.source === source) return cached.code;
|
|
284
|
-
|
|
285
|
-
// Compile .rip → JS
|
|
286
|
-
if (compile) {
|
|
287
|
-
const code = compile(source);
|
|
288
|
-
fs.setCompiled(route.file, { source, code });
|
|
289
|
-
return code;
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
return source; // fallback: return raw source
|
|
293
|
-
},
|
|
294
|
-
|
|
295
|
-
// Rebuild route tree manually
|
|
296
|
-
rebuild() { tree = buildRouteTree(fs, root); },
|
|
297
|
-
|
|
298
|
-
// Get all defined routes
|
|
299
|
-
get routes() { return tree.routes; },
|
|
300
|
-
|
|
301
|
-
// Clean up event listeners
|
|
302
|
-
destroy() {
|
|
303
|
-
if (typeof window !== 'undefined') {
|
|
304
|
-
window.removeEventListener('popstate', onPopState);
|
|
305
|
-
}
|
|
306
|
-
if (typeof document !== 'undefined') {
|
|
307
|
-
document.removeEventListener('click', onClick);
|
|
308
|
-
}
|
|
309
|
-
navigateCallbacks.clear();
|
|
310
|
-
},
|
|
311
|
-
|
|
312
|
-
// Initialize — resolve current URL
|
|
313
|
-
init() {
|
|
314
|
-
resolve(location.pathname + location.search + location.hash);
|
|
315
|
-
return router;
|
|
316
|
-
}
|
|
317
|
-
};
|
|
318
|
-
|
|
319
|
-
return router;
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
// Export utilities
|
|
323
|
-
export { buildRouteTree, fileToPattern, patternToRegex, matchRoute };
|
|
324
|
-
|
|
325
|
-
export default createRouter;
|