@revealui/router 0.2.0 → 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.
@@ -0,0 +1,187 @@
1
+ import { ComponentType, ReactNode } from 'react';
2
+
3
+ /**
4
+ * Middleware function — runs before route resolution.
5
+ * Return `true` to continue, `false` to abort, or a redirect path string.
6
+ */
7
+ type RouteMiddleware = (context: MiddlewareContext) => boolean | string | Promise<boolean | string>;
8
+ /**
9
+ * Context passed to middleware functions
10
+ */
11
+ interface MiddlewareContext {
12
+ /** Current URL pathname */
13
+ pathname: string;
14
+ /** Matched route params (if available at this stage) */
15
+ params: RouteParams;
16
+ /** Route metadata */
17
+ meta?: RouteMeta;
18
+ }
19
+ /**
20
+ * Route configuration
21
+ */
22
+ interface Route<TData = unknown, TProps = Record<string, unknown>> {
23
+ /** Route path pattern (e.g., '/', '/about', '/posts/:id') */
24
+ path: string;
25
+ /** Component to render for this route */
26
+ component: ComponentType<TProps>;
27
+ /** Optional layout component */
28
+ layout?: ComponentType<{
29
+ children: ReactNode;
30
+ }>;
31
+ /** Optional data loader function */
32
+ loader?: (params: RouteParams) => Promise<TData> | TData;
33
+ /** Optional metadata */
34
+ meta?: RouteMeta;
35
+ /** Optional middleware that runs before this route's loader */
36
+ middleware?: RouteMiddleware[];
37
+ /** Nested child routes — children inherit parent's layout and middleware */
38
+ children?: Route[];
39
+ }
40
+ /**
41
+ * Route parameters extracted from URL
42
+ */
43
+ interface RouteParams {
44
+ [key: string]: string;
45
+ }
46
+ /**
47
+ * Route metadata (for SEO, etc.)
48
+ */
49
+ interface RouteMeta {
50
+ title?: string;
51
+ description?: string;
52
+ [key: string]: unknown;
53
+ }
54
+ /**
55
+ * Matched route result
56
+ */
57
+ interface RouteMatch<TData = unknown> {
58
+ route: Route<TData>;
59
+ params: RouteParams;
60
+ data?: TData;
61
+ }
62
+ /**
63
+ * Router configuration options
64
+ */
65
+ interface RouterOptions {
66
+ /** Base URL path */
67
+ basePath?: string;
68
+ /** 404 component */
69
+ notFound?: ComponentType;
70
+ /** Error boundary component */
71
+ errorBoundary?: ComponentType<{
72
+ error: Error;
73
+ }>;
74
+ }
75
+ /**
76
+ * Navigation options
77
+ */
78
+ interface NavigateOptions<TState = unknown> {
79
+ /** Replace current history entry instead of pushing */
80
+ replace?: boolean;
81
+ /** State to pass with navigation */
82
+ state?: TState;
83
+ }
84
+ /**
85
+ * Current location state
86
+ */
87
+ interface Location {
88
+ /** URL pathname (e.g., '/about') */
89
+ pathname: string;
90
+ /** Query string including leading '?' (e.g., '?q=test') or empty string */
91
+ search: string;
92
+ /** Hash fragment including leading '#' (e.g., '#section') or empty string */
93
+ hash: string;
94
+ }
95
+
96
+ /**
97
+ * RevealUI Router - Lightweight file-based routing with SSR support
98
+ */
99
+ declare class Router {
100
+ private routes;
101
+ private flatRoutes;
102
+ private globalMiddleware;
103
+ private options;
104
+ private listeners;
105
+ private currentMatch;
106
+ private lastPathname;
107
+ private popstateHandler;
108
+ private clickHandler;
109
+ constructor(options?: RouterOptions);
110
+ /**
111
+ * Add global middleware that runs before all routes.
112
+ */
113
+ use(...middleware: RouteMiddleware[]): void;
114
+ /**
115
+ * Get router options
116
+ */
117
+ getOptions(): RouterOptions;
118
+ /**
119
+ * Register a route. Nested children are flattened with combined paths,
120
+ * middleware, and layout chains.
121
+ */
122
+ register(route: Route): void;
123
+ /**
124
+ * Register multiple routes
125
+ */
126
+ registerRoutes(routes: Route[]): void;
127
+ /**
128
+ * Flatten nested routes into the flat lookup table.
129
+ * Children inherit parent path prefix, middleware, and layout.
130
+ */
131
+ private flattenRoute;
132
+ /**
133
+ * Match a URL to a route.
134
+ * Checks flattened routes first (includes nested), then falls back to top-level.
135
+ */
136
+ match(url: string): RouteMatch | null;
137
+ /**
138
+ * Resolve a route with middleware execution and data loading.
139
+ *
140
+ * Middleware chain: global middleware → route middleware → loader.
141
+ * If any middleware returns `false`, resolution is aborted (returns null).
142
+ * If any middleware returns a string, navigation is redirected to that path.
143
+ */
144
+ resolve(url: string): Promise<RouteMatch | null>;
145
+ /**
146
+ * Navigate to a URL (client-side only)
147
+ */
148
+ navigate(url: string, options?: NavigateOptions): void;
149
+ /**
150
+ * Go back in history
151
+ */
152
+ back(): void;
153
+ /**
154
+ * Go forward in history
155
+ */
156
+ forward(): void;
157
+ /**
158
+ * Subscribe to route changes
159
+ */
160
+ subscribe(listener: () => void): () => void;
161
+ /**
162
+ * Get current route match
163
+ */
164
+ getCurrentMatch(): RouteMatch | null;
165
+ /**
166
+ * Get all registered routes
167
+ */
168
+ getRoutes(): Route[];
169
+ /**
170
+ * Clear all routes and middleware
171
+ */
172
+ clear(): void;
173
+ private normalizePath;
174
+ private notifyListeners;
175
+ /**
176
+ * Initialize client-side routing.
177
+ * Uses a global flag to prevent duplicate event listeners on HMR re-invocation.
178
+ */
179
+ initClient(): void;
180
+ /**
181
+ * Clean up client-side event listeners.
182
+ * Call this before unmounting or during HMR teardown.
183
+ */
184
+ dispose(): void;
185
+ }
186
+
187
+ export { type Location as L, type MiddlewareContext as M, type NavigateOptions as N, Router as R, type RouteMatch as a, type Route as b, type RouteMeta as c, type RouteMiddleware as d, type RouteParams as e, type RouterOptions as f };
package/dist/server.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import * as _hono_node_server from '@hono/node-server';
2
2
  import { Context } from 'hono';
