@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.
- package/dist/runtime/abstract.runtime.d.ts +6 -4
- package/dist/runtime/abstract.runtime.js +42 -121
- package/dist/runtime/abstract.runtime.js.map +1 -1
- package/dist/runtime/bun/esbuild-runtime-loader.plugin.d.ts +5 -1
- package/dist/runtime/bun/esbuild-runtime-loader.plugin.js +13 -2
- package/dist/runtime/bun/esbuild-runtime-loader.plugin.js.map +1 -1
- package/dist/runtime/bun/fs/bun-fs.runtime.js +0 -1
- package/dist/runtime/bun/fs/bun-fs.runtime.js.map +1 -1
- package/dist/runtime/bun/sqlite/bun-sqlite.runtime.js +4 -5
- package/dist/runtime/bun/sqlite/bun-sqlite.runtime.js.map +1 -1
- package/dist/runtime/universal/fs/universal-fs.runtime.js +0 -1
- package/dist/runtime/universal/fs/universal-fs.runtime.js.map +1 -1
- package/dist/server/codegen.util.d.ts +1 -1
- package/dist/server/codegen.util.js +6 -4
- package/dist/server/codegen.util.js.map +1 -1
- package/dist/server/emroute.server.js +54 -47
- package/dist/server/emroute.server.js.map +1 -1
- package/dist/server/esbuild-manifest.plugin.d.ts +0 -2
- package/dist/server/esbuild-manifest.plugin.js +67 -87
- package/dist/server/esbuild-manifest.plugin.js.map +1 -1
- package/dist/server/server-api.type.d.ts +5 -5
- package/dist/src/index.d.ts +5 -2
- package/dist/src/index.js +2 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/renderer/spa/base.renderer.js +4 -4
- package/dist/src/renderer/spa/base.renderer.js.map +1 -1
- package/dist/src/renderer/spa/hash.renderer.d.ts +3 -2
- package/dist/src/renderer/spa/hash.renderer.js +36 -17
- package/dist/src/renderer/spa/hash.renderer.js.map +1 -1
- package/dist/src/renderer/spa/html.renderer.d.ts +11 -3
- package/dist/src/renderer/spa/html.renderer.js +24 -11
- package/dist/src/renderer/spa/html.renderer.js.map +1 -1
- package/dist/src/renderer/spa/mod.d.ts +4 -1
- package/dist/src/renderer/spa/mod.js +1 -0
- package/dist/src/renderer/spa/mod.js.map +1 -1
- package/dist/src/renderer/ssr/html.renderer.d.ts +4 -3
- package/dist/src/renderer/ssr/html.renderer.js +4 -4
- package/dist/src/renderer/ssr/html.renderer.js.map +1 -1
- package/dist/src/renderer/ssr/md.renderer.d.ts +4 -3
- package/dist/src/renderer/ssr/md.renderer.js +4 -4
- package/dist/src/renderer/ssr/md.renderer.js.map +1 -1
- package/dist/src/renderer/ssr/ssr.renderer.d.ts +3 -2
- package/dist/src/renderer/ssr/ssr.renderer.js +10 -15
- package/dist/src/renderer/ssr/ssr.renderer.js.map +1 -1
- package/dist/src/route/route-tree.util.d.ts +15 -0
- package/dist/src/route/route-tree.util.js +32 -0
- package/dist/src/route/route-tree.util.js.map +1 -0
- package/dist/src/route/route.core.d.ts +31 -25
- package/dist/src/route/route.core.js +82 -60
- package/dist/src/route/route.core.js.map +1 -1
- package/dist/src/route/route.resolver.d.ts +28 -0
- package/dist/src/route/route.resolver.js +11 -0
- package/dist/src/route/route.resolver.js.map +1 -0
- package/dist/src/route/route.trie.d.ts +38 -0
- package/dist/src/route/route.trie.js +206 -0
- package/dist/src/route/route.trie.js.map +1 -0
- package/dist/src/type/route-tree.type.d.ts +42 -0
- package/dist/src/type/route-tree.type.js +12 -0
- package/dist/src/type/route-tree.type.js.map +1 -0
- package/package.json +1 -1
- package/runtime/abstract.runtime.ts +46 -146
- package/runtime/bun/esbuild-runtime-loader.plugin.ts +19 -3
- package/runtime/bun/fs/bun-fs.runtime.ts +0 -1
- package/runtime/bun/sqlite/bun-sqlite.runtime.ts +4 -5
- package/runtime/universal/fs/universal-fs.runtime.ts +0 -1
- package/server/codegen.util.ts +8 -4
- package/server/emroute.server.ts +50 -48
- package/server/esbuild-manifest.plugin.ts +68 -104
- package/server/server-api.type.ts +5 -5
- package/src/index.ts +5 -2
- package/src/renderer/spa/base.renderer.ts +4 -4
- package/src/renderer/spa/hash.renderer.ts +43 -20
- package/src/renderer/spa/html.renderer.ts +29 -12
- package/src/renderer/spa/mod.ts +3 -1
- package/src/renderer/ssr/html.renderer.ts +6 -5
- package/src/renderer/ssr/md.renderer.ts +6 -5
- package/src/renderer/ssr/ssr.renderer.ts +11 -17
- package/src/route/route-tree.util.ts +35 -0
- package/src/route/route.core.ts +89 -64
- package/src/route/route.resolver.ts +33 -0
- package/src/route/route.trie.ts +265 -0
- 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(
|
|
56
|
+
constructor(resolver: RouteResolver, options?: SpaHtmlRouterOptions) {
|
|
55
57
|
const bp = options?.basePath ?? DEFAULT_BASE_PATH;
|
|
56
|
-
const core = new RouteCore(
|
|
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 —
|
|
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(
|
|
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,
|
|
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.
|
|
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.
|
|
362
|
+
const boundary = this.core.findErrorBoundary(pathname);
|
|
346
363
|
if (boundary && await this.tryRenderErrorModule(boundary.modulePath)) return;
|
|
347
364
|
|
|
348
|
-
const errorHandler = this.core.
|
|
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
|
-
|
|
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(
|
|
395
|
+
const router = new SpaHtmlRouter(resolver, options);
|
|
379
396
|
await router.initialize();
|
|
380
397
|
g.__emroute_router = router;
|
|
381
398
|
return router;
|
package/src/renderer/spa/mod.ts
CHANGED
|
@@ -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
|
|
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(
|
|
32
|
-
super(
|
|
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
|
-
|
|
156
|
+
resolver: RouteResolver,
|
|
156
157
|
options?: SsrHtmlRouterOptions,
|
|
157
158
|
): SsrHtmlRouter {
|
|
158
|
-
return new SsrHtmlRouter(
|
|
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
|
|
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(
|
|
32
|
-
super(
|
|
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
|
-
|
|
139
|
+
resolver: RouteResolver,
|
|
139
140
|
options?: SsrMdRouterOptions,
|
|
140
141
|
): SsrMdRouter {
|
|
141
|
-
return new SsrMdRouter(
|
|
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(
|
|
44
|
-
this.core = new RouteCore(
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
160
|
+
let route = this.core.findRoute(routePattern);
|
|
167
161
|
|
|
168
|
-
if (!route && routePattern ===
|
|
169
|
-
route =
|
|
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
|
+
}
|
package/src/route/route.core.ts
CHANGED
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
* Route Core
|
|
3
3
|
*
|
|
4
4
|
* Shared routing logic used by all renderers:
|
|
5
|
-
* - Route matching
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
*
|
|
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 ===
|
|
183
|
-
return [
|
|
212
|
+
if (pattern === '/') {
|
|
213
|
+
return ['/'];
|
|
184
214
|
}
|
|
185
215
|
|
|
186
|
-
|
|
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[] = [
|
|
191
|
-
let current =
|
|
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
|
+
}
|