@emkodev/emroute 1.6.6-beta.2 → 1.6.6-beta.4
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/emroute.js +2757 -0
- package/dist/emroute.js.map +7 -0
- package/dist/runtime/abstract.runtime.d.ts +0 -28
- package/dist/runtime/abstract.runtime.js +10 -58
- package/dist/runtime/abstract.runtime.js.map +1 -1
- package/dist/runtime/bun/esbuild-runtime-loader.plugin.js +3 -0
- package/dist/runtime/bun/esbuild-runtime-loader.plugin.js.map +1 -1
- package/dist/runtime/bun/fs/bun-fs.runtime.d.ts +0 -5
- package/dist/runtime/bun/fs/bun-fs.runtime.js +1 -95
- package/dist/runtime/bun/fs/bun-fs.runtime.js.map +1 -1
- package/dist/runtime/bun/sqlite/bun-sqlite.runtime.d.ts +0 -5
- package/dist/runtime/bun/sqlite/bun-sqlite.runtime.js +2 -96
- package/dist/runtime/bun/sqlite/bun-sqlite.runtime.js.map +1 -1
- package/dist/runtime/fetch.runtime.d.ts +26 -0
- package/dist/runtime/fetch.runtime.js +55 -0
- package/dist/runtime/fetch.runtime.js.map +1 -0
- package/dist/runtime/sitemap.generator.d.ts +4 -4
- package/dist/runtime/sitemap.generator.js +32 -11
- package/dist/runtime/sitemap.generator.js.map +1 -1
- package/dist/runtime/universal/fs/universal-fs.runtime.d.ts +0 -5
- package/dist/runtime/universal/fs/universal-fs.runtime.js +1 -95
- package/dist/runtime/universal/fs/universal-fs.runtime.js.map +1 -1
- package/dist/server/build.util.d.ts +38 -0
- package/dist/server/build.util.js +133 -0
- package/dist/server/build.util.js.map +1 -0
- package/dist/server/codegen.util.d.ts +3 -0
- package/dist/server/codegen.util.js +28 -10
- package/dist/server/codegen.util.js.map +1 -1
- package/dist/server/emroute.server.js +53 -29
- package/dist/server/emroute.server.js.map +1 -1
- package/dist/server/esbuild-manifest.plugin.js +6 -4
- package/dist/server/esbuild-manifest.plugin.js.map +1 -1
- package/dist/server/server-api.type.d.ts +6 -0
- package/dist/src/component/abstract.component.d.ts +5 -3
- package/dist/src/component/abstract.component.js.map +1 -1
- package/dist/src/element/component.element.js +5 -4
- package/dist/src/element/component.element.js.map +1 -1
- package/dist/src/renderer/spa/mod.d.ts +2 -3
- package/dist/src/renderer/spa/mod.js +2 -3
- package/dist/src/renderer/spa/mod.js.map +1 -1
- package/dist/src/renderer/spa/thin-client.d.ts +34 -0
- package/dist/src/renderer/spa/thin-client.js +138 -0
- package/dist/src/renderer/spa/thin-client.js.map +1 -0
- package/dist/src/renderer/ssr/html.renderer.d.ts +3 -3
- package/dist/src/renderer/ssr/html.renderer.js +6 -6
- package/dist/src/renderer/ssr/html.renderer.js.map +1 -1
- package/dist/src/renderer/ssr/md.renderer.d.ts +3 -3
- package/dist/src/renderer/ssr/md.renderer.js +12 -7
- package/dist/src/renderer/ssr/md.renderer.js.map +1 -1
- package/dist/src/renderer/ssr/ssr.renderer.d.ts +7 -6
- package/dist/src/renderer/ssr/ssr.renderer.js +42 -44
- package/dist/src/renderer/ssr/ssr.renderer.js.map +1 -1
- package/dist/src/route/route.core.d.ts +16 -6
- package/dist/src/route/route.core.js +44 -23
- package/dist/src/route/route.core.js.map +1 -1
- package/dist/src/type/route-tree.type.d.ts +2 -0
- package/dist/src/type/route.type.d.ts +6 -24
- package/dist/src/util/md.util.d.ts +8 -0
- package/dist/src/util/md.util.js +28 -0
- package/dist/src/util/md.util.js.map +1 -0
- package/dist/src/util/widget-resolve.util.js +6 -1
- package/dist/src/util/widget-resolve.util.js.map +1 -1
- package/dist/src/widget/breadcrumb.widget.d.ts +0 -1
- package/dist/src/widget/breadcrumb.widget.js +4 -15
- package/dist/src/widget/breadcrumb.widget.js.map +1 -1
- package/package.json +13 -2
- package/runtime/abstract.runtime.ts +9 -82
- package/runtime/bun/esbuild-runtime-loader.plugin.ts +2 -0
- package/runtime/bun/fs/bun-fs.runtime.ts +0 -109
- package/runtime/bun/sqlite/bun-sqlite.runtime.ts +1 -112
- package/runtime/fetch.runtime.ts +70 -0
- package/runtime/sitemap.generator.ts +37 -12
- package/runtime/universal/fs/universal-fs.runtime.ts +0 -109
- package/server/build.util.ts +168 -0
- package/server/codegen.util.ts +29 -11
- package/server/emroute.server.ts +50 -30
- package/server/esbuild-manifest.plugin.ts +5 -3
- package/server/server-api.type.ts +7 -0
- package/src/component/abstract.component.ts +5 -3
- package/src/element/component.element.ts +5 -4
- package/src/renderer/spa/mod.ts +2 -8
- package/src/renderer/spa/thin-client.ts +165 -0
- package/src/renderer/ssr/html.renderer.ts +6 -5
- package/src/renderer/ssr/md.renderer.ts +12 -6
- package/src/renderer/ssr/ssr.renderer.ts +54 -48
- package/src/route/route.core.ts +49 -28
- package/src/type/route-tree.type.ts +2 -0
- package/src/type/route.type.ts +7 -32
- package/src/util/md.util.ts +31 -0
- package/src/util/widget-resolve.util.ts +6 -1
- package/src/widget/breadcrumb.widget.ts +4 -16
- package/server/scanner.util.ts +0 -243
- package/src/renderer/spa/base.renderer.ts +0 -186
- package/src/renderer/spa/hash.renderer.ts +0 -238
- package/src/renderer/spa/html.renderer.ts +0 -399
- package/src/route/route.matcher.ts +0 -260
- package/src/web-doc/index.md +0 -15
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/// <reference path="../../type/navigation-api.d.ts" />
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Emroute App
|
|
5
|
+
*
|
|
6
|
+
* Browser entry point for `/app/` routes. Wraps an EmrouteServer instance
|
|
7
|
+
* (same server, same pipeline) with Navigation API glue that intercepts
|
|
8
|
+
* link clicks, calls `htmlRouter.render()`, and injects the result.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { EmrouteServer } from '../../../server/server-api.type.ts';
|
|
12
|
+
import type { NavigateOptions } from '../../type/route.type.ts';
|
|
13
|
+
import { assertSafeRedirect, type BasePath, DEFAULT_BASE_PATH } from '../../route/route.core.ts';
|
|
14
|
+
import { escapeHtml } from '../../util/html.util.ts';
|
|
15
|
+
|
|
16
|
+
/** Options for `createEmrouteApp`. */
|
|
17
|
+
export interface EmrouteAppOptions {
|
|
18
|
+
basePath?: BasePath;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Browser app — Navigation API wired to an EmrouteServer. */
|
|
22
|
+
export class EmrouteApp {
|
|
23
|
+
private readonly server: EmrouteServer;
|
|
24
|
+
private readonly appBase: string;
|
|
25
|
+
private slot: Element | null = null;
|
|
26
|
+
private abortController: AbortController | null = null;
|
|
27
|
+
|
|
28
|
+
constructor(server: EmrouteServer, options?: EmrouteAppOptions) {
|
|
29
|
+
const bp = options?.basePath ?? DEFAULT_BASE_PATH;
|
|
30
|
+
this.server = server;
|
|
31
|
+
this.appBase = bp.app;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async initialize(slotSelector = 'router-slot'): Promise<void> {
|
|
35
|
+
this.slot = document.querySelector(slotSelector);
|
|
36
|
+
|
|
37
|
+
if (!this.slot) {
|
|
38
|
+
console.error('[EmrouteApp] Slot not found:', slotSelector);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!('navigation' in globalThis)) {
|
|
43
|
+
console.warn('[EmrouteApp] Navigation API not available');
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
this.abortController = new AbortController();
|
|
48
|
+
const { signal } = this.abortController;
|
|
49
|
+
|
|
50
|
+
navigation.addEventListener('navigate', (event) => {
|
|
51
|
+
if (!event.canIntercept) return;
|
|
52
|
+
if (event.hashChange) return;
|
|
53
|
+
if (event.downloadRequest !== null) return;
|
|
54
|
+
|
|
55
|
+
const url = new URL(event.destination.url);
|
|
56
|
+
if (!this.isAppPath(url.pathname)) return;
|
|
57
|
+
|
|
58
|
+
event.intercept({
|
|
59
|
+
scroll: 'manual',
|
|
60
|
+
handler: async () => {
|
|
61
|
+
await this.handleNavigation(url, event.signal);
|
|
62
|
+
event.scroll();
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
}, { signal });
|
|
66
|
+
|
|
67
|
+
// SSR adoption — server already rendered this page, skip re-render
|
|
68
|
+
const ssrRoute = this.slot.getAttribute('data-ssr-route');
|
|
69
|
+
if (ssrRoute && (location.pathname === ssrRoute || location.pathname === ssrRoute + '/')) {
|
|
70
|
+
this.slot.removeAttribute('data-ssr-route');
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Initial render
|
|
75
|
+
await this.handleNavigation(new URL(location.href), this.abortController.signal);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
dispose(): void {
|
|
79
|
+
this.abortController?.abort();
|
|
80
|
+
this.abortController = null;
|
|
81
|
+
this.slot = null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async navigate(url: string, options: NavigateOptions = {}): Promise<void> {
|
|
85
|
+
try {
|
|
86
|
+
const { finished } = navigation.navigate(url, {
|
|
87
|
+
state: options.state,
|
|
88
|
+
history: options.replace ? 'replace' : 'auto',
|
|
89
|
+
});
|
|
90
|
+
await finished;
|
|
91
|
+
} catch (e) {
|
|
92
|
+
if (e instanceof DOMException && e.name === 'AbortError') return;
|
|
93
|
+
throw e;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private isAppPath(pathname: string): boolean {
|
|
98
|
+
return pathname === this.appBase || pathname.startsWith(this.appBase + '/');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private stripAppBase(pathname: string): string {
|
|
102
|
+
if (pathname === this.appBase) return '/';
|
|
103
|
+
if (pathname.startsWith(this.appBase + '/')) return pathname.slice(this.appBase.length);
|
|
104
|
+
return pathname;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private async handleNavigation(url: URL, signal: AbortSignal): Promise<void> {
|
|
108
|
+
if (!this.slot || !this.server.htmlRouter) return;
|
|
109
|
+
|
|
110
|
+
const routePath = this.stripAppBase(url.pathname);
|
|
111
|
+
const routeUrl = new URL(routePath + url.search, url.origin);
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
const { content, title, redirect } = await this.server.htmlRouter.render(routeUrl, signal);
|
|
115
|
+
|
|
116
|
+
if (signal.aborted) return;
|
|
117
|
+
|
|
118
|
+
if (redirect) {
|
|
119
|
+
assertSafeRedirect(redirect);
|
|
120
|
+
const target = redirect.startsWith('/') ? this.appBase + redirect : redirect;
|
|
121
|
+
navigation.navigate(target, { history: 'replace' });
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (document.startViewTransition) {
|
|
126
|
+
const transition = document.startViewTransition(() => {
|
|
127
|
+
this.slot!.setHTMLUnsafe(content);
|
|
128
|
+
});
|
|
129
|
+
signal.addEventListener('abort', () => transition.skipTransition(), { once: true });
|
|
130
|
+
await transition.updateCallbackDone;
|
|
131
|
+
} else {
|
|
132
|
+
this.slot.setHTMLUnsafe(content);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (title) document.title = title;
|
|
136
|
+
} catch (error) {
|
|
137
|
+
if (signal.aborted) return;
|
|
138
|
+
console.error('[EmrouteApp] Navigation error:', error);
|
|
139
|
+
if (this.slot) {
|
|
140
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
141
|
+
this.slot.setHTMLUnsafe(`<h1>Error</h1><p>${escapeHtml(message)}</p>`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Create and initialize the browser app.
|
|
149
|
+
*
|
|
150
|
+
* Stored on `globalThis.__emroute_app` for programmatic access.
|
|
151
|
+
*/
|
|
152
|
+
export async function createEmrouteApp(
|
|
153
|
+
server: EmrouteServer,
|
|
154
|
+
options?: EmrouteAppOptions,
|
|
155
|
+
): Promise<EmrouteApp> {
|
|
156
|
+
const g = globalThis as Record<string, unknown>;
|
|
157
|
+
if (g.__emroute_app) {
|
|
158
|
+
console.warn('eMroute: App already initialized.');
|
|
159
|
+
return g.__emroute_app as EmrouteApp;
|
|
160
|
+
}
|
|
161
|
+
const app = new EmrouteApp(server, options);
|
|
162
|
+
await app.initialize();
|
|
163
|
+
g.__emroute_app = app;
|
|
164
|
+
return app;
|
|
165
|
+
}
|
|
@@ -57,12 +57,13 @@ export class SsrHtmlRouter extends SsrRenderer {
|
|
|
57
57
|
routeInfo: RouteInfo,
|
|
58
58
|
route: RouteConfig,
|
|
59
59
|
isLeaf?: boolean,
|
|
60
|
+
signal?: AbortSignal,
|
|
60
61
|
): Promise<{ content: string; title?: string }> {
|
|
61
62
|
if (route.modulePath === DEFAULT_ROOT_ROUTE.modulePath) {
|
|
62
63
|
return { content: `<router-slot pattern="${route.pattern}"></router-slot>` };
|
|
63
64
|
}
|
|
64
65
|
|
|
65
|
-
const { content: rawContent, title } = await this.loadRouteContent(routeInfo, route, isLeaf);
|
|
66
|
+
const { content: rawContent, title } = await this.loadRouteContent(routeInfo, route, isLeaf, signal);
|
|
66
67
|
let content = rawContent;
|
|
67
68
|
|
|
68
69
|
// Expand <mark-down> tags server-side
|
|
@@ -100,18 +101,18 @@ export class SsrHtmlRouter extends SsrRenderer {
|
|
|
100
101
|
return `<meta http-equiv="refresh" content="0;url=${escapeHtml(to)}">`;
|
|
101
102
|
}
|
|
102
103
|
|
|
103
|
-
protected override renderStatusPage(status: number,
|
|
104
|
+
protected override renderStatusPage(status: number, url: URL): string {
|
|
104
105
|
return `
|
|
105
106
|
<h1>${STATUS_MESSAGES[status] ?? 'Error'}</h1>
|
|
106
|
-
<p>Path: ${escapeHtml(pathname)}</p>
|
|
107
|
+
<p>Path: ${escapeHtml(url.pathname)}</p>
|
|
107
108
|
`;
|
|
108
109
|
}
|
|
109
110
|
|
|
110
|
-
protected override renderErrorPage(error: unknown,
|
|
111
|
+
protected override renderErrorPage(error: unknown, url: URL): string {
|
|
111
112
|
const message = error instanceof Error ? error.message : String(error);
|
|
112
113
|
return `
|
|
113
114
|
<h1>Error</h1>
|
|
114
|
-
<p>Path: ${escapeHtml(pathname)}</p>
|
|
115
|
+
<p>Path: ${escapeHtml(url.pathname)}</p>
|
|
115
116
|
<p>${escapeHtml(message)}</p>
|
|
116
117
|
`;
|
|
117
118
|
}
|
|
@@ -50,12 +50,13 @@ export class SsrMdRouter extends SsrRenderer {
|
|
|
50
50
|
routeInfo: RouteInfo,
|
|
51
51
|
route: RouteConfig,
|
|
52
52
|
isLeaf?: boolean,
|
|
53
|
+
signal?: AbortSignal,
|
|
53
54
|
): Promise<{ content: string; title?: string }> {
|
|
54
55
|
if (route.modulePath === DEFAULT_ROOT_ROUTE.modulePath) {
|
|
55
56
|
return { content: routerSlotBlock(route.pattern) };
|
|
56
57
|
}
|
|
57
58
|
|
|
58
|
-
const { content: rawContent, title } = await this.loadRouteContent(routeInfo, route, isLeaf);
|
|
59
|
+
const { content: rawContent, title } = await this.loadRouteContent(routeInfo, route, isLeaf, signal);
|
|
59
60
|
let content = rawContent;
|
|
60
61
|
|
|
61
62
|
// Attribute bare router-slot blocks with this route's pattern
|
|
@@ -81,12 +82,12 @@ export class SsrMdRouter extends SsrRenderer {
|
|
|
81
82
|
return `Redirect to: ${to}`;
|
|
82
83
|
}
|
|
83
84
|
|
|
84
|
-
protected override renderStatusPage(status: number,
|
|
85
|
-
return `# ${STATUS_MESSAGES[status] ?? 'Error'}\n\nPath: \`${pathname}\``;
|
|
85
|
+
protected override renderStatusPage(status: number, url: URL): string {
|
|
86
|
+
return `# ${STATUS_MESSAGES[status] ?? 'Error'}\n\nPath: \`${url.pathname}\``;
|
|
86
87
|
}
|
|
87
88
|
|
|
88
|
-
protected override renderErrorPage(_error: unknown,
|
|
89
|
-
return `# Internal Server Error\n\nPath: \`${pathname}\``;
|
|
89
|
+
protected override renderErrorPage(_error: unknown, url: URL): string {
|
|
90
|
+
return `# Internal Server Error\n\nPath: \`${url.pathname}\``;
|
|
90
91
|
}
|
|
91
92
|
|
|
92
93
|
/**
|
|
@@ -117,7 +118,12 @@ export class SsrMdRouter extends SsrRenderer {
|
|
|
117
118
|
files = await this.core.loadWidgetFiles(filePaths);
|
|
118
119
|
}
|
|
119
120
|
|
|
120
|
-
const baseContext = {
|
|
121
|
+
const baseContext = {
|
|
122
|
+
...routeInfo,
|
|
123
|
+
pathname: routeInfo.url.pathname,
|
|
124
|
+
searchParams: routeInfo.url.searchParams,
|
|
125
|
+
files,
|
|
126
|
+
};
|
|
121
127
|
const context = this.core.contextProvider
|
|
122
128
|
? this.core.contextProvider(baseContext)
|
|
123
129
|
: baseContext;
|
|
@@ -20,7 +20,6 @@ import {
|
|
|
20
20
|
type RouteCoreOptions,
|
|
21
21
|
} from '../../route/route.core.ts';
|
|
22
22
|
import type { RouteResolver } from '../../route/route.resolver.ts';
|
|
23
|
-
import { toUrl } from '../../route/route.matcher.ts';
|
|
24
23
|
import type { WidgetRegistry } from '../../widget/widget.registry.ts';
|
|
25
24
|
|
|
26
25
|
/** Base options for SSR renderers */
|
|
@@ -50,30 +49,26 @@ export abstract class SsrRenderer {
|
|
|
50
49
|
* Render a URL to a content string.
|
|
51
50
|
*/
|
|
52
51
|
async render(
|
|
53
|
-
url:
|
|
52
|
+
url: URL,
|
|
53
|
+
signal?: AbortSignal,
|
|
54
54
|
): Promise<{ content: string; status: number; title?: string; redirect?: string }> {
|
|
55
|
-
const
|
|
56
|
-
const pathname = urlObj.pathname;
|
|
57
|
-
|
|
58
|
-
const matched = this.core.match(urlObj);
|
|
59
|
-
|
|
60
|
-
const searchParams = urlObj.searchParams ?? new URLSearchParams();
|
|
55
|
+
const matched = this.core.match(url);
|
|
61
56
|
|
|
62
57
|
if (!matched) {
|
|
63
58
|
const statusPage = this.core.getStatusPage(404);
|
|
64
59
|
if (statusPage) {
|
|
65
60
|
try {
|
|
66
|
-
const ri: RouteInfo = {
|
|
67
|
-
const result = await this.renderRouteContent(ri, statusPage);
|
|
61
|
+
const ri: RouteInfo = { url, params: {} };
|
|
62
|
+
const result = await this.renderRouteContent(ri, statusPage, undefined, signal);
|
|
68
63
|
return { content: this.stripSlots(result.content), status: 404, title: result.title };
|
|
69
64
|
} catch (e) {
|
|
70
65
|
logger.error(
|
|
71
|
-
`[${this.label}] Failed to render 404 status page for ${pathname}`,
|
|
66
|
+
`[${this.label}] Failed to render 404 status page for ${url.pathname}`,
|
|
72
67
|
e instanceof Error ? e : undefined,
|
|
73
68
|
);
|
|
74
69
|
}
|
|
75
70
|
}
|
|
76
|
-
return { content: this.renderStatusPage(404,
|
|
71
|
+
return { content: this.renderStatusPage(404, url), status: 404 };
|
|
77
72
|
}
|
|
78
73
|
|
|
79
74
|
// Handle redirect
|
|
@@ -90,23 +85,18 @@ export abstract class SsrRenderer {
|
|
|
90
85
|
};
|
|
91
86
|
}
|
|
92
87
|
|
|
93
|
-
const routeInfo = this.core.toRouteInfo(matched,
|
|
88
|
+
const routeInfo = this.core.toRouteInfo(matched, url);
|
|
94
89
|
|
|
95
90
|
try {
|
|
96
|
-
const { content, title } = await this.renderPage(routeInfo, matched);
|
|
91
|
+
const { content, title } = await this.renderPage(routeInfo, matched, signal);
|
|
97
92
|
return { content, status: 200, title };
|
|
98
93
|
} catch (error) {
|
|
99
94
|
if (error instanceof Response) {
|
|
100
95
|
const statusPage = this.core.getStatusPage(error.status);
|
|
101
96
|
if (statusPage) {
|
|
102
97
|
try {
|
|
103
|
-
const ri: RouteInfo = {
|
|
104
|
-
|
|
105
|
-
pattern: statusPage.pattern,
|
|
106
|
-
params: {},
|
|
107
|
-
searchParams,
|
|
108
|
-
};
|
|
109
|
-
const result = await this.renderRouteContent(ri, statusPage);
|
|
98
|
+
const ri: RouteInfo = { url, params: {} };
|
|
99
|
+
const result = await this.renderRouteContent(ri, statusPage, undefined, signal);
|
|
110
100
|
return {
|
|
111
101
|
content: this.stripSlots(result.content),
|
|
112
102
|
status: error.status,
|
|
@@ -114,31 +104,31 @@ export abstract class SsrRenderer {
|
|
|
114
104
|
};
|
|
115
105
|
} catch (e) {
|
|
116
106
|
logger.error(
|
|
117
|
-
`[${this.label}] Failed to render ${error.status} status page for ${pathname}`,
|
|
107
|
+
`[${this.label}] Failed to render ${error.status} status page for ${url.pathname}`,
|
|
118
108
|
e instanceof Error ? e : undefined,
|
|
119
109
|
);
|
|
120
110
|
}
|
|
121
111
|
}
|
|
122
|
-
return { content: this.renderStatusPage(error.status,
|
|
112
|
+
return { content: this.renderStatusPage(error.status, url), status: error.status };
|
|
123
113
|
}
|
|
124
114
|
logger.error(
|
|
125
|
-
`[${this.label}] Error rendering ${pathname}:`,
|
|
115
|
+
`[${this.label}] Error rendering ${url.pathname}:`,
|
|
126
116
|
error instanceof Error ? error : undefined,
|
|
127
117
|
);
|
|
128
118
|
|
|
129
|
-
const boundary = this.core.findErrorBoundary(pathname);
|
|
119
|
+
const boundary = this.core.findErrorBoundary(url.pathname);
|
|
130
120
|
if (boundary) {
|
|
131
|
-
const result = await this.tryRenderErrorModule(boundary.modulePath,
|
|
121
|
+
const result = await this.tryRenderErrorModule(boundary.modulePath, url, 'boundary');
|
|
132
122
|
if (result) return result;
|
|
133
123
|
}
|
|
134
124
|
|
|
135
125
|
const errorHandler = this.core.getErrorHandler();
|
|
136
126
|
if (errorHandler) {
|
|
137
|
-
const result = await this.tryRenderErrorModule(errorHandler.modulePath,
|
|
127
|
+
const result = await this.tryRenderErrorModule(errorHandler.modulePath, url, 'handler');
|
|
138
128
|
if (result) return result;
|
|
139
129
|
}
|
|
140
130
|
|
|
141
|
-
return { content: this.renderErrorPage(error,
|
|
131
|
+
return { content: this.renderErrorPage(error, url), status: 500 };
|
|
142
132
|
}
|
|
143
133
|
}
|
|
144
134
|
|
|
@@ -148,13 +138,12 @@ export abstract class SsrRenderer {
|
|
|
148
138
|
protected async renderPage(
|
|
149
139
|
routeInfo: RouteInfo,
|
|
150
140
|
matched: MatchedRoute,
|
|
141
|
+
signal?: AbortSignal,
|
|
151
142
|
): Promise<{ content: string; title?: string }> {
|
|
152
|
-
const hierarchy = this.core.buildRouteHierarchy(
|
|
153
|
-
|
|
154
|
-
let result = '';
|
|
155
|
-
let pageTitle: string | undefined;
|
|
156
|
-
let lastRenderedPattern = '';
|
|
143
|
+
const hierarchy = this.core.buildRouteHierarchy(matched.route.pattern);
|
|
157
144
|
|
|
145
|
+
// Resolve routes for each hierarchy segment (skip missing / duplicate wildcard)
|
|
146
|
+
const segments: { route: RouteConfig; isLeaf: boolean }[] = [];
|
|
158
147
|
for (let i = 0; i < hierarchy.length; i++) {
|
|
159
148
|
const routePattern = hierarchy[i];
|
|
160
149
|
let route = this.core.findRoute(routePattern);
|
|
@@ -164,12 +153,25 @@ export abstract class SsrRenderer {
|
|
|
164
153
|
}
|
|
165
154
|
|
|
166
155
|
if (!route) continue;
|
|
167
|
-
|
|
168
|
-
// Skip wildcard route appearing as its own parent (prevents double-render)
|
|
169
156
|
if (route === matched.route && routePattern !== matched.route.pattern) continue;
|
|
170
157
|
|
|
171
|
-
|
|
172
|
-
|
|
158
|
+
segments.push({ route, isLeaf: i === hierarchy.length - 1 });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Fire all renderRouteContent calls in parallel
|
|
162
|
+
const results = await Promise.all(
|
|
163
|
+
segments.map(({ route, isLeaf }) =>
|
|
164
|
+
this.renderRouteContent(routeInfo, route, isLeaf, signal),
|
|
165
|
+
),
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
// Sequential slot injection
|
|
169
|
+
let result = '';
|
|
170
|
+
let pageTitle: string | undefined;
|
|
171
|
+
let lastRenderedPattern = '';
|
|
172
|
+
|
|
173
|
+
for (let i = 0; i < segments.length; i++) {
|
|
174
|
+
const { content, title } = results[i];
|
|
173
175
|
|
|
174
176
|
if (title) {
|
|
175
177
|
pageTitle = title;
|
|
@@ -182,14 +184,14 @@ export abstract class SsrRenderer {
|
|
|
182
184
|
if (injected === result) {
|
|
183
185
|
logger.warn(
|
|
184
186
|
`[${this.label}] Route "${lastRenderedPattern}" has no <router-slot> ` +
|
|
185
|
-
`for child route "${
|
|
187
|
+
`for child route "${hierarchy[i]}" to render into. ` +
|
|
186
188
|
`Add <router-slot></router-slot> to the parent template.`,
|
|
187
189
|
);
|
|
188
190
|
}
|
|
189
191
|
result = injected;
|
|
190
192
|
}
|
|
191
193
|
|
|
192
|
-
lastRenderedPattern = route.pattern;
|
|
194
|
+
lastRenderedPattern = segments[i].route.pattern;
|
|
193
195
|
}
|
|
194
196
|
|
|
195
197
|
result = this.stripSlots(result);
|
|
@@ -201,6 +203,7 @@ export abstract class SsrRenderer {
|
|
|
201
203
|
routeInfo: RouteInfo,
|
|
202
204
|
route: RouteConfig,
|
|
203
205
|
isLeaf?: boolean,
|
|
206
|
+
signal?: AbortSignal,
|
|
204
207
|
): Promise<{ content: string; title?: string }>;
|
|
205
208
|
|
|
206
209
|
/** Load component, build context, get data, render content, get title. */
|
|
@@ -208,16 +211,17 @@ export abstract class SsrRenderer {
|
|
|
208
211
|
routeInfo: RouteInfo,
|
|
209
212
|
route: RouteConfig,
|
|
210
213
|
isLeaf?: boolean,
|
|
214
|
+
signal?: AbortSignal,
|
|
211
215
|
): Promise<{ content: string; title?: string }> {
|
|
212
216
|
const files = route.files ?? {};
|
|
213
217
|
|
|
214
|
-
const tsModule = files.ts;
|
|
218
|
+
const tsModule = files.ts ?? files.js;
|
|
215
219
|
const component: PageComponent = tsModule
|
|
216
220
|
? (await this.core.loadModule<{ default: PageComponent }>(tsModule)).default
|
|
217
221
|
: defaultPageComponent;
|
|
218
222
|
|
|
219
|
-
const context = await this.core.buildComponentContext(routeInfo, route,
|
|
220
|
-
const data = await component.getData({ params: routeInfo.params, context });
|
|
223
|
+
const context = await this.core.buildComponentContext(routeInfo, route, signal, isLeaf);
|
|
224
|
+
const data = await component.getData({ params: routeInfo.params, signal, context });
|
|
221
225
|
const content = this.renderContent(component, { data, params: routeInfo.params, context });
|
|
222
226
|
const title = component.getTitle({ data, params: routeInfo.params, context });
|
|
223
227
|
|
|
@@ -239,19 +243,21 @@ export abstract class SsrRenderer {
|
|
|
239
243
|
return this.renderContent(component, { data, params: {}, context });
|
|
240
244
|
}
|
|
241
245
|
|
|
246
|
+
private static readonly EMPTY_URL = new URL('http://error');
|
|
247
|
+
|
|
242
248
|
/** Try to load and render an error boundary or handler module. Returns null on failure. */
|
|
243
249
|
private async tryRenderErrorModule(
|
|
244
250
|
modulePath: string,
|
|
245
|
-
|
|
251
|
+
url: URL,
|
|
246
252
|
kind: 'boundary' | 'handler',
|
|
247
253
|
): Promise<{ content: string; status: number } | null> {
|
|
248
254
|
try {
|
|
249
255
|
const module = await this.core.loadModule<{ default: PageComponent }>(modulePath);
|
|
250
256
|
const component = module.default;
|
|
251
257
|
const minCtx: ComponentContext = {
|
|
252
|
-
|
|
253
|
-
pattern: '',
|
|
258
|
+
url: SsrRenderer.EMPTY_URL,
|
|
254
259
|
params: {},
|
|
260
|
+
pathname: '',
|
|
255
261
|
searchParams: new URLSearchParams(),
|
|
256
262
|
};
|
|
257
263
|
const data = await component.getData({ params: {}, context: minCtx });
|
|
@@ -259,7 +265,7 @@ export abstract class SsrRenderer {
|
|
|
259
265
|
return { content, status: 500 };
|
|
260
266
|
} catch (e) {
|
|
261
267
|
logger.error(
|
|
262
|
-
`[${this.label}] Error ${kind} failed for ${pathname}`,
|
|
268
|
+
`[${this.label}] Error ${kind} failed for ${url.pathname}`,
|
|
263
269
|
e instanceof Error ? e : undefined,
|
|
264
270
|
);
|
|
265
271
|
return null;
|
|
@@ -268,9 +274,9 @@ export abstract class SsrRenderer {
|
|
|
268
274
|
|
|
269
275
|
protected abstract renderRedirect(to: string): string;
|
|
270
276
|
|
|
271
|
-
protected abstract renderStatusPage(status: number,
|
|
277
|
+
protected abstract renderStatusPage(status: number, url: URL): string;
|
|
272
278
|
|
|
273
|
-
protected abstract renderErrorPage(error: unknown,
|
|
279
|
+
protected abstract renderErrorPage(error: unknown, url: URL): string;
|
|
274
280
|
|
|
275
281
|
/** Inject child content into the slot owned by parentPattern. */
|
|
276
282
|
protected abstract injectSlot(parent: string, child: string, parentPattern: string): string;
|
package/src/route/route.core.ts
CHANGED
|
@@ -19,7 +19,6 @@ import type {
|
|
|
19
19
|
} from '../type/route.type.ts';
|
|
20
20
|
import type { ComponentContext, ContextProvider } from '../component/abstract.component.ts';
|
|
21
21
|
import type { RouteResolver, ResolvedRoute } from './route.resolver.ts';
|
|
22
|
-
import { toUrl } from './route.matcher.ts';
|
|
23
22
|
|
|
24
23
|
/** Base paths for the two SSR rendering endpoints. */
|
|
25
24
|
export interface BasePath {
|
|
@@ -27,10 +26,12 @@ export interface BasePath {
|
|
|
27
26
|
html: string;
|
|
28
27
|
/** Base path for SSR Markdown rendering (default: '/md') */
|
|
29
28
|
md: string;
|
|
29
|
+
/** Base path for PWA/SPA rendering (default: '/app') */
|
|
30
|
+
app: string;
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
/** Default base paths — backward compatible with existing /html/ and /md/ prefixes. */
|
|
33
|
-
export const DEFAULT_BASE_PATH: BasePath = { html: '/html', md: '/md' };
|
|
34
|
+
export const DEFAULT_BASE_PATH: BasePath = { html: '/html', md: '/md', app: '/app' };
|
|
34
35
|
|
|
35
36
|
const BLOCKED_PROTOCOLS = /^(javascript|data|vbscript):/i;
|
|
36
37
|
|
|
@@ -54,7 +55,7 @@ function toRouteConfig(resolved: ResolvedRoute): RouteConfig {
|
|
|
54
55
|
return {
|
|
55
56
|
pattern: resolved.pattern,
|
|
56
57
|
type: node.redirect ? 'redirect' : 'page',
|
|
57
|
-
modulePath: node.redirect ?? node.files?.ts ?? node.files?.html ?? node.files?.md ?? '',
|
|
58
|
+
modulePath: node.redirect ?? node.files?.ts ?? node.files?.js ?? node.files?.html ?? node.files?.md ?? '',
|
|
58
59
|
files: node.files,
|
|
59
60
|
};
|
|
60
61
|
}
|
|
@@ -69,8 +70,6 @@ export interface RouteCoreOptions {
|
|
|
69
70
|
fileReader?: (path: string) => Promise<string>;
|
|
70
71
|
/** Enriches every ComponentContext with app-level services before it reaches components. */
|
|
71
72
|
extendContext?: ContextProvider;
|
|
72
|
-
/** Base path stripped from URLs before matching (e.g. '/html'). No trailing slash. */
|
|
73
|
-
basePath?: string;
|
|
74
73
|
/** Module loaders keyed by path — server provides these for SSR imports. */
|
|
75
74
|
moduleLoaders?: Record<string, () => Promise<unknown>>;
|
|
76
75
|
}
|
|
@@ -82,8 +81,6 @@ export class RouteCore {
|
|
|
82
81
|
private readonly resolver: RouteResolver;
|
|
83
82
|
/** Registered context provider (if any). Exposed so renderers can apply it to inline contexts. */
|
|
84
83
|
readonly contextProvider: ContextProvider | undefined;
|
|
85
|
-
/** Base path for URL matching (e.g. '/html'). Empty string when no basePath. */
|
|
86
|
-
readonly basePath: string;
|
|
87
84
|
private listeners: Set<RouterEventListener> = new Set();
|
|
88
85
|
private moduleCache: Map<string, unknown> = new Map();
|
|
89
86
|
private widgetFileCache: Map<string, string> = new Map();
|
|
@@ -93,7 +90,6 @@ export class RouteCore {
|
|
|
93
90
|
|
|
94
91
|
constructor(resolver: RouteResolver, options: RouteCoreOptions = {}) {
|
|
95
92
|
this.resolver = resolver;
|
|
96
|
-
this.basePath = options.basePath ?? '';
|
|
97
93
|
this.readFile = options.fileReader ??
|
|
98
94
|
((path) => fetch(path, { headers: { Accept: 'text/plain' } }).then((r) => r.text()));
|
|
99
95
|
this.contextProvider = options.extendContext;
|
|
@@ -132,16 +128,14 @@ export class RouteCore {
|
|
|
132
128
|
* Match a URL to a route.
|
|
133
129
|
* Falls back to the default root route for '/'.
|
|
134
130
|
*/
|
|
135
|
-
match(url: URL
|
|
136
|
-
const
|
|
137
|
-
const pathname = urlObj.pathname;
|
|
131
|
+
match(url: URL): MatchedRoute | undefined {
|
|
132
|
+
const pathname = url.pathname;
|
|
138
133
|
|
|
139
134
|
const resolved = this.resolver.match(pathname);
|
|
140
135
|
if (resolved) {
|
|
141
136
|
return {
|
|
142
137
|
route: toRouteConfig(resolved),
|
|
143
138
|
params: resolved.params,
|
|
144
|
-
searchParams: urlObj.searchParams,
|
|
145
139
|
};
|
|
146
140
|
}
|
|
147
141
|
|
|
@@ -149,7 +143,6 @@ export class RouteCore {
|
|
|
149
143
|
return {
|
|
150
144
|
route: DEFAULT_ROOT_ROUTE,
|
|
151
145
|
params: {},
|
|
152
|
-
searchParams: urlObj.searchParams,
|
|
153
146
|
};
|
|
154
147
|
}
|
|
155
148
|
|
|
@@ -163,7 +156,7 @@ export class RouteCore {
|
|
|
163
156
|
return {
|
|
164
157
|
pattern: `/${status}`,
|
|
165
158
|
type: 'page',
|
|
166
|
-
modulePath: node.files?.ts ?? node.files?.html ?? node.files?.md ?? '',
|
|
159
|
+
modulePath: node.files?.ts ?? node.files?.js ?? node.files?.html ?? node.files?.md ?? '',
|
|
167
160
|
files: node.files,
|
|
168
161
|
};
|
|
169
162
|
}
|
|
@@ -196,7 +189,7 @@ export class RouteCore {
|
|
|
196
189
|
return {
|
|
197
190
|
pattern,
|
|
198
191
|
type: node.redirect ? 'redirect' : 'page',
|
|
199
|
-
modulePath: node.redirect ?? node.files?.ts ?? node.files?.html ?? node.files?.md ?? '',
|
|
192
|
+
modulePath: node.redirect ?? node.files?.ts ?? node.files?.js ?? node.files?.html ?? node.files?.md ?? '',
|
|
200
193
|
files: node.files,
|
|
201
194
|
};
|
|
202
195
|
}
|
|
@@ -301,17 +294,30 @@ export class RouteCore {
|
|
|
301
294
|
* Build a RouteInfo from a matched route and the resolved URL pathname.
|
|
302
295
|
* Called once per navigation; the result is reused across the route hierarchy.
|
|
303
296
|
*/
|
|
304
|
-
toRouteInfo(matched: MatchedRoute,
|
|
297
|
+
toRouteInfo(matched: MatchedRoute, url: URL): RouteInfo {
|
|
305
298
|
return {
|
|
306
|
-
|
|
307
|
-
pattern: matched.route.pattern,
|
|
299
|
+
url,
|
|
308
300
|
params: matched.params,
|
|
309
|
-
searchParams: matched.searchParams ?? new URLSearchParams(),
|
|
310
301
|
};
|
|
311
302
|
}
|
|
312
303
|
|
|
304
|
+
/**
|
|
305
|
+
* Get inlined `__files` from a cached module (merged module pattern).
|
|
306
|
+
* Returns undefined if the module isn't cached or has no __files.
|
|
307
|
+
*/
|
|
308
|
+
getModuleFiles(modulePath: string): { html?: string; md?: string; css?: string } | undefined {
|
|
309
|
+
const cached = this.moduleCache.get(modulePath);
|
|
310
|
+
if (!cached || typeof cached !== 'object') return undefined;
|
|
311
|
+
const files = (cached as Record<string, unknown>).__files;
|
|
312
|
+
if (!files || typeof files !== 'object') return undefined;
|
|
313
|
+
return files as { html?: string; md?: string; css?: string };
|
|
314
|
+
}
|
|
315
|
+
|
|
313
316
|
/**
|
|
314
317
|
* Build a ComponentContext by extending RouteInfo with loaded file contents.
|
|
318
|
+
*
|
|
319
|
+
* When the route module is a merged module (contains `__files`), uses
|
|
320
|
+
* inlined content directly. Otherwise falls back to reading companion files.
|
|
315
321
|
*/
|
|
316
322
|
async buildComponentContext(
|
|
317
323
|
routeInfo: RouteInfo,
|
|
@@ -319,22 +325,37 @@ export class RouteCore {
|
|
|
319
325
|
signal?: AbortSignal,
|
|
320
326
|
isLeaf?: boolean,
|
|
321
327
|
): Promise<ComponentContext> {
|
|
322
|
-
const fetchFile = (filePath: string): Promise<string> =>
|
|
323
|
-
this.readFile(this.toAbsolutePath(filePath));
|
|
324
|
-
|
|
325
328
|
const rf = route.files;
|
|
326
|
-
const
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
329
|
+
const modulePath = rf?.ts ?? rf?.js;
|
|
330
|
+
|
|
331
|
+
// Try inlined __files from merged module (already cached by loadRouteContent)
|
|
332
|
+
const inlined = modulePath ? this.getModuleFiles(modulePath) : undefined;
|
|
333
|
+
|
|
334
|
+
let html: string | undefined;
|
|
335
|
+
let md: string | undefined;
|
|
336
|
+
let css: string | undefined;
|
|
337
|
+
|
|
338
|
+
if (inlined) {
|
|
339
|
+
html = inlined.html;
|
|
340
|
+
md = inlined.md;
|
|
341
|
+
css = inlined.css;
|
|
342
|
+
} else {
|
|
343
|
+
const fetchFile = (filePath: string): Promise<string> =>
|
|
344
|
+
this.readFile(this.toAbsolutePath(filePath));
|
|
345
|
+
[html, md, css] = await Promise.all([
|
|
346
|
+
rf?.html ? fetchFile(rf.html) : undefined,
|
|
347
|
+
rf?.md ? fetchFile(rf.md) : undefined,
|
|
348
|
+
rf?.css ? fetchFile(rf.css) : undefined,
|
|
349
|
+
]);
|
|
350
|
+
}
|
|
331
351
|
|
|
332
352
|
const base: ComponentContext = {
|
|
333
353
|
...routeInfo,
|
|
354
|
+
pathname: routeInfo.url.pathname,
|
|
355
|
+
searchParams: routeInfo.url.searchParams,
|
|
334
356
|
files: { html, md, css },
|
|
335
357
|
signal,
|
|
336
358
|
isLeaf,
|
|
337
|
-
basePath: this.basePath || undefined,
|
|
338
359
|
};
|
|
339
360
|
return this.contextProvider ? this.contextProvider(base) : base;
|
|
340
361
|
}
|