@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/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!()
|