@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
@@ -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, pathname: string): string {
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, pathname: string): string {
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, pathname: string): string {
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, pathname: string): string {
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 = { ...routeInfo, files };
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: string,
52
+ url: URL,
53
+ signal?: AbortSignal,
54
54
  ): Promise<{ content: string; status: number; title?: string; redirect?: string }> {
55
- const urlObj = toUrl(url);
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 = { pathname, pattern: statusPage.pattern, params: {}, searchParams };
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, pathname), status: 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, pathname);
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
- pathname,
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, pathname), status: 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, pathname, 'boundary');
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, pathname, 'handler');
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, pathname), status: 500 };
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(routeInfo.pattern);
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
- const isLeaf = i === hierarchy.length - 1;
172
- const { content, title } = await this.renderRouteContent(routeInfo, route, isLeaf);
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 "${routePattern}" to render into. ` +
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, undefined, isLeaf);
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
- pathname: string,
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
- pathname: '',
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, pathname: string): string;
277
+ protected abstract renderStatusPage(status: number, url: URL): string;
272
278
 
273
- protected abstract renderErrorPage(error: unknown, pathname: string): string;
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;
@@ -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 | string): MatchedRoute | undefined {
136
- const urlObj = toUrl(url);
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, pathname: string): RouteInfo {
297
+ toRouteInfo(matched: MatchedRoute, url: URL): RouteInfo {
305
298
  return {
306
- pathname,
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 [html, md, css] = await Promise.all([
327
- rf?.html ? fetchFile(rf.html) : undefined,
328
- rf?.md ? fetchFile(rf.md) : undefined,
329
- rf?.css ? fetchFile(rf.css) : undefined,
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
  }