@emkodev/emroute 1.6.5 → 1.6.6-beta.2

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.
Files changed (82) hide show
  1. package/dist/runtime/abstract.runtime.d.ts +6 -4
  2. package/dist/runtime/abstract.runtime.js +42 -121
  3. package/dist/runtime/abstract.runtime.js.map +1 -1
  4. package/dist/runtime/bun/esbuild-runtime-loader.plugin.d.ts +5 -1
  5. package/dist/runtime/bun/esbuild-runtime-loader.plugin.js +13 -2
  6. package/dist/runtime/bun/esbuild-runtime-loader.plugin.js.map +1 -1
  7. package/dist/runtime/bun/fs/bun-fs.runtime.js +0 -1
  8. package/dist/runtime/bun/fs/bun-fs.runtime.js.map +1 -1
  9. package/dist/runtime/bun/sqlite/bun-sqlite.runtime.js +4 -5
  10. package/dist/runtime/bun/sqlite/bun-sqlite.runtime.js.map +1 -1
  11. package/dist/runtime/universal/fs/universal-fs.runtime.js +0 -1
  12. package/dist/runtime/universal/fs/universal-fs.runtime.js.map +1 -1
  13. package/dist/server/codegen.util.d.ts +1 -1
  14. package/dist/server/codegen.util.js +6 -4
  15. package/dist/server/codegen.util.js.map +1 -1
  16. package/dist/server/emroute.server.js +54 -47
  17. package/dist/server/emroute.server.js.map +1 -1
  18. package/dist/server/esbuild-manifest.plugin.d.ts +0 -2
  19. package/dist/server/esbuild-manifest.plugin.js +67 -87
  20. package/dist/server/esbuild-manifest.plugin.js.map +1 -1
  21. package/dist/server/server-api.type.d.ts +5 -5
  22. package/dist/src/index.d.ts +5 -2
  23. package/dist/src/index.js +2 -1
  24. package/dist/src/index.js.map +1 -1
  25. package/dist/src/renderer/spa/base.renderer.js +4 -4
  26. package/dist/src/renderer/spa/base.renderer.js.map +1 -1
  27. package/dist/src/renderer/spa/hash.renderer.d.ts +3 -2
  28. package/dist/src/renderer/spa/hash.renderer.js +36 -17
  29. package/dist/src/renderer/spa/hash.renderer.js.map +1 -1
  30. package/dist/src/renderer/spa/html.renderer.d.ts +11 -3
  31. package/dist/src/renderer/spa/html.renderer.js +24 -11
  32. package/dist/src/renderer/spa/html.renderer.js.map +1 -1
  33. package/dist/src/renderer/spa/mod.d.ts +4 -1
  34. package/dist/src/renderer/spa/mod.js +1 -0
  35. package/dist/src/renderer/spa/mod.js.map +1 -1
  36. package/dist/src/renderer/ssr/html.renderer.d.ts +4 -3
  37. package/dist/src/renderer/ssr/html.renderer.js +4 -4
  38. package/dist/src/renderer/ssr/html.renderer.js.map +1 -1
  39. package/dist/src/renderer/ssr/md.renderer.d.ts +4 -3
  40. package/dist/src/renderer/ssr/md.renderer.js +4 -4
  41. package/dist/src/renderer/ssr/md.renderer.js.map +1 -1
  42. package/dist/src/renderer/ssr/ssr.renderer.d.ts +3 -2
  43. package/dist/src/renderer/ssr/ssr.renderer.js +10 -15
  44. package/dist/src/renderer/ssr/ssr.renderer.js.map +1 -1
  45. package/dist/src/route/route-tree.util.d.ts +15 -0
  46. package/dist/src/route/route-tree.util.js +32 -0
  47. package/dist/src/route/route-tree.util.js.map +1 -0
  48. package/dist/src/route/route.core.d.ts +31 -25
  49. package/dist/src/route/route.core.js +82 -60
  50. package/dist/src/route/route.core.js.map +1 -1
  51. package/dist/src/route/route.resolver.d.ts +28 -0
  52. package/dist/src/route/route.resolver.js +11 -0
  53. package/dist/src/route/route.resolver.js.map +1 -0
  54. package/dist/src/route/route.trie.d.ts +38 -0
  55. package/dist/src/route/route.trie.js +206 -0
  56. package/dist/src/route/route.trie.js.map +1 -0
  57. package/dist/src/type/route-tree.type.d.ts +42 -0
  58. package/dist/src/type/route-tree.type.js +12 -0
  59. package/dist/src/type/route-tree.type.js.map +1 -0
  60. package/package.json +1 -1
  61. package/runtime/abstract.runtime.ts +46 -146
  62. package/runtime/bun/esbuild-runtime-loader.plugin.ts +19 -3
  63. package/runtime/bun/fs/bun-fs.runtime.ts +0 -1
  64. package/runtime/bun/sqlite/bun-sqlite.runtime.ts +4 -5
  65. package/runtime/universal/fs/universal-fs.runtime.ts +0 -1
  66. package/server/codegen.util.ts +8 -4
  67. package/server/emroute.server.ts +50 -48
  68. package/server/esbuild-manifest.plugin.ts +68 -104
  69. package/server/server-api.type.ts +5 -5
  70. package/src/index.ts +5 -2
  71. package/src/renderer/spa/base.renderer.ts +4 -4
  72. package/src/renderer/spa/hash.renderer.ts +43 -20
  73. package/src/renderer/spa/html.renderer.ts +29 -12
  74. package/src/renderer/spa/mod.ts +3 -1
  75. package/src/renderer/ssr/html.renderer.ts +6 -5
  76. package/src/renderer/ssr/md.renderer.ts +6 -5
  77. package/src/renderer/ssr/ssr.renderer.ts +11 -17
  78. package/src/route/route-tree.util.ts +35 -0
  79. package/src/route/route.core.ts +89 -64
  80. package/src/route/route.resolver.ts +33 -0
  81. package/src/route/route.trie.ts +265 -0
  82. package/src/type/route-tree.type.ts +49 -0