3
- import { b as Route, R as Router } from './router-DctgwX83.js';
3
+ import { b as Route, R as Router } from './router-B2MrlNC3.js';
4
4
  import 'react';
5
5
 
6
6
  /**
package/dist/server.js CHANGED
@@ -1,16 +1,17 @@
1
- var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
2
- get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
3
- }) : x)(function(x) {
4
- if (typeof require !== "undefined") return require.apply(this, arguments);
5
- throw Error('Dynamic require of "' + x + '" is not supported');
6
- });
7
-
8
1
  // src/server.tsx
9
2
  import { logger as logger2 } from "@revealui/core/observability/logger";
10
- import { renderToPipeableStream, renderToString } from "react-dom/server";
3
+ import { renderToReadableStream, renderToString } from "react-dom/server";
11
4
 
12
5
  // src/components.tsx
13
- import { createContext, useContext, useEffect, useSyncExternalStore } from "react";
6
+ import {
7
+ Component,
8
+ createContext,
9
+ use,
10
+ useEffect,
11
+ useMemo,
12
+ useRef,
13
+ useSyncExternalStore
14
+ } from "react";
14
15
  import { jsx, jsxs } from "react/jsx-runtime";
15
16
  var RouterContext = createContext(null);
16
17
  var MatchContext = createContext(null);
@@ -22,6 +23,7 @@ function RouterProvider({
22
23
  }
23
24
  function Routes() {
24
25
  const router = useRouter();
26
+ const options = router.getOptions();
25
27
  const match = useSyncExternalStore(
26
28
  (callback) => router.subscribe(callback),
27
29
  () => router.getCurrentMatch(),
@@ -29,13 +31,15 @@ function Routes() {
29
31
  // Server-side snapshot (same as client)
30
32
  );
31
33
  if (!match) {
32
- return /* @__PURE__ */ jsx(NotFound, {});
34
+ const CustomNotFound = options.notFound;
35
+ return CustomNotFound ? /* @__PURE__ */ jsx(CustomNotFound, {}) : /* @__PURE__ */ jsx(NotFound, {});
33
36
  }
