@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.
Files changed (97) hide show
  1. package/dist/emroute.js +2757 -0
  2. package/dist/emroute.js.map +7 -0
  3. package/dist/runtime/abstract.runtime.d.ts +0 -28
  4. package/dist/runtime/abstract.runtime.js +10 -58
  5. package/dist/runtime/abstract.runtime.js.map +1 -1
  6. package/dist/runtime/bun/esbuild-runtime-loader.plugin.js +3 -0
  7. package/dist/runtime/bun/esbuild-runtime-loader.plugin.js.map +1 -1
  8. package/dist/runtime/bun/fs/bun-fs.runtime.d.ts +0 -5
  9. package/dist/runtime/bun/fs/bun-fs.runtime.js +1 -95
  10. package/dist/runtime/bun/fs/bun-fs.runtime.js.map +1 -1
  11. package/dist/runtime/bun/sqlite/bun-sqlite.runtime.d.ts +0 -5
  12. package/dist/runtime/bun/sqlite/bun-sqlite.runtime.js +2 -96
  13. package/dist/runtime/bun/sqlite/bun-sqlite.runtime.js.map +1 -1
  14. package/dist/runtime/fetch.runtime.d.ts +26 -0
  15. package/dist/runtime/fetch.runtime.js +55 -0
  16. package/dist/runtime/fetch.runtime.js.map +1 -0
  17. package/dist/runtime/sitemap.generator.d.ts +4 -4
  18. package/dist/runtime/sitemap.generator.js +32 -11
  19. package/dist/runtime/sitemap.generator.js.map +1 -1
  20. package/dist/runtime/universal/fs/universal-fs.runtime.d.ts +0 -5
  21. package/dist/runtime/universal/fs/universal-fs.runtime.js +1 -95
  22. package/dist/runtime/universal/fs/universal-fs.runtime.js.map +1 -1
  23. package/dist/server/build.util.d.ts +38 -0
  24. package/dist/server/build.util.js +133 -0
  25. package/dist/server/build.util.js.map +1 -0
  26. package/dist/server/codegen.util.d.ts +3 -0
  27. package/dist/server/codegen.util.js +28 -10
  28. package/dist/server/codegen.util.js.map +1 -1
  29. package/dist/server/emroute.server.js +53 -29
  30. package/dist/server/emroute.server.js.map +1 -1
  31. package/dist/server/esbuild-manifest.plugin.js +6 -4
  32. package/dist/server/esbuild-manifest.plugin.js.map +1 -1
  33. package/dist/server/server-api.type.d.ts +6 -0
  34. package/dist/src/component/abstract.component.d.ts +5 -3
  35. package/dist/src/component/abstract.component.js.map +1 -1
  36. package/dist/src/element/component.element.js +5 -4
  37. package/dist/src/element/component.element.js.map +1 -1
  38. package/dist/src/renderer/spa/mod.d.ts +2 -3
  39. package/dist/src/renderer/spa/mod.js +2 -3
  40. package/dist/src/renderer/spa/mod.js.map +1 -1
  41. package/dist/src/renderer/spa/thin-client.d.ts +34 -0
  42. package/dist/src/renderer/spa/thin-client.js +138 -0
  43. package/dist/src/renderer/spa/thin-client.js.map +1 -0
  44. package/dist/src/renderer/ssr/html.renderer.d.ts +3 -3
  45. package/dist/src/renderer/ssr/html.renderer.js +6 -6
  46. package/dist/src/renderer/ssr/html.renderer.js.map +1 -1
  47. package/dist/src/renderer/ssr/md.renderer.d.ts +3 -3
  48. package/dist/src/renderer/ssr/md.renderer.js +12 -7
  49. package/dist/src/renderer/ssr/md.renderer.js.map +1 -1
  50. package/dist/src/renderer/ssr/ssr.renderer.d.ts +7 -6
  51. package/dist/src/renderer/ssr/ssr.renderer.js +42 -44
  52. package/dist/src/renderer/ssr/ssr.renderer.js.map +1 -1
  53. package/dist/src/route/route.core.d.ts +16 -6
  54. package/dist/src/route/route.core.js +44 -23
  55. package/dist/src/route/route.core.js.map +1 -1
  56. package/dist/src/type/route-tree.type.d.ts +2 -0
  57. package/dist/src/type/route.type.d.ts +6 -24
  58. package/dist/src/util/md.util.d.ts +8 -0
  59. package/dist/src/util/md.util.js +28 -0
  60. package/dist/src/util/md.util.js.map +1 -0
  61. package/dist/src/util/widget-resolve.util.js +6 -1
  62. package/dist/src/util/widget-resolve.util.js.map +1 -1
  63. package/dist/src/widget/breadcrumb.widget.d.ts +0 -1
  64. package/dist/src/widget/breadcrumb.widget.js +4 -15
  65. package/dist/src/widget/breadcrumb.widget.js.map +1 -1
  66. package/package.json +13 -2
  67. package/runtime/abstract.runtime.ts +9 -82
  68. package/runtime/bun/esbuild-runtime-loader.plugin.ts +2 -0
  69. package/runtime/bun/fs/bun-fs.runtime.ts +0 -109
  70. package/runtime/bun/sqlite/bun-sqlite.runtime.ts +1 -112
  71. package/runtime/fetch.runtime.ts +70 -0
  72. package/runtime/sitemap.generator.ts +37 -12
  73. package/runtime/universal/fs/universal-fs.runtime.ts +0 -109
  74. package/server/build.util.ts +168 -0
  75. package/server/codegen.util.ts +29 -11
  76. package/server/emroute.server.ts +50 -30
  77. package/server/esbuild-manifest.plugin.ts +5 -3
  78. package/server/server-api.type.ts +7 -0
  79. package/src/component/abstract.component.ts +5 -3
  80. package/src/element/component.element.ts +5 -4
  81. package/src/renderer/spa/mod.ts +2 -8
  82. package/src/renderer/spa/thin-client.ts +165 -0
  83. package/src/renderer/ssr/html.renderer.ts +6 -5
  84. package/src/renderer/ssr/md.renderer.ts +12 -6
  85. package/src/renderer/ssr/ssr.renderer.ts +54 -48
  86. package/src/route/route.core.ts +49 -28
  87. package/src/type/route-tree.type.ts +2 -0
  88. package/src/type/route.type.ts +7 -32
  89. package/src/util/md.util.ts +31 -0
  90. package/src/util/widget-resolve.util.ts +6 -1
  91. package/src/widget/breadcrumb.widget.ts +4 -16
  92. package/server/scanner.util.ts +0 -243
  93. package/src/renderer/spa/base.renderer.ts +0 -186
  94. package/src/renderer/spa/hash.renderer.ts +0 -238
  95. package/src/renderer/spa/html.renderer.ts +0 -399
  96. package/src/route/route.matcher.ts +0 -260
  97. package/src/web-doc/index.md +0 -15