@@ -17,10 +17,10 @@ import type {
17
17
  RedirectConfig,
18
18
  RouteParams,
19
19
  RouterState,
20
- RoutesManifest,
21
20
  } from '../../type/route.type.ts';
22
21
  import type { ContextProvider } from '../../component/abstract.component.ts';
23
22
  import type { PageComponent } from '../../component/page.component.ts';
23
+ import type { RouteResolver } from '../../route/route.resolver.ts';
24
24
  import { ComponentElement } from '../../element/component.element.ts';
25
25
  import {
26
26
  assertSafeRedirect,
@@ -40,6 +40,8 @@ export interface SpaHtmlRouterOptions {
40
40
  extendContext?: ContextProvider;
41
41
  /** Base paths for SSR endpoints. SPA uses html basePath for routing, md for passthrough. */
42
42
  basePath?: BasePath;
43
+ /** Pre-bundled module loaders keyed by file path. Bridges JSON route tree → bundled code in the browser. */
44
+ moduleLoaders?: Record<string, () => Promise<unknown>>;
43
45
  }
44
46
 
45
47
  /**
@@ -51,11 +53,12 @@ export class SpaHtmlRouter extends BaseRenderer {
51
53
  private htmlBase: string;
52
54
  private mdBase: string;
53
55
 
54
- constructor(manifest: RoutesManifest, options?: SpaHtmlRouterOptions) {
56
+ constructor(resolver: RouteResolver, options?: SpaHtmlRouterOptions) {
55
57
  const bp = options?.basePath ?? DEFAULT_BASE_PATH;
56
- const core = new RouteCore(manifest, {
58
+ const core = new RouteCore(resolver, {
57
59
  extendContext: options?.extendContext,
58
60
  basePath: bp.html,
61
+ moduleLoaders: options?.moduleLoaders,
59
62
  });
60
63
  super(core);
61
64
  this.htmlBase = bp.html;
@@ -120,8 +123,8 @@ export class SpaHtmlRouter extends BaseRenderer {
120
123
  logger.ssr('check-adoption', `SSR route=${ssrRoute}, current=${currentPath}`);
121
124
 
122
125
  if (currentPath === ssrRoute || currentPath === ssrRoute + '/') {
123
- // Adopt SSR content — patterns are prefixed, match directly
124
- const matched = this.core.match(new URL(ssrRoute, location.origin));
126
+ // Adopt SSR content — strip basePath before matching unprefixed trie
127
+ const matched = this.core.match(new URL(this.stripBase(ssrRoute), location.origin));
125
128
  if (matched) {
126
129
  logger.ssr('adopt', ssrRoute);
127
130
  this.core.currentRoute = matched;
@@ -195,6 +198,17 @@ export class SpaHtmlRouter extends BaseRenderer {
195
198
  return this.core.currentRoute;
196
199
  }
197
200
 
201
+ /**
202
+ * Strip the HTML basePath prefix from a browser pathname.
203
+ * Browser URLs include the prefix (e.g. /html/about) but trie patterns don't.
204
+ */
205
+ private stripBase(pathname: string): string {
206
+ if (this.htmlBase && (pathname.startsWith(this.htmlBase + '/') || pathname === this.htmlBase)) {
207
+ return pathname === this.htmlBase ? '/' : pathname.slice(this.htmlBase.length);
208
+ }
209
+ return pathname;
210
+ }
211
+
198
212
  /**
199
213
  * Handle navigation to a URL.
200
214
  *
@@ -217,8 +231,11 @@ export class SpaHtmlRouter extends BaseRenderer {
217
231
  return;
218
232
  }
219
233
 
234
+ // Strip basePath prefix — trie holds unprefixed patterns
235
+ const routePath = this.stripBase(pathname);
236
+
220
237
  try {
221
- const matched = this.core.match(urlObj);
238
+ const matched = this.core.match(new URL(routePath, location.origin));
222
239
 
223
240
  if (!matched) {
224
241
  logger.nav('not-found', pathname, pathname);
@@ -267,7 +284,7 @@ export class SpaHtmlRouter extends BaseRenderer {
267
284
  await this.renderStatusPage(error.status, pathname);
268
285
  return;
269
286
  }
270
- await this.handleError(error, pathname);
287
+ await this.handleError(error, routePath);
271
288
  }
272
289
  }
273
290
 
@@ -280,7 +297,7 @@ export class SpaHtmlRouter extends BaseRenderer {
280
297
  ): Promise<void> {
281
298
  if (!this.slot) return;
282
299
 
283
- const statusPage = this.core.matcher.getStatusPage(status);
300
+ const statusPage = this.core.getStatusPage(status);
284
301
 
285
302
  if (statusPage) {
286
303
  try {
@@ -342,10 +359,10 @@ export class SpaHtmlRouter extends BaseRenderer {
342
359
  error: error instanceof Error ? error : new Error(String(error)),
343
360
  });
344
361
 
345
- const boundary = this.core.matcher.findErrorBoundary(pathname);
362
+ const boundary = this.core.findErrorBoundary(pathname);
346
363
  if (boundary && await this.tryRenderErrorModule(boundary.modulePath)) return;
347
364
 
348
- const errorHandler = this.core.matcher.getErrorHandler();
365
+ const errorHandler = this.core.getErrorHandler();
349
366
  if (errorHandler && await this.tryRenderErrorModule(errorHandler.modulePath)) return;
350
367
 
351
368
  if (this.slot) {
@@ -367,7 +384,7 @@ export class SpaHtmlRouter extends BaseRenderer {
367
384
  * Calling this function twice returns the existing router with a warning.
368
385
  */
369
386
  export async function createSpaHtmlRouter(
370
- manifest: RoutesManifest,
387
+ resolver: RouteResolver,
371
388
  options?: SpaHtmlRouterOptions,
372
389
  ): Promise<SpaHtmlRouter> {
373
390
  const g = globalThis as Record<string, unknown>;
@@ -375,7 +392,7 @@ export async function createSpaHtmlRouter(
375
392
  console.warn('eMroute: SPA router already initialized. Remove duplicate <script> tags.');
376
393
  return g.__emroute_router as SpaHtmlRouter;
377
394
  }
378
- const router = new SpaHtmlRouter(manifest, options);
395
+ const router = new SpaHtmlRouter(resolver, options);
379
396
  await router.initialize();
380
397
  g.__emroute_router = router;
381
398
  return router;
@@ -39,8 +39,10 @@ export type {
39
39
  RouterEvent,
40
40
  RouterEventListener,
41
41
  RouterEventType,
42
- RoutesManifest,
43
42
  } from '../../type/route.type.ts';
43
+ export type { RouteNode } from '../../type/route-tree.type.ts';
44
+ export type { RouteResolver, ResolvedRoute } from '../../route/route.resolver.ts';
45
+ export { RouteTrie } from '../../route/route.trie.ts';
44
46
  export type { MarkdownRenderer } from '../../type/markdown.type.ts';
45
47
  export { type BasePath, DEFAULT_BASE_PATH } from '../../route/route.core.ts';
46
48
  export { escapeHtml, scopeWidgetCss } from '../../util/html.util.ts';
@@ -6,7 +6,8 @@
6
6
  * Expands <mark-down> tags server-side when a markdown renderer is provided.
7
7
  */
8
8
 
9
- import type { RouteConfig, RouteInfo, RoutesManifest } from '../../type/route.type.ts';
9
+ import type { RouteConfig, RouteInfo } from '../../type/route.type.ts';
10
+ import type { RouteResolver } from '../../route/route.resolver.ts';
10
11
  import type { MarkdownRenderer } from '../../type/markdown.type.ts';
11
12
  import type { PageComponent } from '../../component/page.component.ts';
12
13
  import { DEFAULT_ROOT_ROUTE } from '../../route/route.core.ts';
@@ -28,8 +29,8 @@ export class SsrHtmlRouter extends SsrRenderer {
28
29
  private markdownRenderer: MarkdownRenderer | null;
29
30
  private markdownReady: Promise<void> | null = null;
30
31
 
31
- constructor(manifest: RoutesManifest, options: SsrHtmlRouterOptions = {}) {
32
- super(manifest, options);
32
+ constructor(resolver: RouteResolver, options: SsrHtmlRouterOptions = {}) {
33
+ super(resolver, options);
33
34
  this.markdownRenderer = options.markdownRenderer ?? null;
34
35
 
35
36
  if (this.markdownRenderer?.init) {
@@ -152,8 +153,8 @@ export class SsrHtmlRouter extends SsrRenderer {
152
153
  * Create SSR HTML router.
153
154
  */
154
155
  export function createSsrHtmlRouter(
155
- manifest: RoutesManifest,
156
+ resolver: RouteResolver,
156
157
  options?: SsrHtmlRouterOptions,
157
158
  ): SsrHtmlRouter {
158
- return new SsrHtmlRouter(manifest, options);
159
+ return new SsrHtmlRouter(resolver, options);
159
160
  }
@@ -5,7 +5,8 @@
5
5
  * Generates Markdown strings for LLM consumption, text clients, curl.
6
6
  */
7
7
 
8
- import type { RouteConfig, RouteInfo, RoutesManifest } from '../../type/route.type.ts';
8
+ import type { RouteConfig, RouteInfo } from '../../type/route.type.ts';
9
+ import type { RouteResolver } from '../../route/route.resolver.ts';
9
10
  import type { PageComponent } from '../../component/page.component.ts';
10
11
  import { DEFAULT_ROOT_ROUTE } from '../../route/route.core.ts';
11
12
  import { STATUS_MESSAGES } from '../../util/html.util.ts';
@@ -28,8 +29,8 @@ export type SsrMdRouterOptions = SsrRendererOptions;
28
29
  export class SsrMdRouter extends SsrRenderer {
29
30
  protected override readonly label = 'SSR MD';
30
31
 
31
- constructor(manifest: RoutesManifest, options: SsrMdRouterOptions = {}) {
32
- super(manifest, options);
32
+ constructor(resolver: RouteResolver, options: SsrMdRouterOptions = {}) {
33
+ super(resolver, options);
33
34
  }
34
35
 
35
36
  protected override injectSlot(parent: string, child: string, parentPattern: string): string {
@@ -135,8 +136,8 @@ export class SsrMdRouter extends SsrRenderer {
135
136
  * Create SSR Markdown router.
136
137
  */
137
138
  export function createSsrMdRouter(
138
- manifest: RoutesManifest,
139
+ resolver: RouteResolver,
139
140
  options?: SsrMdRouterOptions,
140
141
  ): SsrMdRouter {
141
- return new SsrMdRouter(manifest, options);
142
+ return new SsrMdRouter(resolver, options);
142
143
  }
@@ -9,7 +9,6 @@ import type {
9
9
  MatchedRoute,
10
10
  RouteConfig,
11
11
  RouteInfo,
12
- RoutesManifest,
13
12
  } from '../../type/route.type.ts';
14
13
  import { logger } from '../../type/logger.type.ts';
15
14
  import type { ComponentContext } from '../../component/abstract.component.ts';
@@ -20,6 +19,7 @@ import {
20
19
  RouteCore,
21
20
  type RouteCoreOptions,
22
21
  } from '../../route/route.core.ts';
22
+ import type { RouteResolver } from '../../route/route.resolver.ts';
23
23
  import { toUrl } from '../../route/route.matcher.ts';
24
24
  import type { WidgetRegistry } from '../../widget/widget.registry.ts';
25
25
 
@@ -40,8 +40,8 @@ export abstract class SsrRenderer {
40
40
  protected widgetFiles: Record<string, { html?: string; md?: string; css?: string }>;
41
41
  protected abstract readonly label: string;
42
42
 
43
- constructor(manifest: RoutesManifest, options: SsrRendererOptions = {}) {
44
- this.core = new RouteCore(manifest, options);
43
+ constructor(resolver: RouteResolver, options: SsrRendererOptions = {}) {
44
+ this.core = new RouteCore(resolver, options);
45
45
  this.widgets = options.widgets ?? null;
46
46
  this.widgetFiles = options.widgetFiles ?? {};
47
47
  }
@@ -55,19 +55,12 @@ export abstract class SsrRenderer {
55
55
  const urlObj = toUrl(url);
56
56
  const pathname = urlObj.pathname;
57
57
 
58
- // Redirect trailing-slash URLs to canonical form (301)
59
- const normalized = this.core.normalizeUrl(pathname);
60
- if (normalized !== pathname) {
61
- const query = urlObj.search || '';
62
- return { content: '', status: 301, redirect: normalized + query };
63
- }
64
-
65
58
  const matched = this.core.match(urlObj);
66
59
 
67
60
  const searchParams = urlObj.searchParams ?? new URLSearchParams();
68
61
 
69
62
  if (!matched) {
70
- const statusPage = this.core.matcher.getStatusPage(404);
63
+ const statusPage = this.core.getStatusPage(404);
71
64
  if (statusPage) {
72
65
  try {
73
66
  const ri: RouteInfo = { pathname, pattern: statusPage.pattern, params: {}, searchParams };
@@ -93,6 +86,7 @@ export abstract class SsrRenderer {
93
86
  return {
94
87
  content: this.renderRedirect(redirectConfig.to),
95
88
  status: redirectConfig.status ?? 301,
89
+ redirect: redirectConfig.to,
96
90
  };
97
91
  }
98
92
 
@@ -103,7 +97,7 @@ export abstract class SsrRenderer {
103
97
  return { content, status: 200, title };
104
98
  } catch (error) {
105
99
  if (error instanceof Response) {
106
- const statusPage = this.core.matcher.getStatusPage(error.status);
100
+ const statusPage = this.core.getStatusPage(error.status);
107
101
  if (statusPage) {
108
102
  try {
109
103
  const ri: RouteInfo = {
@@ -132,13 +126,13 @@ export abstract class SsrRenderer {
132
126
  error instanceof Error ? error : undefined,
133
127
  );
134
128
 
135
- const boundary = this.core.matcher.findErrorBoundary(pathname);
129
+ const boundary = this.core.findErrorBoundary(pathname);
136
130
  if (boundary) {
137
131
  const result = await this.tryRenderErrorModule(boundary.modulePath, pathname, 'boundary');
138
132
  if (result) return result;
139
133
  }
140
134
 
141
- const errorHandler = this.core.matcher.getErrorHandler();
135
+ const errorHandler = this.core.getErrorHandler();
142
136
  if (errorHandler) {
143
137
  const result = await this.tryRenderErrorModule(errorHandler.modulePath, pathname, 'handler');
144
138
  if (result) return result;
@@ -163,10 +157,10 @@ export abstract class SsrRenderer {
163
157
 
164
158
  for (let i = 0; i < hierarchy.length; i++) {
165
159
  const routePattern = hierarchy[i];
166
- let route = this.core.matcher.findRoute(routePattern);
160
+ let route = this.core.findRoute(routePattern);
167
161
 
168
- if (!route && routePattern === this.core.root) {
169
- route = { ...DEFAULT_ROOT_ROUTE, pattern: this.core.root };
162
+ if (!route && routePattern === '/') {
163
+ route = DEFAULT_ROOT_ROUTE;
170
164
  }
171
165
 
172
166
  if (!route) continue;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Route Tree Utilities
3
+ *
4
+ * Pure functions for building a RouteNode tree from filesystem paths.
5
+ */
6
+
7
+ import type { RouteNode } from '../type/route-tree.type.ts';
8
+
9
+ /**
10
+ * Resolve the target node for a page or redirect file based on its name.
11
+ *
12
+ * - "index" at root → the node itself (root route)
13
+ * - "index" in a subdirectory → wildcard catch-all on the node
14
+ * - "[param]" → dynamic child
15
+ * - anything else → static child
16
+ */
17
+ export function resolveTargetNode(node: RouteNode, name: string, isRoot: boolean): RouteNode {
18
+ if (name === 'index') {
19
+ if (isRoot) return node;
20
+ // Non-root index → wildcard catch-all
21
+ node.wildcard ??= { param: 'rest', child: {} };
22
+ return node.wildcard.child;
23
+ }
24
+
25
+ if (name.startsWith('[') && name.endsWith(']')) {
26
+ const param = name.slice(1, -1);
27
+ node.dynamic ??= { param, child: {} };
28
+ return node.dynamic.child;
29
+ }
30
+
31
+ // Static segment
32
+ node.children ??= {};
33
+ node.children[name] ??= {};
34
+ return node.children[name];
35
+ }
@@ -2,10 +2,11 @@
2
2
  * Route Core
3
3
  *
4
4
  * Shared routing logic used by all renderers:
5
- * - Route matching and hierarchy building
5
+ * - Route matching (delegates to RouteResolver)
6
6
  * - Module loading and caching
7
7
  * - Event emission
8
8
  * - URL normalization
9
+ * - BasePath stripping
9
10
  */
10
11
 
11
12
  import type {
@@ -15,10 +16,10 @@ import type {
15
16
  RouteParams,
16
17
  RouterEvent,
17
18
  RouterEventListener,
18
- RoutesManifest,
19
19
  } from '../type/route.type.ts';
20
20
  import type { ComponentContext, ContextProvider } from '../component/abstract.component.ts';
21
- import { RouteMatcher, toUrl } from './route.matcher.ts';
21
+ import type { RouteResolver, ResolvedRoute } from './route.resolver.ts';
22
+ import { toUrl } from './route.matcher.ts';
22
23
 
23
24
  /** Base paths for the two SSR rendering endpoints. */
24
25
  export interface BasePath {
@@ -31,34 +32,6 @@ export interface BasePath {
31
32
  /** Default base paths — backward compatible with existing /html/ and /md/ prefixes. */
32
33
  export const DEFAULT_BASE_PATH: BasePath = { html: '/html', md: '/md' };
33
34
 
34
- /**
35
- * Create a copy of a manifest with basePath prepended to all patterns.
36
- * Used by the server to prefix bare in-memory manifests before passing to routers.
37
- */
38
- export function prefixManifest(manifest: RoutesManifest, basePath: string): RoutesManifest {
39
- if (!basePath) return manifest;
40
- return {
41
- routes: manifest.routes.map((r) => ({
42
- ...r,
43
- // Root pattern '/' becomes basePath itself (e.g. '/html'), not '/html/'
44
- pattern: r.pattern === '/' ? basePath : basePath + r.pattern,
45
- parent: r.parent ? (r.parent === '/' ? basePath : basePath + r.parent) : undefined,
46
- })),
47
- errorBoundaries: manifest.errorBoundaries.map((e) => ({
48
- ...e,
49
- pattern: e.pattern === '/' ? basePath : basePath + e.pattern,
50
- })),
51
- statusPages: new Map(
52
- [...manifest.statusPages].map(([status, route]) => [
53
- status,
54
- { ...route, pattern: basePath + route.pattern },
55
- ]),
56
- ),
57
- errorHandler: manifest.errorHandler,
58
- moduleLoaders: manifest.moduleLoaders,
59
- };
60
- }
61
-
62
35
  const BLOCKED_PROTOCOLS = /^(javascript|data|vbscript):/i;
63
36
 
64
37
  /** Throw if a redirect URL uses a dangerous protocol. */
@@ -68,13 +41,24 @@ export function assertSafeRedirect(url: string): void {
68
41
  }
69
42
  }
70
43
 
71
- /** Default root route - renders a slot for child routes */
44
+ /** Default root route renders a slot for child routes. */
72
45
  export const DEFAULT_ROOT_ROUTE: RouteConfig = {
73
46
  pattern: '/',
74
47
  type: 'page',
75
48
  modulePath: '__default_root__',
76
49
  };
77
50
 
51
+ /** Synthesize a RouteConfig from a ResolvedRoute (bridge for renderer compatibility). */
52
+ function toRouteConfig(resolved: ResolvedRoute): RouteConfig {
53
+ const node = resolved.node;
54
+ return {
55
+ pattern: resolved.pattern,
56
+ type: node.redirect ? 'redirect' : 'page',
57
+ modulePath: node.redirect ?? node.files?.ts ?? node.files?.html ?? node.files?.md ?? '',
58
+ files: node.files,
59
+ };
60
+ }
61
+
78
62
  /** Options for RouteCore */
79
63
  export interface RouteCoreOptions {
80
64
  /**
@@ -85,23 +69,21 @@ export interface RouteCoreOptions {
85
69
  fileReader?: (path: string) => Promise<string>;
86
70
  /** Enriches every ComponentContext with app-level services before it reaches components. */
87
71
  extendContext?: ContextProvider;
88
- /** Base path prepended to route patterns for URL matching (e.g. '/html'). No trailing slash. */
72
+ /** Base path stripped from URLs before matching (e.g. '/html'). No trailing slash. */
89
73
  basePath?: string;
74
+ /** Module loaders keyed by path — server provides these for SSR imports. */
75
+ moduleLoaders?: Record<string, () => Promise<unknown>>;
90
76
  }
91
77
 
92
78
  /**
93
79
  * Core router functionality shared across all rendering contexts.
94
80
  */
95
81
  export class RouteCore {
96
- readonly matcher: RouteMatcher;
82
+ private readonly resolver: RouteResolver;
97
83
  /** Registered context provider (if any). Exposed so renderers can apply it to inline contexts. */
98
84
  readonly contextProvider: ContextProvider | undefined;
99
85
  /** Base path for URL matching (e.g. '/html'). Empty string when no basePath. */
100
86
  readonly basePath: string;
101
- /** The root pattern — basePath when set, '/' otherwise. */
102
- get root(): string {
103
- return this.basePath || '/';
104
- }
105
87
  private listeners: Set<RouterEventListener> = new Set();
106
88
  private moduleCache: Map<string, unknown> = new Map();
107
89
  private widgetFileCache: Map<string, string> = new Map();
@@ -109,13 +91,13 @@ export class RouteCore {
109
91
  currentRoute: MatchedRoute | null = null;
110
92
  private readFile: (path: string) => Promise<string>;
111
93
 
112
- constructor(manifest: RoutesManifest, options: RouteCoreOptions = {}) {
94
+ constructor(resolver: RouteResolver, options: RouteCoreOptions = {}) {
95
+ this.resolver = resolver;
113
96
  this.basePath = options.basePath ?? '';
114
- this.matcher = new RouteMatcher(manifest);
115
97
  this.readFile = options.fileReader ??
116
98
  ((path) => fetch(path, { headers: { Accept: 'text/plain' } }).then((r) => r.text()));
117
99
  this.contextProvider = options.extendContext;
118
- this.moduleLoaders = manifest.moduleLoaders ?? {};
100
+ this.moduleLoaders = options.moduleLoaders ?? {};
119
101
  }
120
102
 
121
103
  /**
@@ -148,16 +130,24 @@ export class RouteCore {
148
130
 
149
131
  /**
150
132
  * Match a URL to a route.
151
- * Falls back to the default root route for the basePath root (or '/' when no basePath).
133
+ * Falls back to the default root route for '/'.
152
134
  */
153
135
  match(url: URL | string): MatchedRoute | undefined {
154
- const matched = this.matcher.match(url);
155
- if (matched) return matched;
156
-
157
136
  const urlObj = toUrl(url);
158
- if (urlObj.pathname === this.root || urlObj.pathname === this.root + '/') {
137
+ const pathname = urlObj.pathname;
138
+
139
+ const resolved = this.resolver.match(pathname);
140
+ if (resolved) {
141
+ return {
142
+ route: toRouteConfig(resolved),
143
+ params: resolved.params,
144
+ searchParams: urlObj.searchParams,
145
+ };
146
+ }
147
+
148
+ if (pathname === '/' || pathname === '') {
159
149
  return {
160
- route: { ...DEFAULT_ROOT_ROUTE, pattern: this.root },
150
+ route: DEFAULT_ROOT_ROUTE,
161
151
  params: {},
162
152
  searchParams: urlObj.searchParams,
163
153
  };
@@ -166,29 +156,67 @@ export class RouteCore {
166
156
  return undefined;
167
157
  }
168
158
 
159
+ /** Get status-specific page (404, 401, 403). */
160
+ getStatusPage(status: number): RouteConfig | undefined {
161
+ const node = this.resolver.findRoute(`/${status}`);
162
+ if (!node) return undefined;
163
+ return {
164
+ pattern: `/${status}`,
165
+ type: 'page',
166
+ modulePath: node.files?.ts ?? node.files?.html ?? node.files?.md ?? '',
167
+ files: node.files,
168
+ };
169
+ }
170
+
171
+ /** Get global error handler (root errorBoundary). */
172
+ getErrorHandler(): RouteConfig | undefined {
173
+ const modulePath = this.resolver.findErrorBoundary('/');
174
+ if (!modulePath) return undefined;
175
+ return { pattern: '/', type: 'error', modulePath };
176
+ }
177
+
178
+ /**
179
+ * Find error boundary for a given pathname.
180
+ * Note: pattern is the input pathname, not the boundary's own pattern.
181
+ * Callers should only rely on modulePath.
182
+ */
183
+ findErrorBoundary(pathname: string): { pattern: string; modulePath: string } | undefined {
184
+ const modulePath = this.resolver.findErrorBoundary(pathname);
185
+ if (!modulePath) return undefined;
186
+ return { pattern: pathname, modulePath };
187
+ }
188
+
189
+ /**
190
+ * Find a route by its exact pattern.
191
+ * Used for building route hierarchy.
192
+ */
193
+ findRoute(pattern: string): RouteConfig | undefined {
194
+ const node = this.resolver.findRoute(pattern);
195
+ if (!node) return undefined;
196
+ return {
197
+ pattern,
198
+ type: node.redirect ? 'redirect' : 'page',
199
+ modulePath: node.redirect ?? node.files?.ts ?? node.files?.html ?? node.files?.md ?? '',
200
+ files: node.files,
201
+ };
202
+ }
203
+
169
204
  /**
170
205
  * Build route hierarchy from a pattern.
206
+ * Patterns are always unprefixed (no basePath).
171
207
  *
172
- * When basePath is set, the root is the basePath itself and only
173
- * segments after it are split into ancestors.
174
- *
175
- * e.g., basePath='/html', pattern='/html/projects/:id/tasks'
176
- * → ['/html', '/html/projects', '/html/projects/:id', '/html/projects/:id/tasks']
177
- *
178
- * Without basePath: '/projects/:id/tasks'
208
+ * e.g., '/projects/:id/tasks'
179
209
  * → ['/', '/projects', '/projects/:id', '/projects/:id/tasks']
180
210
  */
181
211
  buildRouteHierarchy(pattern: string): string[] {
182
- if (pattern === this.root || pattern === this.root + '/') {
183
- return [this.root];
212
+ if (pattern === '/') {
213
+ return ['/'];
184
214
  }
185
215
 
186
- // Extract the part after basePath
187
- const tail = this.basePath ? pattern.slice(this.basePath.length) : pattern;
188
- const segments = tail.split('/').filter(Boolean);
216
+ const segments = pattern.split('/').filter(Boolean);
189
217
 
190
- const hierarchy: string[] = [this.root];
191
- let current = this.basePath || '';
218
+ const hierarchy: string[] = ['/'];
219
+ let current = '';
192
220
  for (const segment of segments) {
193
221
  current += '/' + segment;
194
222
  hierarchy.push(current);
@@ -238,7 +266,6 @@ export class RouteCore {
238
266
 
239
267
  /**
240
268
  * Load widget file contents with caching.
241
- * Returns an object with loaded file contents.
242
269
  */
243
270
  async loadWidgetFiles(
244
271
  widgetFiles: { html?: string; md?: string; css?: string },
@@ -285,8 +312,6 @@ export class RouteCore {
285
312
 
286
313
  /**
287
314
  * Build a ComponentContext by extending RouteInfo with loaded file contents.
288
- * When a signal is provided it is forwarded to fetch() calls and included
289
- * in the returned context so that getData() can observe cancellation.
290
315
  */
291
316
  async buildComponentContext(
292
317
  routeInfo: RouteInfo,
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Route Resolver
3
+ *
4
+ * Interface for route lookup. Accepts a RouteNode tree (the manifest),
5
+ * provides O(depth) matching, error boundary lookup, and hierarchy traversal.
6
+ *
7
+ * Implementations: RouteTrie (in-memory trie from RouteNode tree).
8
+ * RouteCore depends on this interface, not on the algorithm.
9
+ */
10
+
11
+ import type { RouteNode } from '../type/route-tree.type.ts';
12
+
13
+ /** Result of matching a URL pathname against the route tree. */
14
+ export interface ResolvedRoute {
15
+ /** The matched route node. */
16
+ readonly node: RouteNode;
17
+ /** URL pattern reconstructed from the tree path (e.g. "/projects/:id"). */
18
+ readonly pattern: string;
19
+ /** Extracted URL parameters (e.g. { id: "42" }). */
20
+ readonly params: Record<string, string>;
21
+ }
22
+
23
+ /** Route lookup interface. Decouples matching algorithm from the router. */
24
+ export interface RouteResolver {
25
+ /** Match a URL pathname to a route. */
26
+ match(pathname: string): ResolvedRoute | undefined;
27
+
28
+ /** Find the most specific error boundary for a pathname. */
29
+ findErrorBoundary(pathname: string): string | undefined;
30
+
31
+ /** Look up a route node by its exact pattern (e.g. "/projects/:id"). */
32
+ findRoute(pattern: string): RouteNode | undefined;
33
+ }