@revealui/router 0.2.1 → 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,10 +81,31 @@ 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
102
  import { logger } from "@revealui/core/observability/logger";
103
+ import { createElement } from "react";
104
+ var MAX_PATTERN_LENGTH = 2048;
83
105
  function compilePathPattern(pattern) {
106
+ if (pattern.length > MAX_PATTERN_LENGTH) {
107
+ throw new Error(`Route pattern exceeds ${MAX_PATTERN_LENGTH} characters`);
108
+ }
84
109
  const keys = [];
85
110
  let src = "^";
86
111
  let i = 0;
@@ -112,8 +137,17 @@ function compilePathPattern(pattern) {
112
137
  src += "$";
113
138
  return { regex: new RegExp(src), keys };
114
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
+ }
115
149
  function pathMatch(pattern, options = {}) {
116
- const { regex, keys } = compilePathPattern(pattern);
150
+ const { regex, keys } = getCompiledPattern(pattern);
117
151
  const decode = options.decode ?? ((s) => s);
118
152
  return (path) => {
119
153
  const m = regex.exec(path);
@@ -130,9 +164,14 @@ function pathMatch(pattern, options = {}) {
130
164
  }
131
165
  var Router = class {
132
166
  routes = [];
167
+ flatRoutes = [];
168
+ globalMiddleware = [];
133
169
  options;
134
170
  listeners = /* @__PURE__ */ new Set();
135
171
  currentMatch = null;
172
+ lastPathname = null;
173
+ popstateHandler = null;
174
+ clickHandler = null;
136
175
  constructor(options = {}) {
137
176
  this.options = {
138
177
  basePath: "",
@@ -140,10 +179,24 @@ var Router = class {
140
179
  };
141
180
  }
142
181
  /**
143
- * 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.
144
196
  */
145
197
  register(route) {
146
198
  this.routes.push(route);
199
+ this.flattenRoute(route, "", [], void 0);
147
200
  }
148
201
  /**
149
202
  * Register multiple routes
@@ -154,11 +207,37 @@ var Router = class {
154
207
  });
155
208
  }
156
209
  /**
157
- * 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.
158
236
  */
159
237
  match(url) {
160
238
  const path = this.normalizePath(url);
161
- for (const route of this.routes) {
239
+ const allRoutes = this.flatRoutes.length > 0 ? this.flatRoutes : this.routes;
240
+ for (const route of allRoutes) {
162
241
  const matcher = pathMatch(route.path, { decode: decodeURIComponent });
163
242
  const result = matcher(path);
164
243
  if (result) {
@@ -171,13 +250,35 @@ var Router = class {
171
250
  return null;
172
251
  }
173
252
  /**
174
- * 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.
175
258
  */
176
259
  async resolve(url) {
177
260
  const matched = this.match(url);
178
261
  if (!matched) {
179
262
  return null;
180
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
+ }
181
282
  if (matched.route.loader) {
182
283
  try {
183
284
  matched.data = await matched.route.loader(matched.params);
@@ -189,9 +290,7 @@ var Router = class {
189
290
  throw error;
190
291
  }
191
292
  }
192
- if (typeof window === "undefined") {
193
- this.currentMatch = matched;
194
- }
293
+ this.currentMatch = matched;
195
294
  return matched;
196
295
  }
197
296
  /**
@@ -207,6 +306,8 @@ var Router = class {
207
306
  } else {
208
307
  window.history.pushState(options.state || null, "", fullUrl);
209
308
  }
309
+ this.lastPathname = window.location.pathname;
310
+ this.currentMatch = this.match(window.location.pathname);
210
311
  this.notifyListeners();
211
312
  }
212
313
  /**
@@ -241,7 +342,12 @@ var Router = class {
241
342
  if (typeof window === "undefined") {
242
343
  return this.currentMatch;
243
344
  }
244
- 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;
245
351
  }
246
352
  /**
247
353
  * Get all registered routes
@@ -250,10 +356,12 @@ var Router = class {
250
356
  return [...this.routes];
251
357
  }
252
358
  /**
253
- * Clear all routes
359
+ * Clear all routes and middleware
254
360
  */
255
361
  clear() {
256
362
  this.routes = [];
363
+ this.flatRoutes = [];
364
+ this.globalMiddleware = [];
257
365
  }
258
366
  normalizePath(url) {
259
367
  let path = url;
@@ -272,16 +380,23 @@ var Router = class {
272
380
  });
273
381
  }
274
382
  /**
275
- * Initialize client-side routing
383
+ * Initialize client-side routing.
384
+ * Uses a global flag to prevent duplicate event listeners on HMR re-invocation.
276
385
  */
277
386
  initClient() {
278
387
  if (typeof window === "undefined") {
279
388
  return;
280
389
  }
281
- 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);
282
396
  this.notifyListeners();
283
- });
284
- document.addEventListener("click", (e) => {
397
+ };
398
+ window.addEventListener("popstate", this.popstateHandler);
399
+ this.clickHandler = (e) => {
285
400
  const target = e.target.closest("a");
286
401
  if (!target) return;
287
402
  const href = target.getAttribute("href");
@@ -289,9 +404,37 @@ var Router = class {
289
404
  e.preventDefault();
290
405
  this.navigate(href);
291
406
  }
292
- });
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;
293
427
  }
294
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
+ }
295
438
 
296
439
  // src/server.tsx
297
440
  import { jsx as jsx2 } from "react/jsx-runtime";
@@ -329,24 +472,19 @@ function createSSRHandler(routes, options = {}) {
329
472
  return c.html(template("<div>404 - Page Not Found</div>"));
330
473
  }
331
474
  if (options.streaming) {
332
- return new Promise((resolve, reject) => {
333
- const { pipe } = renderToPipeableStream(
334
- /* @__PURE__ */ jsx2(RouterProvider, { router, children: /* @__PURE__ */ jsx2(Routes, {}) }),
335
- {
336
- onShellReady() {
337
- c.header("Content-Type", "text/html");
338
- const html2 = pipe;
339
- resolve(c.body(html2));
340
- },
341
- onError(error) {
342
- logger2.error("SSR error", error instanceof Error ? error : new Error(String(error)));
343
- if (options.onError) {
344
- options.onError(error, c);
345
- }
346
- 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);
347
482
  }
348
483
  }
349
- );
484
+ }
485
+ );
486
+ return new Response(stream, {
487
+ headers: { "Content-Type": "text/html; charset=utf-8" }
350
488
  });
351
489
  }
352
490
  const html = renderToString(
@@ -389,9 +527,15 @@ async function hydrate(router, rootElement = null) {
389
527
  return;
390
528
  }
391
529
  const dataScript = document.getElementById("__REVEALUI_DATA__");
392
- 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
+ }
393
537
  router.initClient();
394
- const { hydrateRoot } = __require("react-dom/client");
538
+ const { hydrateRoot } = await import("react-dom/client");
395
539
  hydrateRoot(
396
540
  root,
397
541
  /* @__PURE__ */ jsx2(RouterProvider, { router, children: /* @__PURE__ */ jsx2(Routes, {}) })