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