@emkodev/emroute 1.0.3 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +28 -0
- package/README.md +147 -12
- package/package.json +48 -7
- package/runtime/abstract.runtime.ts +441 -0
- package/runtime/bun/esbuild-runtime-loader.plugin.ts +94 -0
- package/runtime/bun/fs/bun-fs.runtime.ts +245 -0
- package/runtime/bun/sqlite/bun-sqlite.runtime.ts +279 -0
- package/runtime/sitemap.generator.ts +180 -0
- package/server/codegen.util.ts +66 -0
- package/server/emroute.server.ts +398 -0
- package/server/esbuild-manifest.plugin.ts +243 -0
- package/server/scanner.util.ts +243 -0
- package/server/server-api.type.ts +90 -0
- package/src/component/abstract.component.ts +229 -0
- package/src/component/page.component.ts +134 -0
- package/src/component/widget.component.ts +85 -0
- package/src/element/component.element.ts +353 -0
- package/src/element/markdown.element.ts +107 -0
- package/src/element/slot.element.ts +31 -0
- package/src/index.ts +61 -0
- package/src/overlay/mod.ts +10 -0
- package/src/overlay/overlay.css.ts +170 -0
- package/src/overlay/overlay.service.ts +348 -0
- package/src/overlay/overlay.type.ts +38 -0
- package/src/renderer/spa/base.renderer.ts +186 -0
- package/src/renderer/spa/hash.renderer.ts +215 -0
- package/src/renderer/spa/html.renderer.ts +382 -0
- package/src/renderer/spa/mod.ts +76 -0
- package/src/renderer/ssr/html.renderer.ts +159 -0
- package/src/renderer/ssr/md.renderer.ts +142 -0
- package/src/renderer/ssr/ssr.renderer.ts +286 -0
- package/src/route/route.core.ts +316 -0
- package/src/route/route.matcher.ts +260 -0
- package/src/type/logger.type.ts +24 -0
- package/src/type/markdown.type.ts +21 -0
- package/src/type/navigation-api.d.ts +95 -0
- package/src/type/route.type.ts +149 -0
- package/src/type/widget.type.ts +65 -0
- package/src/util/html.util.ts +186 -0
- package/src/util/logger.util.ts +83 -0
- package/src/util/widget-resolve.util.ts +197 -0
- package/src/web-doc/index.md +15 -0
- package/src/widget/breadcrumb.widget.ts +106 -0
- package/src/widget/page-title.widget.ts +52 -0
- package/src/widget/widget.parser.ts +89 -0
- package/src/widget/widget.registry.ts +51 -0
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
/// <reference path="../../type/navigation-api.d.ts" />
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SPA HTML Renderer
|
|
5
|
+
*
|
|
6
|
+
* Browser-based Single Page Application renderer.
|
|
7
|
+
* Handles:
|
|
8
|
+
* - DOM manipulation (slot.innerHTML)
|
|
9
|
+
* - Navigation API (navigate event, intercept, scroll restoration)
|
|
10
|
+
* - Document title via component.getTitle()
|
|
11
|
+
* - View transitions
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type {
|
|
15
|
+
MatchedRoute,
|
|
16
|
+
NavigateOptions,
|
|
17
|
+
RedirectConfig,
|
|
18
|
+
RouteParams,
|
|
19
|
+
RouterState,
|
|
20
|
+
RoutesManifest,
|
|
21
|
+
} from '../../type/route.type.ts';
|
|
22
|
+
import type { ContextProvider } from '../../component/abstract.component.ts';
|
|
23
|
+
import type { PageComponent } from '../../component/page.component.ts';
|
|
24
|
+
import { ComponentElement } from '../../element/component.element.ts';
|
|
25
|
+
import {
|
|
26
|
+
assertSafeRedirect,
|
|
27
|
+
type BasePath,
|
|
28
|
+
DEFAULT_BASE_PATH,
|
|
29
|
+
RouteCore,
|
|
30
|
+
} from '../../route/route.core.ts';
|
|
31
|
+
import { escapeHtml, STATUS_MESSAGES } from '../../util/html.util.ts';
|
|
32
|
+
import { logger } from '../../util/logger.util.ts';
|
|
33
|
+
import { BaseRenderer } from './base.renderer.ts';
|
|
34
|
+
import type { RouteInfo } from '../../type/route.type.ts';
|
|
35
|
+
import defaultPageComponent from '../../component/page.component.ts';
|
|
36
|
+
|
|
37
|
+
/** Options for SPA HTML Router */
|
|
38
|
+
export interface SpaHtmlRouterOptions {
|
|
39
|
+
/** Enriches every ComponentContext with app-level services before it reaches components. */
|
|
40
|
+
extendContext?: ContextProvider;
|
|
41
|
+
/** Base paths for SSR endpoints. SPA uses html basePath for routing, md for passthrough. */
|
|
42
|
+
basePath?: BasePath;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* SPA Router for browser-based HTML rendering.
|
|
47
|
+
*/
|
|
48
|
+
export class SpaHtmlRouter extends BaseRenderer {
|
|
49
|
+
private abortController: AbortController | null = null;
|
|
50
|
+
/** Base paths for SSR endpoints. */
|
|
51
|
+
private htmlBase: string;
|
|
52
|
+
private mdBase: string;
|
|
53
|
+
|
|
54
|
+
constructor(manifest: RoutesManifest, options?: SpaHtmlRouterOptions) {
|
|
55
|
+
const bp = options?.basePath ?? DEFAULT_BASE_PATH;
|
|
56
|
+
const core = new RouteCore(manifest, {
|
|
57
|
+
extendContext: options?.extendContext,
|
|
58
|
+
basePath: bp.html,
|
|
59
|
+
});
|
|
60
|
+
super(core);
|
|
61
|
+
this.htmlBase = bp.html;
|
|
62
|
+
this.mdBase = bp.md;
|
|
63
|
+
if (options?.extendContext) {
|
|
64
|
+
ComponentElement.setContextProvider(options.extendContext);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Initialize router with slot element.
|
|
70
|
+
* Sets up Navigation API listener and performs initial navigation.
|
|
71
|
+
*/
|
|
72
|
+
async initialize(slotSelector = 'router-slot'): Promise<void> {
|
|
73
|
+
this.slot = document.querySelector(slotSelector);
|
|
74
|
+
|
|
75
|
+
if (!this.slot) {
|
|
76
|
+
console.error(`[Router] Slot not found: ${slotSelector}`);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Navigation API required for SPA routing — SSR handles browsers without it
|
|
81
|
+
if (!('navigation' in globalThis)) {
|
|
82
|
+
logger.info('init', 'Navigation API not available — using SSR full-page navigation');
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
this.abortController = new AbortController();
|
|
87
|
+
const { signal } = this.abortController;
|
|
88
|
+
|
|
89
|
+
// Single handler for ALL navigations: link clicks, back/forward,
|
|
90
|
+
// navigate() calls, form submissions. Replaces popstate + click listeners.
|
|
91
|
+
navigation.addEventListener('navigate', (event) => {
|
|
92
|
+
if (!event.canIntercept) return;
|
|
93
|
+
if (event.hashChange) return;
|
|
94
|
+
if (event.downloadRequest !== null) return;
|
|
95
|
+
|
|
96
|
+
const url = new URL(event.destination.url);
|
|
97
|
+
|
|
98
|
+
// /md/ paths pass through to server for full page load
|
|
99
|
+
if (url.pathname.startsWith(this.mdBase + '/') || url.pathname === this.mdBase) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
event.intercept({
|
|
104
|
+
scroll: 'manual',
|
|
105
|
+
handler: async () => {
|
|
106
|
+
await this.handleNavigation(
|
|
107
|
+
url.pathname + url.search + url.hash,
|
|
108
|
+
event.signal,
|
|
109
|
+
);
|
|
110
|
+
event.scroll();
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
}, { signal });
|
|
114
|
+
|
|
115
|
+
// Check for SSR content — skip initial render if route matches.
|
|
116
|
+
// data-ssr-route stores the full path including basePath (e.g. /html/about).
|
|
117
|
+
const ssrRoute = this.slot.getAttribute('data-ssr-route');
|
|
118
|
+
if (ssrRoute) {
|
|
119
|
+
const currentPath = location.pathname;
|
|
120
|
+
logger.ssr('check-adoption', `SSR route=${ssrRoute}, current=${currentPath}`);
|
|
121
|
+
|
|
122
|
+
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));
|
|
125
|
+
if (matched) {
|
|
126
|
+
logger.ssr('adopt', ssrRoute);
|
|
127
|
+
this.core.currentRoute = matched;
|
|
128
|
+
navigation.updateCurrentEntry({
|
|
129
|
+
state: { pathname: ssrRoute, params: matched.params } as RouterState,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
this.slot.removeAttribute('data-ssr-route');
|
|
133
|
+
return;
|
|
134
|
+
} else {
|
|
135
|
+
logger.ssr('mismatch', `Expected ${ssrRoute}, got ${currentPath}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// No SSR content or route mismatch — full client-side render
|
|
140
|
+
logger.info('init', `Initial navigation to ${location.pathname}`);
|
|
141
|
+
await this.handleNavigation(
|
|
142
|
+
location.pathname + location.search + location.hash,
|
|
143
|
+
this.abortController.signal,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Remove event listeners and release references.
|
|
149
|
+
*/
|
|
150
|
+
dispose(): void {
|
|
151
|
+
this.abortController?.abort();
|
|
152
|
+
this.abortController = null;
|
|
153
|
+
this.slot = null;
|
|
154
|
+
ComponentElement.setContextProvider(undefined);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Navigate to a new URL.
|
|
159
|
+
*/
|
|
160
|
+
async navigate(url: string, options: NavigateOptions = {}): Promise<void> {
|
|
161
|
+
const normalizedUrl = this.core.normalizeUrl(url);
|
|
162
|
+
try {
|
|
163
|
+
const { finished } = navigation.navigate(normalizedUrl, {
|
|
164
|
+
state: options.state,
|
|
165
|
+
history: options.replace ? 'replace' : 'auto',
|
|
166
|
+
});
|
|
167
|
+
await finished;
|
|
168
|
+
} catch (e) {
|
|
169
|
+
// Navigation interrupted (e.g. by a redirect) — not an error
|
|
170
|
+
if (e instanceof DOMException && e.name === 'AbortError') return;
|
|
171
|
+
throw e;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Add event listener for router events.
|
|
177
|
+
*/
|
|
178
|
+
addEventListener(
|
|
179
|
+
listener: Parameters<RouteCore['addEventListener']>[0],
|
|
180
|
+
): () => void {
|
|
181
|
+
return this.core.addEventListener(listener);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Get current route parameters.
|
|
186
|
+
*/
|
|
187
|
+
getParams(): RouteParams {
|
|
188
|
+
return this.core.getParams();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Get current matched route.
|
|
193
|
+
*/
|
|
194
|
+
getCurrentRoute(): MatchedRoute | null {
|
|
195
|
+
return this.core.currentRoute;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Handle navigation to a URL.
|
|
200
|
+
*
|
|
201
|
+
* Pure render function — URL updates and scroll restoration are handled
|
|
202
|
+
* by the Navigation API. Abort is signalled via the navigate event's signal.
|
|
203
|
+
*/
|
|
204
|
+
private async handleNavigation(
|
|
205
|
+
url: string,
|
|
206
|
+
signal: AbortSignal,
|
|
207
|
+
): Promise<void> {
|
|
208
|
+
const urlObj = new URL(url, location.origin);
|
|
209
|
+
const pathname = urlObj.pathname;
|
|
210
|
+
|
|
211
|
+
logger.nav('start', location.pathname, pathname);
|
|
212
|
+
|
|
213
|
+
// /md/ paths are handled server-side (initial load only — navigate handler filters these)
|
|
214
|
+
if (pathname.startsWith(this.mdBase + '/') || pathname === this.mdBase) {
|
|
215
|
+
logger.nav('redirect-md', pathname, pathname, { reason: 'server-side markdown' });
|
|
216
|
+
globalThis.location.href = url;
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
const matched = this.core.match(urlObj);
|
|
222
|
+
|
|
223
|
+
if (!matched) {
|
|
224
|
+
logger.nav('not-found', pathname, pathname);
|
|
225
|
+
await this.renderStatusPage(404, pathname);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
logger.nav('matched', pathname, matched.route.pattern, { params: matched.params });
|
|
230
|
+
|
|
231
|
+
// Handle redirect — starts a new navigation, aborting this one
|
|
232
|
+
if (matched.route.type === 'redirect') {
|
|
233
|
+
const module = await this.core.loadModule<{ default: RedirectConfig }>(
|
|
234
|
+
matched.route.modulePath,
|
|
235
|
+
);
|
|
236
|
+
if (signal.aborted) return;
|
|
237
|
+
assertSafeRedirect(module.default.to);
|
|
238
|
+
navigation.navigate(module.default.to, { history: 'replace' });
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Render page
|
|
243
|
+
this.core.currentRoute = matched;
|
|
244
|
+
const routeInfo = this.core.toRouteInfo(matched, pathname);
|
|
245
|
+
|
|
246
|
+
if (document.startViewTransition) {
|
|
247
|
+
const transition = document.startViewTransition(async () => {
|
|
248
|
+
await this.renderPage(routeInfo, matched, signal);
|
|
249
|
+
});
|
|
250
|
+
signal.addEventListener('abort', () => transition.skipTransition(), { once: true });
|
|
251
|
+
await transition.updateCallbackDone;
|
|
252
|
+
} else {
|
|
253
|
+
await this.renderPage(routeInfo, matched, signal);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (signal.aborted) return;
|
|
257
|
+
|
|
258
|
+
// Emit navigate event
|
|
259
|
+
this.core.emit({
|
|
260
|
+
type: 'navigate',
|
|
261
|
+
pathname,
|
|
262
|
+
params: matched.params,
|
|
263
|
+
});
|
|
264
|
+
} catch (error) {
|
|
265
|
+
if (signal.aborted) return;
|
|
266
|
+
if (error instanceof Response) {
|
|
267
|
+
await this.renderStatusPage(error.status, pathname);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
await this.handleError(error, pathname);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Render a status-specific page.
|
|
276
|
+
*/
|
|
277
|
+
private async renderStatusPage(
|
|
278
|
+
status: number,
|
|
279
|
+
pathname: string,
|
|
280
|
+
): Promise<void> {
|
|
281
|
+
if (!this.slot) return;
|
|
282
|
+
|
|
283
|
+
const statusPage = this.core.matcher.getStatusPage(status);
|
|
284
|
+
|
|
285
|
+
if (statusPage) {
|
|
286
|
+
try {
|
|
287
|
+
const component: PageComponent = statusPage.files?.ts
|
|
288
|
+
? (await this.core.loadModule<{ default: PageComponent }>(statusPage.files.ts)).default
|
|
289
|
+
: defaultPageComponent;
|
|
290
|
+
const ri: RouteInfo = {
|
|
291
|
+
pathname,
|
|
292
|
+
pattern: statusPage.pattern,
|
|
293
|
+
params: {},
|
|
294
|
+
searchParams: new URLSearchParams(),
|
|
295
|
+
};
|
|
296
|
+
const context = await this.core.buildComponentContext(ri, statusPage);
|
|
297
|
+
const data = await component.getData({ params: {}, context });
|
|
298
|
+
this.slot.setHTMLUnsafe(component.renderHTML({ data, params: {}, context }));
|
|
299
|
+
this.updateTitle();
|
|
300
|
+
return;
|
|
301
|
+
} catch (e) {
|
|
302
|
+
console.error(`[Router] Failed to render ${status} page:`, e);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
this.slot.setHTMLUnsafe(`
|
|
307
|
+
<h1>${STATUS_MESSAGES[status] ?? 'Error'}</h1>
|
|
308
|
+
<p>Path: ${escapeHtml(pathname)}</p>
|
|
309
|
+
`);
|
|
310
|
+
this.updateTitle();
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Try to load and render an error boundary or handler module into the slot. */
|
|
314
|
+
private async tryRenderErrorModule(modulePath: string): Promise<boolean> {
|
|
315
|
+
try {
|
|
316
|
+
const module = await this.core.loadModule<{ default: PageComponent }>(modulePath);
|
|
317
|
+
const component = module.default;
|
|
318
|
+
const minCtx = { pathname: '', pattern: '', params: {}, searchParams: new URLSearchParams() };
|
|
319
|
+
const data = await component.getData({ params: {}, context: minCtx });
|
|
320
|
+
const html = component.renderHTML({ data, params: {}, context: minCtx });
|
|
321
|
+
if (this.slot) {
|
|
322
|
+
this.slot.setHTMLUnsafe(html);
|
|
323
|
+
this.updateTitle();
|
|
324
|
+
}
|
|
325
|
+
return true;
|
|
326
|
+
} catch (e) {
|
|
327
|
+
console.error('[Router] Error module failed:', e);
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Handle errors during navigation/rendering.
|
|
334
|
+
*/
|
|
335
|
+
private async handleError(error: unknown, pathname: string): Promise<void> {
|
|
336
|
+
console.error('[Router] Navigation error:', error);
|
|
337
|
+
|
|
338
|
+
this.core.emit({
|
|
339
|
+
type: 'error',
|
|
340
|
+
pathname,
|
|
341
|
+
params: {},
|
|
342
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
const boundary = this.core.matcher.findErrorBoundary(pathname);
|
|
346
|
+
if (boundary && await this.tryRenderErrorModule(boundary.modulePath)) return;
|
|
347
|
+
|
|
348
|
+
const errorHandler = this.core.matcher.getErrorHandler();
|
|
349
|
+
if (errorHandler && await this.tryRenderErrorModule(errorHandler.modulePath)) return;
|
|
350
|
+
|
|
351
|
+
if (this.slot) {
|
|
352
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
353
|
+
this.slot.setHTMLUnsafe(`
|
|
354
|
+
<h1>Error</h1>
|
|
355
|
+
<p>${escapeHtml(message)}</p>
|
|
356
|
+
`);
|
|
357
|
+
this.updateTitle();
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Create and initialize SPA HTML router.
|
|
364
|
+
*
|
|
365
|
+
* The router instance is stored on `globalThis.__emroute_router` for
|
|
366
|
+
* programmatic access from consumer scripts (navigate, getParams, etc.).
|
|
367
|
+
* Calling this function twice returns the existing router with a warning.
|
|
368
|
+
*/
|
|
369
|
+
export async function createSpaHtmlRouter(
|
|
370
|
+
manifest: RoutesManifest,
|
|
371
|
+
options?: SpaHtmlRouterOptions,
|
|
372
|
+
): Promise<SpaHtmlRouter> {
|
|
373
|
+
const g = globalThis as Record<string, unknown>;
|
|
374
|
+
if (g.__emroute_router) {
|
|
375
|
+
console.warn('eMroute: SPA router already initialized. Remove duplicate <script> tags.');
|
|
376
|
+
return g.__emroute_router as SpaHtmlRouter;
|
|
377
|
+
}
|
|
378
|
+
const router = new SpaHtmlRouter(manifest, options);
|
|
379
|
+
await router.initialize();
|
|
380
|
+
g.__emroute_router = router;
|
|
381
|
+
return router;
|
|
382
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SPA (Browser) Module
|
|
3
|
+
*
|
|
4
|
+
* Everything needed for the browser bundle:
|
|
5
|
+
* - SPA router with client-side navigation
|
|
6
|
+
* - Custom elements for rendering and hydrating SSR islands
|
|
7
|
+
* - Widget registry (built-in widgets are opt-in)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { RouterSlot } from '../../element/slot.element.ts';
|
|
11
|
+
import { MarkdownElement } from '../../element/markdown.element.ts';
|
|
12
|
+
import { ComponentElement } from '../../element/component.element.ts';
|
|
13
|
+
import { WidgetRegistry } from '../../widget/widget.registry.ts';
|
|
14
|
+
|
|
15
|
+
export { createSpaHtmlRouter, SpaHtmlRouter, type SpaHtmlRouterOptions } from './html.renderer.ts';
|
|
16
|
+
export {
|
|
17
|
+
createHashRouter,
|
|
18
|
+
type HashRouteConfig,
|
|
19
|
+
HashRouter,
|
|
20
|
+
type HashRouterOptions,
|
|
21
|
+
} from './hash.renderer.ts';
|
|
22
|
+
export { ComponentElement, MarkdownElement, RouterSlot, WidgetRegistry };
|
|
23
|
+
export type { SpaMode, WidgetsManifest } from '../../type/widget.type.ts';
|
|
24
|
+
|
|
25
|
+
// Re-export base classes and types for consumer code (pages, widgets)
|
|
26
|
+
export { PageComponent } from '../../component/page.component.ts';
|
|
27
|
+
export { WidgetComponent } from '../../component/widget.component.ts';
|
|
28
|
+
export {
|
|
29
|
+
Component,
|
|
30
|
+
type ComponentContext,
|
|
31
|
+
type ComponentManifestEntry,
|
|
32
|
+
type ContextProvider,
|
|
33
|
+
type RenderContext,
|
|
34
|
+
} from '../../component/abstract.component.ts';
|
|
35
|
+
export type {
|
|
36
|
+
MatchedRoute,
|
|
37
|
+
NavigateOptions,
|
|
38
|
+
RouteParams,
|
|
39
|
+
RouterEvent,
|
|
40
|
+
RouterEventListener,
|
|
41
|
+
RouterEventType,
|
|
42
|
+
RoutesManifest,
|
|
43
|
+
} from '../../type/route.type.ts';
|
|
44
|
+
export type { MarkdownRenderer } from '../../type/markdown.type.ts';
|
|
45
|
+
export { type BasePath, DEFAULT_BASE_PATH } from '../../route/route.core.ts';
|
|
46
|
+
export { escapeHtml, scopeWidgetCss } from '../../util/html.util.ts';
|
|
47
|
+
export type {
|
|
48
|
+
ErrorBoundary,
|
|
49
|
+
RedirectConfig,
|
|
50
|
+
RouteConfig,
|
|
51
|
+
RouteFiles,
|
|
52
|
+
RouteFileType,
|
|
53
|
+
RouteInfo,
|
|
54
|
+
RouterState,
|
|
55
|
+
} from '../../type/route.type.ts';
|
|
56
|
+
export type { ParsedWidgetBlock, WidgetManifestEntry } from '../../type/widget.type.ts';
|
|
57
|
+
export { type Logger, setLogger } from '../../type/logger.type.ts';
|
|
58
|
+
|
|
59
|
+
// Register core custom elements in the browser
|
|
60
|
+
if (globalThis.customElements) {
|
|
61
|
+
if (!customElements.get('router-slot')) customElements.define('router-slot', RouterSlot);
|
|
62
|
+
if (!customElements.get('mark-down')) customElements.define('mark-down', MarkdownElement);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Overlay API (tree-shakeable - only bundled if imported)
|
|
66
|
+
export { createOverlayService } from '../../overlay/overlay.service.ts';
|
|
67
|
+
export type {
|
|
68
|
+
ModalOptions,
|
|
69
|
+
OverlayService,
|
|
70
|
+
PopoverOptions,
|
|
71
|
+
ToastOptions,
|
|
72
|
+
} from '../../overlay/overlay.type.ts';
|
|
73
|
+
|
|
74
|
+
// Optional: Built-in widgets (tree-shakeable - only bundled if imported)
|
|
75
|
+
export { PageTitleWidget } from '../../widget/page-title.widget.ts';
|
|
76
|
+
export { BreadcrumbWidget } from '../../widget/breadcrumb.widget.ts';
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSR HTML Renderer
|
|
3
|
+
*
|
|
4
|
+
* Server-side HTML rendering.
|
|
5
|
+
* Generates complete HTML strings without DOM manipulation.
|
|
6
|
+
* Expands <mark-down> tags server-side when a markdown renderer is provided.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { RouteConfig, RouteInfo, RoutesManifest } from '../../type/route.type.ts';
|
|
10
|
+
import type { MarkdownRenderer } from '../../type/markdown.type.ts';
|
|
11
|
+
import type { PageComponent } from '../../component/page.component.ts';
|
|
12
|
+
import { DEFAULT_ROOT_ROUTE } from '../../route/route.core.ts';
|
|
13
|
+
import { escapeHtml, STATUS_MESSAGES, unescapeHtml } from '../../util/html.util.ts';
|
|
14
|
+
import { resolveWidgetTags } from '../../util/widget-resolve.util.ts';
|
|
15
|
+
import { SsrRenderer, type SsrRendererOptions } from './ssr.renderer.ts';
|
|
16
|
+
|
|
17
|
+
/** Options for SSR HTML Router */
|
|
18
|
+
export interface SsrHtmlRouterOptions extends SsrRendererOptions {
|
|
19
|
+
/** Markdown renderer for server-side <mark-down> expansion */
|
|
20
|
+
markdownRenderer?: MarkdownRenderer;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* SSR HTML Router for server-side rendering.
|
|
25
|
+
*/
|
|
26
|
+
export class SsrHtmlRouter extends SsrRenderer {
|
|
27
|
+
protected override readonly label = 'SSR HTML';
|
|
28
|
+
private markdownRenderer: MarkdownRenderer | null;
|
|
29
|
+
private markdownReady: Promise<void> | null = null;
|
|
30
|
+
|
|
31
|
+
constructor(manifest: RoutesManifest, options: SsrHtmlRouterOptions = {}) {
|
|
32
|
+
super(manifest, options);
|
|
33
|
+
this.markdownRenderer = options.markdownRenderer ?? null;
|
|
34
|
+
|
|
35
|
+
if (this.markdownRenderer?.init) {
|
|
36
|
+
this.markdownReady = this.markdownRenderer.init();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
protected override injectSlot(parent: string, child: string, parentPattern: string): string {
|
|
41
|
+
const escaped = parentPattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
42
|
+
return parent.replace(
|
|
43
|
+
new RegExp(`<router-slot\\b[^>]*\\bpattern="${escaped}"[^>]*></router-slot>`),
|
|
44
|
+
child,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
protected override stripSlots(result: string): string {
|
|
49
|
+
return result.replace(/<router-slot[^>]*><\/router-slot>/g, '');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Render a single route's content.
|
|
54
|
+
*/
|
|
55
|
+
protected override async renderRouteContent(
|
|
56
|
+
routeInfo: RouteInfo,
|
|
57
|
+
route: RouteConfig,
|
|
58
|
+
isLeaf?: boolean,
|
|
59
|
+
): Promise<{ content: string; title?: string }> {
|
|
60
|
+
if (route.modulePath === DEFAULT_ROOT_ROUTE.modulePath) {
|
|
61
|
+
return { content: `<router-slot pattern="${route.pattern}"></router-slot>` };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const { content: rawContent, title } = await this.loadRouteContent(routeInfo, route, isLeaf);
|
|
65
|
+
let content = rawContent;
|
|
66
|
+
|
|
67
|
+
// Expand <mark-down> tags server-side
|
|
68
|
+
content = await this.expandMarkdown(content);
|
|
69
|
+
|
|
70
|
+
// Attribute bare <router-slot> tags with this route's pattern (before widget
|
|
71
|
+
// resolution so widget-internal slots inside <template> are not affected)
|
|
72
|
+
content = this.attributeSlots(content, route.pattern);
|
|
73
|
+
|
|
74
|
+
// Resolve <widget-*> tags: call getData() + renderHTML(), inject ssr attribute
|
|
75
|
+
if (this.widgets) {
|
|
76
|
+
content = await resolveWidgetTags(
|
|
77
|
+
content,
|
|
78
|
+
this.widgets,
|
|
79
|
+
routeInfo,
|
|
80
|
+
(name, declared) => {
|
|
81
|
+
const files = this.widgetFiles[name] ?? declared;
|
|
82
|
+
return files ? this.core.loadWidgetFiles(files) : Promise.resolve({});
|
|
83
|
+
},
|
|
84
|
+
this.core.contextProvider,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return { content, title };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
protected override renderContent(
|
|
92
|
+
component: PageComponent,
|
|
93
|
+
args: PageComponent['RenderArgs'],
|
|
94
|
+
): string {
|
|
95
|
+
return component.renderHTML(args);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
protected override renderRedirect(to: string): string {
|
|
99
|
+
return `<meta http-equiv="refresh" content="0;url=${escapeHtml(to)}">`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
protected override renderStatusPage(status: number, pathname: string): string {
|
|
103
|
+
return `
|
|
104
|
+
<h1>${STATUS_MESSAGES[status] ?? 'Error'}</h1>
|
|
105
|
+
<p>Path: ${escapeHtml(pathname)}</p>
|
|
106
|
+
`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
protected override renderErrorPage(error: unknown, pathname: string): string {
|
|
110
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
111
|
+
return `
|
|
112
|
+
<h1>Error</h1>
|
|
113
|
+
<p>Path: ${escapeHtml(pathname)}</p>
|
|
114
|
+
<p>${escapeHtml(message)}</p>
|
|
115
|
+
`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Add pattern attribute to bare <router-slot> tags. */
|
|
119
|
+
private attributeSlots(content: string, routePattern: string): string {
|
|
120
|
+
return content.replace(
|
|
121
|
+
/<router-slot(?![^>]*\bpattern=)([^>]*)><\/router-slot>/g,
|
|
122
|
+
`<router-slot pattern="${routePattern}"$1></router-slot>`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Expand <mark-down> tags by rendering markdown to HTML server-side.
|
|
128
|
+
* Leaves content unchanged if no markdown renderer is configured.
|
|
129
|
+
*/
|
|
130
|
+
private async expandMarkdown(content: string): Promise<string> {
|
|
131
|
+
if (!this.markdownRenderer) return content;
|
|
132
|
+
if (!content.includes('<mark-down>')) return content;
|
|
133
|
+
|
|
134
|
+
if (this.markdownReady) {
|
|
135
|
+
await this.markdownReady;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const renderer = this.markdownRenderer;
|
|
139
|
+
|
|
140
|
+
// Match <mark-down>escaped content</mark-down>
|
|
141
|
+
const pattern = /<mark-down>([\s\S]*?)<\/mark-down>/g;
|
|
142
|
+
|
|
143
|
+
return content.replace(pattern, (_match, escaped: string) => {
|
|
144
|
+
const markdown = unescapeHtml(escaped);
|
|
145
|
+
const rendered = renderer.render(markdown);
|
|
146
|
+
return rendered;
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Create SSR HTML router.
|
|
153
|
+
*/
|
|
154
|
+
export function createSsrHtmlRouter(
|
|
155
|
+
manifest: RoutesManifest,
|
|
156
|
+
options?: SsrHtmlRouterOptions,
|
|
157
|
+
): SsrHtmlRouter {
|
|
158
|
+
return new SsrHtmlRouter(manifest, options);
|
|
159
|
+
}
|