34
37
  const { route, params, data } = match;
35
- const Component = route.component;
38
+ const RouteComponent = route.component;
36
39
  const Layout = route.layout;
37
- const element = /* @__PURE__ */ jsx(Component, { params, data });
38
- return /* @__PURE__ */ jsx(MatchContext.Provider, { value: match, children: Layout ? /* @__PURE__ */ jsx(Layout, { children: element }) : element });
40
+ const element = /* @__PURE__ */ jsx(RouteComponent, { params, data });
41
+ const wrapped = Layout ? /* @__PURE__ */ jsx(Layout, { children: element }) : element;
42
+ return /* @__PURE__ */ jsx(MatchContext.Provider, { value: match, children: options.errorBoundary ? /* @__PURE__ */ jsx(RouteErrorBoundary, { fallback: options.errorBoundary, children: wrapped }) : wrapped });
39
43
  }
40
44
  function Link({
41
45
  to,
@@ -64,7 +68,7 @@ function Link({
64
68
  return /* @__PURE__ */ jsx("a", { href: to, onClick: handleClick, className, style, ...props, children });
65
69
  }
66
70
  function useRouter() {
67
- const router = useContext(RouterContext);
71
+ const router = use(RouterContext);
68
72
  if (!router) {
69
73
  throw new Error("useRouter must be used within a RouterProvider");
70
74
  }
@@ -77,27 +81,97 @@ function NotFound() {
77
81
  /* @__PURE__ */ jsx(Link, { to: "/", children: "Go Home" })
78
82
  ] });
79
83
  }
84
+ var RouteErrorBoundary = class extends Component {
85
+ constructor(props) {
86
+ super(props);
87
+ this.state = { error: null };
88
+ }
89
+ static getDerivedStateFromError(error) {
90
+ return { error };
91
+ }
92
+ render() {
93
+ if (this.state.error) {
94
+ const Fallback = this.props.fallback;
95
+ return /* @__PURE__ */ jsx(Fallback, { error: this.state.error });
96
+ }
97
+ return this.props.children;
98
+ }
99
+ };
80
100
 
81
101
  // src/router.ts
82
- import { match as pathMatch } from "path-to-regexp";
83
-
84
- // ../core/src/observability/logger.ts
85
- import { logger as utilsLogger } from "@revealui/utils/logger";
86
- import {
87
- createLogger,
88
- Logger,
89
- logAudit,
90
- logError,
91
- logger,
92
- logQuery
93
- } from "@revealui/utils/logger";
94
-
95
- // src/router.ts
102
+ import { logger } from "@revealui/core/observability/logger";
103
+ import { createElement } from "react";
104
+ var MAX_PATTERN_LENGTH = 2048;
105
+ function compilePathPattern(pattern) {
106
+ if (pattern.length > MAX_PATTERN_LENGTH) {
107
+ throw new Error(`Route pattern exceeds ${MAX_PATTERN_LENGTH} characters`);
108
+ }
109
+ const keys = [];
110
+ let src = "^";
111
+ let i = 0;
112
+ while (i < pattern.length) {
113
+ const ch = pattern[i];
114
+ if (ch === "{") {
115
+ src += "(?:";
116
+ i++;
117
+ } else if (ch === "}") {
118
+ src += ")?";
119
+ i++;
120
+ } else if (ch === ":") {
121
+ i++;
122
+ let name = "";
123
+ while (i < pattern.length && /\w/.test(pattern[i])) name += pattern[i++];
124
+ keys.push({ name, wildcard: false });
125
+ src += "([^/]+)";
126
+ } else if (ch === "*") {
127
+ i++;
128
+ let name = "";
129
+ while (i < pattern.length && /\w/.test(pattern[i])) name += pattern[i++];
130
+ keys.push({ name: name || "0", wildcard: true });
131
+ src += "(.+)";
132
+ } else {
133
+ src += ch.replace(/[.+?^$|()[\]\\]/g, "\\$&");
134
+ i++;
135
+ }
136
+ }
137
+ src += "$";
138
+ return { regex: new RegExp(src), keys };
139
+ }
140
+ var patternCache = /* @__PURE__ */ new Map();
141
+ function getCompiledPattern(pattern) {
142
+ let compiled = patternCache.get(pattern);
143
+ if (!compiled) {
144
+ compiled = compilePathPattern(pattern);
145
+ patternCache.set(pattern, compiled);
146
+ }
147
+ return compiled;
148
+ }
149
+ function pathMatch(pattern, options = {}) {
150
+ const { regex, keys } = getCompiledPattern(pattern);
151
+ const decode = options.decode ?? ((s) => s);
152
+ return (path) => {
153
+ const m = regex.exec(path);
154
+ if (!m) return false;
155
+ const params = {};
156
+ for (let j = 0; j < keys.length; j++) {
157
+ const key = keys[j];
158
+ const val = m[j + 1];
159
+ if (val === void 0) continue;
160
+ params[key.name] = key.wildcard ? val.split("/").map(decode) : decode(val);
161
+ }
162
+ return { params };
163
+ };
164
+ }
96
165
  var Router = class {
97
166
  routes = [];
167
+ flatRoutes = [];
168
+ globalMiddleware = [];
98
169
  options;
99
170
  listeners = /* @__PURE__ */ new Set();
100
171
  currentMatch = null;
172
+ lastPathname = null;
173
+ popstateHandler = null;
174
+ clickHandler = null;
101
175
  constructor(options = {}) {
102
176
  this.options = {
103
177
  basePath: "",
@@ -105,10 +179,24 @@ var Router = class {
105
179
  };
106
180
  }
107
181
  /**
108
- * Register a route
182
+ * Add global middleware that runs before all routes.
183
+ */
184
+ use(...middleware) {
185
+ this.globalMiddleware.push(...middleware);
186
+ }
187
+ /**
188
+ * Get router options
189
+ */
190
+ getOptions() {
191
+ return this.options;
192
+ }
193
+ /**
194
+ * Register a route. Nested children are flattened with combined paths,
195
+ * middleware, and layout chains.
109
196
  */
110
197
  register(route) {
111
198
  this.routes.push(route);
199
+ this.flattenRoute(route, "", [], void 0);
112
200
  }
113
201
  /**
114
202
  * Register multiple routes
@@ -119,11 +207,37 @@ var Router = class {
119
207
  });
120
208
  }
121
209
  /**
122
- * Match a URL to a route
210
+ * Flatten nested routes into the flat lookup table.
211
+ * Children inherit parent path prefix, middleware, and layout.
212
+ */
213
+ flattenRoute(route, parentPath, parentMiddleware, parentLayout) {
214
+ const fullPath = joinPaths(parentPath, route.path);
215
+ const combinedMiddleware = [...parentMiddleware, ...route.middleware ?? []];
216
+ const effectiveLayout = parentLayout && route.layout ? wrapLayouts(parentLayout, route.layout) : route.layout ?? parentLayout;
217
+ if (route.component) {
218
+ this.flatRoutes.push({
219
+ ...route,
220
+ path: fullPath,
221
+ middleware: combinedMiddleware.length > 0 ? combinedMiddleware : void 0,
222
+ layout: effectiveLayout,
223
+ children: void 0
224
+ // Already flattened
225
+ });
226
+ }
227
+ if (route.children) {
228
+ for (const child of route.children) {
229
+ this.flattenRoute(child, fullPath, combinedMiddleware, effectiveLayout);
230
+ }
231
+ }
232
+ }
233
+ /**
234
+ * Match a URL to a route.
235
+ * Checks flattened routes first (includes nested), then falls back to top-level.
123
236
  */
124
237
  match(url) {
125
238
  const path = this.normalizePath(url);
126
- for (const route of this.routes) {
239
+ const allRoutes = this.flatRoutes.length > 0 ? this.flatRoutes : this.routes;
240
+ for (const route of allRoutes) {
127
241
  const matcher = pathMatch(route.path, { decode: decodeURIComponent });
128
242
  const result = matcher(path);
129
243
  if (result) {
@@ -136,13 +250,35 @@ var Router = class {
136
250
  return null;
137
251
  }
138
252
  /**
139
- * Resolve a route with data loading
253
+ * Resolve a route with middleware execution and data loading.
254
+ *
255
+ * Middleware chain: global middleware → route middleware → loader.
256
+ * If any middleware returns `false`, resolution is aborted (returns null).
257
+ * If any middleware returns a string, navigation is redirected to that path.
140
258
  */
141
259
  async resolve(url) {
142
260
  const matched = this.match(url);
143
261
  if (!matched) {
144
262
  return null;
145
263
  }
264
+ const allMiddleware = [...this.globalMiddleware, ...matched.route.middleware ?? []];
265
+ if (allMiddleware.length > 0) {
266
+ const context = {
267
+ pathname: this.normalizePath(url),
268
+ params: matched.params,
269
+ meta: matched.route.meta
270
+ };
271
+ for (const mw of allMiddleware) {
272
+ const result = await mw(context);
273
+ if (result === false) {
274
+ return null;
275
+ }
276
+ if (typeof result === "string") {
277
+ this.navigate(result);
278
+ return null;
279
+ }
280
+ }
281
+ }
146
282
  if (matched.route.loader) {
147
283
  try {
148
284
  matched.data = await matched.route.loader(matched.params);
@@ -154,9 +290,7 @@ var Router = class {
154
290
  throw error;
155
291
  }
156
292
  }
157
- if (typeof window === "undefined") {
158
- this.currentMatch = matched;
159
- }
293
+ this.currentMatch = matched;
160
294
  return matched;
161
295
  }
162
296
  /**
@@ -172,6 +306,8 @@ var Router = class {
172
306
  } else {
173
307
  window.history.pushState(options.state || null, "", fullUrl);
174
308
  }
309
+ this.lastPathname = window.location.pathname;
310
+ this.currentMatch = this.match(window.location.pathname);
175
311
  this.notifyListeners();
176
312
  }
177
313
  /**
@@ -206,7 +342,12 @@ var Router = class {
206
342
  if (typeof window === "undefined") {
207
343
  return this.currentMatch;
208
344
  }
209
- return this.match(window.location.pathname);
345
+ const pathname = window.location.pathname;
346
+ if (pathname !== this.lastPathname) {
347
+ this.lastPathname = pathname;
348
+ this.currentMatch = this.match(pathname);
349
+ }
350
+ return this.currentMatch;
210
351
  }
211
352
  /**
212
353
  * Get all registered routes
@@ -215,10 +356,12 @@ var Router = class {
215
356
  return [...this.routes];
216
357
  }
217
358
  /**
218
- * Clear all routes
359
+ * Clear all routes and middleware
219
360
  */
220
361
  clear() {
221
362
  this.routes = [];
363
+ this.flatRoutes = [];
364
+ this.globalMiddleware = [];
222
365
  }
223
366
  normalizePath(url) {
224
367
  let path = url;
@@ -237,16 +380,23 @@ var Router = class {
237
380
  });
238
381
  }
239
382
  /**
240
- * Initialize client-side routing
383
+ * Initialize client-side routing.
384
+ * Uses a global flag to prevent duplicate event listeners on HMR re-invocation.
241
385
  */
242
386
  initClient() {
243
387
  if (typeof window === "undefined") {
244
388
  return;
245
389
  }
246
- window.addEventListener("popstate", () => {
390
+ const g = globalThis;
391
+ if (g.__revealui_router_initialized) return;
392
+ g.__revealui_router_initialized = true;
393
+ this.popstateHandler = () => {
394
+ this.lastPathname = window.location.pathname;
395
+ this.currentMatch = this.match(window.location.pathname);
247
396
  this.notifyListeners();
248
- });
249
- document.addEventListener("click", (e) => {
397
+ };
398
+ window.addEventListener("popstate", this.popstateHandler);
399
+ this.clickHandler = (e) => {
250
400
  const target = e.target.closest("a");
251
401
  if (!target) return;
252
402
  const href = target.getAttribute("href");
@@ -254,9 +404,37 @@ var Router = class {
254
404
  e.preventDefault();
255
405
  this.navigate(href);
256
406
  }
257
- });
407
+ };
408
+ document.addEventListener("click", this.clickHandler);
409
+ }
410
+ /**
411
+ * Clean up client-side event listeners.
412
+ * Call this before unmounting or during HMR teardown.
413
+ */
414
+ dispose() {
415
+ if (typeof window === "undefined") return;
416
+ if (this.popstateHandler) {
417
+ window.removeEventListener("popstate", this.popstateHandler);
418
+ this.popstateHandler = null;
419
+ }
420
+ if (this.clickHandler) {
421
+ document.removeEventListener("click", this.clickHandler);
422
+ this.clickHandler = null;
423
+ }
424
+ this.listeners.clear();
425
+ const g = globalThis;
426
+ g.__revealui_router_initialized = false;
258
427
  }
259
428
  };
429
+ function joinPaths(parent, child) {
430
+ if (!parent || parent === "/") return child;
431
+ if (!child || child === "/") return parent;
432
+ return `${parent.replace(/\/$/, "")}/${child.replace(/^\//, "")}`;
433
+ }
434
+ function wrapLayouts(Parent, Child) {
435
+ const WrappedLayout = ({ children }) => createElement(Parent, null, createElement(Child, null, children));
436
+ return WrappedLayout;
437
+ }
260
438
 
261
439
  // src/server.tsx
262
440
  import { jsx as jsx2 } from "react/jsx-runtime";
@@ -294,24 +472,19 @@ function createSSRHandler(routes, options = {}) {
294
472
  return c.html(template("<div>404 - Page Not Found</div>"));
295
473
  }
296
474
  if (options.streaming) {
297
- return new Promise((resolve, reject) => {
298
- const { pipe } = renderToPipeableStream(
299
- /* @__PURE__ */ jsx2(RouterProvider, { router, children: /* @__PURE__ */ jsx2(Routes, {}) }),
300
- {
301
- onShellReady() {
302
- c.header("Content-Type", "text/html");
303
- const html2 = pipe;
304
- resolve(c.body(html2));
305
- },
306
- onError(error) {
307
- logger2.error("SSR error", error instanceof Error ? error : new Error(String(error)));
308
- if (options.onError) {
309
- options.onError(error, c);
310
- }
311
- reject(error);
475
+ const stream = await renderToReadableStream(
476
+ /* @__PURE__ */ jsx2(RouterProvider, { router, children: /* @__PURE__ */ jsx2(Routes, {}) }),
477
+ {
478
+ onError(error) {
479
+ logger2.error("SSR error", error instanceof Error ? error : new Error(String(error)));
480
+ if (options.onError) {
481
+ options.onError(error, c);
312
482
  }
313
483
  }
314
- );
484
+ }
485
+ );
486
+ return new Response(stream, {
487
+ headers: { "Content-Type": "text/html; charset=utf-8" }
315
488
  });
316
489
  }
317
490
  const html = renderToString(
@@ -354,9 +527,15 @@ async function hydrate(router, rootElement = null) {
354
527
  return;
355
528
  }
356
529
  const dataScript = document.getElementById("__REVEALUI_DATA__");
357
- const _ssrData = dataScript ? JSON.parse(dataScript.textContent || "{}") : {};
530
+ const ssrData = dataScript ? JSON.parse(dataScript.textContent || "{}") : {};
531
+ if (ssrData.route) {
532
+ const match = router.match(window.location.pathname);
533
+ if (match) {
534
+ match.data = ssrData.data;
535
+ }
536
+ }
358
537
  router.initClient();
359
- const { hydrateRoot } = __require("react-dom/client");
538
+ const { hydrateRoot } = await import("react-dom/client");
360
539
  hydrateRoot(
361
540
  root,
362
541
  /* @__PURE__ */ jsx2(RouterProvider, { router, children: /* @__PURE__ */ jsx2(Routes, {}) })