@@ -1,238 +0,0 @@
1
- /**
2
- * Hash Router
3
- *
4
- * Lightweight client-side router for leaf mode mini-apps.
5
- * Uses hashchange events + RouteCore pattern matching.
6
- *
7
- * Routes are defined inline by the consumer, not from the main manifest.
8
- * Coexists with SpaHtmlRouter in root mode (SPA router skips hash changes).
9
- */
10
-
11
- import type { RouteNode } from '../../type/route-tree.type.ts';
12
- import type { ContextProvider } from '../../component/abstract.component.ts';
13
- import type { RouteResolver } from '../../route/route.resolver.ts';
14
- import { RouteCore } from '../../route/route.core.ts';
15
- import { RouteTrie } from '../../route/route.trie.ts';
16
- import { escapeHtml } from '../../util/html.util.ts';
17
- import { logger } from '../../util/logger.util.ts';
18
- import { BaseRenderer } from './base.renderer.ts';
19
-
20
- /**
21
- * A single hash route definition with a lazy module loader.
22
- * @experimental
23
- */
24
- export interface HashRouteConfig {
25
- /** URLPattern pathname pattern (e.g. '/settings', '/users/:id'). */
26
- pattern: string;
27
- /** Lazy loader returning a module with a default PageComponent export. */
28
- loader: () => Promise<unknown>;
29
- }
30
-
31
- /**
32
- * Options for creating a HashRouter.
33
- * @experimental
34
- */
35
- export interface HashRouterOptions {
36
- /** Inline route definitions. */
37
- routes: HashRouteConfig[];
38
- /** CSS selector or element to render into. Defaults to 'hash-slot'. */
39
- slot?: string | Element;
40
- /** Enriches every ComponentContext with app-level services. */
41
- extendContext?: ContextProvider;
42
- }
43
-
44
- /**
45
- * Build a RouteNode tree and module loaders from inline hash route definitions.
46
- * Each route's pattern is used as the moduleLoaders key so RouteCore.loadModule works.
47
- */
48
- function buildRouteTree(routes: HashRouteConfig[]): {
49
- resolver: RouteResolver;
50
- moduleLoaders: Record<string, () => Promise<unknown>>;
51
- } {
52
- const root: RouteNode = {};
53
- const moduleLoaders: Record<string, () => Promise<unknown>> = {};
54
-
55
- for (const r of routes) {
56
- const segments = r.pattern.split('/').filter(Boolean);
57
- let node = root;
58
-
59
- for (const segment of segments) {
60
- if (segment.startsWith(':')) {
61
- const param = segment.endsWith('*') ? segment.slice(1, -1) : segment.slice(1);
62
- if (segment.endsWith('*')) {
63
- node.wildcard ??= { param, child: {} };
64
- node = node.wildcard.child;
65
- } else {
66
- node.dynamic ??= { param, child: {} };
67
- node = node.dynamic.child;
68
- }
69
- } else {
70
- node.children ??= {};
71
- node.children[segment] ??= {};
72
- node = node.children[segment];
73
- }
74
- }
75
-
76
- node.files = { ts: r.pattern };
77
- moduleLoaders[r.pattern] = r.loader;
78
- }
79
-
80
- return { resolver: new RouteTrie(root), moduleLoaders };
81
- }
82
-
83
- /**
84
- * Hash-based mini-app router for leaf mode pages.
85
- *
86
- * Listens to `hashchange`, maps `#/path` → pattern match via RouteCore,
87
- * renders matched PageComponent into a slot element.
88
- *
89
- * @experimental
90
- */
91
- export class HashRouter extends BaseRenderer {
92
- private boundHandler: (() => void) | null = null;
93
-
94
- constructor(resolver: RouteResolver, options?: { extendContext?: ContextProvider; moduleLoaders?: Record<string, () => Promise<unknown>> }) {
95
- const core = new RouteCore(resolver, {
96
- extendContext: options?.extendContext,
97
- moduleLoaders: options?.moduleLoaders,
98
- });
99
- super(core);
100
- }
101
-
102
- /**
103
- * Initialize: find slot, attach hashchange listener, render initial hash.
104
- */
105
- async initialize(slot: string | Element = 'hash-slot'): Promise<void> {
106
- this.slot = typeof slot === 'string' ? document.querySelector(slot) : slot;
107
-
108
- if (!this.slot) {
109
- console.error(`[HashRouter] Slot not found: ${slot}`);
110
- return;
111
- }
112
-
113
- this.boundHandler = () => {
114
- this.handleHashChange();
115
- };
116
- globalThis.addEventListener('hashchange', this.boundHandler);
117
-
118
- // Render initial hash if present
119
- if (location.hash.length > 1) {
120
- await this.handleHashChange();
121
- }
122
- }
123
-
124
- /**
125
- * Navigate to a hash path. Triggers hashchange → render.
126
- */
127
- navigate(hash: string): void {
128
- location.hash = hash.startsWith('#') ? hash : '#' + hash;
129
- }
130
-
131
- /**
132
- * Add event listener for router events.
133
- */
134
- addEventListener(
135
- listener: Parameters<RouteCore['addEventListener']>[0],
136
- ): () => void {
137
- return this.core.addEventListener(listener);
138
- }
139
-
140
- /**
141
- * Remove event listeners and release references.
142
- */
143
- dispose(): void {
144
- if (this.boundHandler) {
145
- globalThis.removeEventListener('hashchange', this.boundHandler);
146
- this.boundHandler = null;
147
- }
148
- this.slot = null;
149
- }
150
-
151
- /**
152
- * Handle a hashchange event: parse hash, match, render.
153
- */
154
- private async handleHashChange(): Promise<void> {
155
- const path = location.hash.slice(1) || '/';
156
- const matchUrl = new URL(path, location.origin);
157
-
158
- logger.nav('hash', path, path);
159
-
160
- const controller = new AbortController();
161
- const { signal } = controller;
162
-
163
- try {
164
- const matched = this.core.match(matchUrl);
165
-
166
- if (!matched) {
167
- logger.nav('hash-not-found', path, path);
168
- if (this.slot) {
169
- this.slot.setHTMLUnsafe(`
170
- <h1>Not Found</h1>
171
- <p>Path: ${escapeHtml(path)}</p>
172
- `);
173
- }
174
- return;
175
- }
176
-
177
- logger.nav('hash-matched', path, matched.route.pattern, { params: matched.params });
178
-
179
- this.core.currentRoute = matched;
180
- const routeInfo = this.core.toRouteInfo(matched, path);
181
-
182
- await this.renderPage(routeInfo, matched, signal);
183
-
184
- if (signal.aborted) return;
185
-
186
- this.core.emit({
187
- type: 'navigate',
188
- pathname: path,
189
- params: matched.params,
190
- });
191
- } catch (error) {
192
- if (signal.aborted) return;
193
- console.error('[HashRouter] Render error:', error);
194
-
195
- this.core.emit({
196
- type: 'error',
197
- pathname: path,
198
- params: {},
199
- error: error instanceof Error ? error : new Error(String(error)),
200
- });
201
-
202
- if (this.slot) {
203
- const message = error instanceof Error ? error.message : String(error);
204
- this.slot.setHTMLUnsafe(`
205
- <h1>Error</h1>
206
- <p>${escapeHtml(message)}</p>
207
- `);
208
- }
209
- }
210
- }
211
- }
212
-
213
- /**
214
- * Create and initialize a hash router for a leaf-mode mini-app.
215
- *
216
- * The router instance is stored on `globalThis.__emroute_hash_router` for
217
- * programmatic access. Calling twice returns the existing router with a warning.
218
- *
219
- * @experimental
220
- */
221
- export async function createHashRouter(
222
- options: HashRouterOptions,
223
- ): Promise<HashRouter> {
224
- const g = globalThis as Record<string, unknown>;
225
- if (g.__emroute_hash_router) {
226
- console.warn('eMroute: Hash router already initialized.');
227
- return g.__emroute_hash_router as HashRouter;
228
- }
229
-
230
- const { resolver, moduleLoaders } = buildRouteTree(options.routes);
231
- const router = new HashRouter(resolver, {
232
- extendContext: options.extendContext,
233
- moduleLoaders,
234
- });
235
- await router.initialize(options.slot ?? 'hash-slot');
236
- g.__emroute_hash_router = router;
237
- return router;
238
- }
@@ -1,399 +0,0 @@
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
- } from '../../type/route.type.ts';
21
- import type { ContextProvider } from '../../component/abstract.component.ts';
22
- import type { PageComponent } from '../../component/page.component.ts';
23
- import type { RouteResolver } from '../../route/route.resolver.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
- /** Pre-bundled module loaders keyed by file path. Bridges JSON route tree → bundled code in the browser. */
44
- moduleLoaders?: Record<string, () => Promise<unknown>>;
45
- }
46
-
47
- /**
48
- * SPA Router for browser-based HTML rendering.
49
- */
50
- export class SpaHtmlRouter extends BaseRenderer {
51
- private abortController: AbortController | null = null;
52
- /** Base paths for SSR endpoints. */
53
- private htmlBase: string;
54
- private mdBase: string;
55
-
56
- constructor(resolver: RouteResolver, options?: SpaHtmlRouterOptions) {
57
- const bp = options?.basePath ?? DEFAULT_BASE_PATH;
58
- const core = new RouteCore(resolver, {
59
- extendContext: options?.extendContext,
60
- basePath: bp.html,
61
- moduleLoaders: options?.moduleLoaders,
62
- });
63
- super(core);
64
- this.htmlBase = bp.html;
65
- this.mdBase = bp.md;
66
- if (options?.extendContext) {
67
- ComponentElement.setContextProvider(options.extendContext);
68
- }
69
- }
70
-
71
- /**
72
- * Initialize router with slot element.
73
- * Sets up Navigation API listener and performs initial navigation.
74
- */
75
- async initialize(slotSelector = 'router-slot'): Promise<void> {
76
- this.slot = document.querySelector(slotSelector);
77
-
78
- if (!this.slot) {
79
- console.error(`[Router] Slot not found: ${slotSelector}`);
80
- return;
81
- }
82
-
83
- // Navigation API required for SPA routing — SSR handles browsers without it
84
- if (!('navigation' in globalThis)) {
85
- logger.info('init', 'Navigation API not available — using SSR full-page navigation');
86
- return;
87
- }
88
-
89
- this.abortController = new AbortController();
90
- const { signal } = this.abortController;
91
-
92
- // Single handler for ALL navigations: link clicks, back/forward,
93
- // navigate() calls, form submissions. Replaces popstate + click listeners.
94
- navigation.addEventListener('navigate', (event) => {
95
- if (!event.canIntercept) return;
96
- if (event.hashChange) return;
97
- if (event.downloadRequest !== null) return;
98
-
99
- const url = new URL(event.destination.url);
100
-
101
- // /md/ paths pass through to server for full page load
102
- if (url.pathname.startsWith(this.mdBase + '/') || url.pathname === this.mdBase) {
103
- return;
104
- }
105
-
106
- event.intercept({
107
- scroll: 'manual',
108
- handler: async () => {
109
- await this.handleNavigation(
110
- url.pathname + url.search + url.hash,
111
- event.signal,
112
- );
113
- event.scroll();
114
- },
115
- });
116
- }, { signal });
117
-
118
- // Check for SSR content — skip initial render if route matches.
119
- // data-ssr-route stores the full path including basePath (e.g. /html/about).
120
- const ssrRoute = this.slot.getAttribute('data-ssr-route');
121
- if (ssrRoute) {
122
- const currentPath = location.pathname;
123
- logger.ssr('check-adoption', `SSR route=${ssrRoute}, current=${currentPath}`);
124
-
125
- if (currentPath === ssrRoute || currentPath === ssrRoute + '/') {
126
- // Adopt SSR content — strip basePath before matching unprefixed trie
127
- const matched = this.core.match(new URL(this.stripBase(ssrRoute), location.origin));
128
- if (matched) {
129
- logger.ssr('adopt', ssrRoute);
130
- this.core.currentRoute = matched;
131
- navigation.updateCurrentEntry({
132
- state: { pathname: ssrRoute, params: matched.params } as RouterState,
133
- });
134
- }
135
- this.slot.removeAttribute('data-ssr-route');
136
- return;
137
- } else {
138
- logger.ssr('mismatch', `Expected ${ssrRoute}, got ${currentPath}`);
139
- }
140
- }
141
-
142
- // No SSR content or route mismatch — full client-side render
143
- logger.info('init', `Initial navigation to ${location.pathname}`);
144
- await this.handleNavigation(
145
- location.pathname + location.search + location.hash,
146
- this.abortController.signal,
147
- );
148
- }
149
-
150
- /**
151
- * Remove event listeners and release references.
152
- */
153
- dispose(): void {
154
- this.abortController?.abort();
155
- this.abortController = null;
156
- this.slot = null;
157
- ComponentElement.setContextProvider(undefined);
158
- }
159
-
160
- /**
161
- * Navigate to a new URL.
162
- */
163
- async navigate(url: string, options: NavigateOptions = {}): Promise<void> {
164
- const normalizedUrl = this.core.normalizeUrl(url);
165
- try {
166
- const { finished } = navigation.navigate(normalizedUrl, {
167
- state: options.state,
168
- history: options.replace ? 'replace' : 'auto',
169
- });
170
- await finished;
171
- } catch (e) {
172
- // Navigation interrupted (e.g. by a redirect) — not an error
173
- if (e instanceof DOMException && e.name === 'AbortError') return;
174
- throw e;
175
- }
176
- }
177
-
178
- /**
179
- * Add event listener for router events.
180
- */
181
- addEventListener(
182
- listener: Parameters<RouteCore['addEventListener']>[0],
183
- ): () => void {
184
- return this.core.addEventListener(listener);
185
- }
186
-
187
- /**
188
- * Get current route parameters.
189
- */
190
- getParams(): RouteParams {
191
- return this.core.getParams();
192
- }
193
-
194
- /**
195
- * Get current matched route.
196
- */
197
- getCurrentRoute(): MatchedRoute | null {
198
- return this.core.currentRoute;
199
- }
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
-
212
- /**
213
- * Handle navigation to a URL.
214
- *
215
- * Pure render function — URL updates and scroll restoration are handled
216
- * by the Navigation API. Abort is signalled via the navigate event's signal.
217
- */
218
- private async handleNavigation(
219
- url: string,
220
- signal: AbortSignal,
221
- ): Promise<void> {
222
- const urlObj = new URL(url, location.origin);
223
- const pathname = urlObj.pathname;
224
-
225
- logger.nav('start', location.pathname, pathname);
226
-
227
- // /md/ paths are handled server-side (initial load only — navigate handler filters these)
228
- if (pathname.startsWith(this.mdBase + '/') || pathname === this.mdBase) {
229
- logger.nav('redirect-md', pathname, pathname, { reason: 'server-side markdown' });
230
- globalThis.location.href = url;
231
- return;
232
- }
233
-
234
- // Strip basePath prefix — trie holds unprefixed patterns
235
- const routePath = this.stripBase(pathname);
236
-
237
- try {
238
- const matched = this.core.match(new URL(routePath, location.origin));
239
-
240
- if (!matched) {
241
- logger.nav('not-found', pathname, pathname);
242
- await this.renderStatusPage(404, pathname);
243
- return;
244
- }
245
-
246
- logger.nav('matched', pathname, matched.route.pattern, { params: matched.params });
247
-
248
- // Handle redirect — starts a new navigation, aborting this one
249
- if (matched.route.type === 'redirect') {
250
- const module = await this.core.loadModule<{ default: RedirectConfig }>(
251
- matched.route.modulePath,
252
- );
253
- if (signal.aborted) return;
254
- assertSafeRedirect(module.default.to);
255
- navigation.navigate(module.default.to, { history: 'replace' });
256
- return;
257
- }
258
-
259
- // Render page
260
- this.core.currentRoute = matched;
261
- const routeInfo = this.core.toRouteInfo(matched, pathname);
262
-
263
- if (document.startViewTransition) {
264
- const transition = document.startViewTransition(async () => {
265
- await this.renderPage(routeInfo, matched, signal);
266
- });
267
- signal.addEventListener('abort', () => transition.skipTransition(), { once: true });
268
- await transition.updateCallbackDone;
269
- } else {
270
- await this.renderPage(routeInfo, matched, signal);
271
- }
272
-
273
- if (signal.aborted) return;
274
-
275
- // Emit navigate event
276
- this.core.emit({
277
- type: 'navigate',
278
- pathname,
279
- params: matched.params,
280
- });
281
- } catch (error) {
282
- if (signal.aborted) return;
283
- if (error instanceof Response) {
284
- await this.renderStatusPage(error.status, pathname);
285
- return;
286
- }
287
- await this.handleError(error, routePath);
288
- }
289
- }
290
-
291
- /**
292
- * Render a status-specific page.
293
- */
294
- private async renderStatusPage(
295
- status: number,
296
- pathname: string,
297
- ): Promise<void> {
298
- if (!this.slot) return;
299
-
300
- const statusPage = this.core.getStatusPage(status);
301
-
302
- if (statusPage) {
303
- try {
304
- const component: PageComponent = statusPage.files?.ts
305
- ? (await this.core.loadModule<{ default: PageComponent }>(statusPage.files.ts)).default
306
- : defaultPageComponent;
307
- const ri: RouteInfo = {
308
- pathname,
309
- pattern: statusPage.pattern,
310
- params: {},
311
- searchParams: new URLSearchParams(),
312
- };
313
- const context = await this.core.buildComponentContext(ri, statusPage);
314
- const data = await component.getData({ params: {}, context });
315
- this.slot.setHTMLUnsafe(component.renderHTML({ data, params: {}, context }));
316
- this.updateTitle();
317
- return;
318
- } catch (e) {
319
- console.error(`[Router] Failed to render ${status} page:`, e);
320
- }
321
- }
322
-
323
- this.slot.setHTMLUnsafe(`
324
- <h1>${STATUS_MESSAGES[status] ?? 'Error'}</h1>
325
- <p>Path: ${escapeHtml(pathname)}</p>
326
- `);
327
- this.updateTitle();
328
- }
329
-
330
- /** Try to load and render an error boundary or handler module into the slot. */
331
- private async tryRenderErrorModule(modulePath: string): Promise<boolean> {
332
- try {
333
- const module = await this.core.loadModule<{ default: PageComponent }>(modulePath);
334
- const component = module.default;
335
- const minCtx = { pathname: '', pattern: '', params: {}, searchParams: new URLSearchParams() };
336
- const data = await component.getData({ params: {}, context: minCtx });
337
- const html = component.renderHTML({ data, params: {}, context: minCtx });
338
- if (this.slot) {
339
- this.slot.setHTMLUnsafe(html);
340
- this.updateTitle();
341
- }
342
- return true;
343
- } catch (e) {
344
- console.error('[Router] Error module failed:', e);
345
- return false;
346
- }
347
- }
348
-
349
- /**
350
- * Handle errors during navigation/rendering.
351
- */
352
- private async handleError(error: unknown, pathname: string): Promise<void> {
353
- console.error('[Router] Navigation error:', error);
354
-
355
- this.core.emit({
356
- type: 'error',
357
- pathname,
358
- params: {},
359
- error: error instanceof Error ? error : new Error(String(error)),
360
- });
361
-
362
- const boundary = this.core.findErrorBoundary(pathname);
363
- if (boundary && await this.tryRenderErrorModule(boundary.modulePath)) return;
364
-
365
- const errorHandler = this.core.getErrorHandler();
366
- if (errorHandler && await this.tryRenderErrorModule(errorHandler.modulePath)) return;
367
-
368
- if (this.slot) {
369
- const message = error instanceof Error ? error.message : String(error);
370
- this.slot.setHTMLUnsafe(`
371
- <h1>Error</h1>
372
- <p>${escapeHtml(message)}</p>
373
- `);
374
- this.updateTitle();
375
- }
376
- }
377
- }
378
-
379
- /**
380
- * Create and initialize SPA HTML router.
381
- *
382
- * The router instance is stored on `globalThis.__emroute_router` for
383
- * programmatic access from consumer scripts (navigate, getParams, etc.).
384
- * Calling this function twice returns the existing router with a warning.
385
- */
386
- export async function createSpaHtmlRouter(
387
- resolver: RouteResolver,
388
- options?: SpaHtmlRouterOptions,
389
- ): Promise<SpaHtmlRouter> {
390
- const g = globalThis as Record<string, unknown>;
391
- if (g.__emroute_router) {
392
- console.warn('eMroute: SPA router already initialized. Remove duplicate <script> tags.');
393
- return g.__emroute_router as SpaHtmlRouter;
394
- }
395
- const router = new SpaHtmlRouter(resolver, options);
396
- await router.initialize();
397
- g.__emroute_router = router;
398
- return router;
399
- }