@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/router.js ADDED
@@ -0,0 +1,325 @@
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;
package/serve.rip ADDED
@@ -0,0 +1,143 @@
1
+ # ==============================================================================
2
+ # @rip-lang/ui/serve — Rip UI Middleware for rip-api
3
+ # ==============================================================================
4
+ #
5
+ # Serves the Rip UI framework files, auto-generated page manifests, and
6
+ # provides an SSE hot-reload channel for live development.
7
+ #
8
+ # Usage:
9
+ # import { ripUI } from '@rip-lang/ui/serve'
10
+ #
11
+ # use ripUI pages: 'pages', watch: true
12
+ #
13
+ # Options:
14
+ # base: string — URL prefix for framework files (default: '/rip-ui')
15
+ # pages: string — directory containing .rip page files (default: 'pages')
16
+ # watch: boolean — enable SSE hot-reload endpoint (default: false)
17
+ # debounce: number — ms to batch filesystem events (default: 250)
18
+ #
19
+ # Registered routes:
20
+ # GET {base}/* — framework JS files, manifest, SSE watch
21
+ # GET /pages/* — individual .rip page files (for hot-reload refetch)
22
+ #
23
+ # ==============================================================================
24
+
25
+ import { get } from '@rip-lang/api'
26
+ import { brotliCompressSync } from 'node:zlib'
27
+ import { watch as fsWatch } from 'node:fs'
28
+
29
+ export ripUI = (opts = {}) ->
30
+ base = opts.base or '/rip-ui'
31
+ pagesDir = opts.pages or 'pages'
32
+ enableWatch = opts.watch or false
33
+ debounceMs = opts.debounce or 250
34
+ uiDir = import.meta.dir
35
+
36
+ # Resolve compiler (rip.browser.js) from the rip-lang package
37
+ compilerPath = null
38
+ try
39
+ compilerPath = Bun.fileURLToPath(import.meta.resolve('rip-lang/docs/dist/rip.browser.js'))
40
+ catch
41
+ compilerPath = "#{uiDir}/../../docs/dist/rip.browser.js"
42
+
43
+ # Framework file map: logical name → filesystem path
44
+ files =
45
+ 'ui.js': "#{uiDir}/ui.js"
46
+ 'stash.js': "#{uiDir}/stash.js"
47
+ 'vfs.js': "#{uiDir}/vfs.js"
48
+ 'router.js': "#{uiDir}/router.js"
49
+ 'renderer.js': "#{uiDir}/renderer.js"
50
+ 'compiler.js': compilerPath
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # Route: /pages/* — individual .rip page files (for hot-reload refetch)
54
+ # ---------------------------------------------------------------------------
55
+
56
+ get '/pages/*', (c) ->
57
+ name = c.req.path.slice('/pages/'.length)
58
+ c.send "#{pagesDir}/#{name}", 'text/plain; charset=UTF-8'
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # Route: {base}/* — framework files, manifest, SSE watch
62
+ # ---------------------------------------------------------------------------
63
+
64
+ get "#{base}/*", (c) ->
65
+ name = c.req.path.slice(base.length + 1)
66
+
67
+ # Framework JS files
68
+ if files[name]
69
+ return c.send files[name], 'application/javascript'
70
+
71
+ # Auto-generated manifest — bundles all .rip sources as JSON (brotli compressed)
72
+ if name is 'manifest.json'
73
+ glob = new Bun.Glob("**/*.rip")
74
+ bundle = {}
75
+ paths = Array.from(glob.scanSync(pagesDir))
76
+ for path in paths
77
+ bundle["pages/#{path}"] = Bun.file("#{pagesDir}/#{path}").text!
78
+ json = JSON.stringify(bundle)
79
+ compressed = brotliCompressSync(Buffer.from(json))
80
+ return new Response compressed,
81
+ headers:
82
+ 'Content-Type': 'application/json'
83
+ 'Content-Encoding': 'br'
84
+
85
+ # SSE watch endpoint — debounced, notify-only, with heartbeat
86
+ if name is 'watch' and enableWatch
87
+ encoder = new TextEncoder()
88
+ pending = new Set()
89
+ timer = null
90
+ watcher = null
91
+ heartbeat = null
92
+
93
+ cleanup = ->
94
+ watcher?.close()
95
+ clearTimeout(timer) if timer
96
+ clearInterval(heartbeat) if heartbeat
97
+ watcher = heartbeat = timer = null
98
+
99
+ return new Response new ReadableStream(
100
+ start: (controller) ->
101
+ send = (event, data) ->
102
+ try
103
+ controller.enqueue encoder.encode("event: #{event}\ndata: #{JSON.stringify(data)}\n\n")
104
+ catch
105
+ cleanup()
106
+
107
+ # Send initial connection confirmation
108
+ send 'connected', { time: Date.now() }
109
+
110
+ # Heartbeat every 5s to prevent Bun's idle timeout from closing the SSE
111
+ heartbeat = setInterval ->
112
+ try
113
+ controller.enqueue encoder.encode(": heartbeat\n\n")
114
+ catch
115
+ cleanup()
116
+ , 5000
117
+
118
+ # Flush pending changes as one batched notification
119
+ flush = ->
120
+ paths = Array.from(pending)
121
+ pending.clear()
122
+ timer = null
123
+ send('changed', { paths }) if paths.length > 0
124
+
125
+ # Watch the pages directory for .rip file changes
126
+ watcher = fsWatch pagesDir, { recursive: true }, (event, filename) ->
127
+ return unless filename?.endsWith('.rip')
128
+ pending.add "pages/#{filename}"
129
+ clearTimeout(timer) if timer
130
+ timer = setTimeout(flush, debounceMs)
131
+
132
+ cancel: -> cleanup()
133
+ ),
134
+ headers:
135
+ 'Content-Type': 'text/event-stream'
136
+ 'Cache-Control': 'no-cache'
137
+ 'Connection': 'keep-alive'
138
+
139
+ # Unknown path under /rip-ui/
140
+ new Response 'Not Found', status: 404
141
+
142
+ # Return pass-through middleware for use()
143
+ (c, next) -> next!()