@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,168 @@
1
+ /**
2
+ * Build Utilities
3
+ *
4
+ * Standalone client bundling — extracted from Runtime so that build is a
5
+ * separate concern from storage. Call `buildClientBundles()` before
6
+ * `createEmrouteServer()` to produce emroute.js + app.js.
7
+ *
8
+ * Requires esbuild as a devDependency in the consumer project.
9
+ */
10
+
11
+ import { readFile } from 'node:fs/promises';
12
+ import { createRequire } from 'node:module';
13
+ import { resolve } from 'node:path';
14
+ import type { Runtime } from '../runtime/abstract.runtime.ts';
15
+ import { DEFAULT_ROUTES_DIR, DEFAULT_WIDGETS_DIR } from '../runtime/abstract.runtime.ts';
16
+ import { createManifestPlugin } from './esbuild-manifest.plugin.ts';
17
+ import { createRuntimeLoaderPlugin } from '../runtime/bun/esbuild-runtime-loader.plugin.ts';
18
+ import { generateMainTs } from './codegen.util.ts';
19
+ import type { SpaMode } from '../src/type/widget.type.ts';
20
+
21
+ export const EMROUTE_EXTERNALS = [
22
+ '@emkodev/emroute/spa',
23
+ '@emkodev/emroute/overlay',
24
+ '@emkodev/emroute',
25
+ '@emkodev/emroute/server',
26
+ '@emkodev/emroute/runtime/fetch',
27
+ ] as const;
28
+
29
+ /** esbuild namespace for virtual `emroute:routes` / `emroute:widgets` modules. */
30
+ export const EMROUTE_VIRTUAL_NS = 'emroute';
31
+
32
+ export interface BuildOptions {
33
+ /** Runtime instance to read manifests and source files from. */
34
+ runtime: Runtime;
35
+ /** Filesystem root for esbuild resolution (e.g. process.cwd() or the app directory). */
36
+ root: string;
37
+ /** SPA mode — skips bundling when 'none'. */
38
+ spa: SpaMode;
39
+ /** Consumer's SPA entry point (e.g. '/main.ts'). When absent, auto-generates one. */
40
+ entryPoint?: string;
41
+ /** Output paths for the bundles. */
42
+ bundlePaths?: { emroute: string; app: string };
43
+ }
44
+
45
+ const DEFAULT_BUNDLE_PATHS = { emroute: '/emroute.js', app: '/app.js' };
46
+
47
+ /**
48
+ * Build client bundles and write them into the runtime.
49
+ *
50
+ * Produces:
51
+ * - emroute.js — pre-built from dist/ (no esbuild needed for this)
52
+ * - app.js — consumer entry point with routeTree, FetchRuntime, createEmrouteApp
53
+ * - index.html — shell with import map + script tags (if not already present)
54
+ */
55
+ export async function buildClientBundles(options: BuildOptions): Promise<void> {
56
+ const { runtime, root, spa, entryPoint } = options;
57
+ if (spa === 'none') return;
58
+
59
+ const paths = options.bundlePaths ?? DEFAULT_BUNDLE_PATHS;
60
+
61
+ // Copy pre-built emroute.js from the package dist/
62
+ const consumerRequire = createRequire(root + '/');
63
+ const emrouteJsPath = resolvePrebuiltBundle(consumerRequire);
64
+ const emrouteJs = await readFile(emrouteJsPath);
65
+ await runtime.command(paths.emroute, { body: emrouteJs });
66
+
67
+ // App bundle — generate main.ts if absent, virtual plugin resolves manifests
68
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
69
+ const esbuild = await loadEsbuild() as any;
70
+ const ep = entryPoint ?? '/main.ts';
71
+ if ((await runtime.query(ep)).status === 404) {
72
+ const hasRoutes = (await runtime.query((runtime.config.routesDir ?? DEFAULT_ROUTES_DIR) + '/')).status !== 404;
73
+ const hasWidgets = (await runtime.query((runtime.config.widgetsDir ?? DEFAULT_WIDGETS_DIR) + '/')).status !== 404;
74
+ const code = generateMainTs(spa, hasRoutes, hasWidgets, '@emkodev/emroute');
75
+ await runtime.command(ep, { body: code });
76
+ }
77
+
78
+ const manifestPlugin = createManifestPlugin({ runtime, resolveDir: root });
79
+ const runtimeLoader = createRuntimeLoaderPlugin({ runtime, root });
80
+
81
+ const result = await esbuild.build({
82
+ bundle: true,
83
+ write: false,
84
+ format: 'esm' as const,
85
+ platform: 'browser' as const,
86
+ entryPoints: [`${root}${ep}`],
87
+ outfile: `${root}${paths.app}`,
88
+ external: [...EMROUTE_EXTERNALS],
89
+ plugins: [manifestPlugin, runtimeLoader],
90
+ });
91
+
92
+ for (const file of result.outputFiles) {
93
+ const runtimePath = file.path.startsWith(root)
94
+ ? file.path.slice(root.length)
95
+ : '/' + file.path;
96
+ await runtime.command(runtimePath, { body: file.contents as unknown as BodyInit });
97
+ }
98
+
99
+ // Write shell (index.html) if not already present
100
+ await writeShell(runtime, paths, ep);
101
+
102
+ await esbuild.stop();
103
+ }
104
+
105
+ /**
106
+ * Resolve the pre-built dist/emroute.js from the consumer's node_modules.
107
+ * Falls back to the local dist/ when running from the source repo.
108
+ */
109
+ function resolvePrebuiltBundle(require: NodeRequire): string {
110
+ try {
111
+ const spaEntry = require.resolve('@emkodev/emroute/spa');
112
+ // Compiled: .../dist/src/renderer/spa/mod.js → .../dist/emroute.js
113
+ const distMatch = spaEntry.match(/^(.+\/dist\/)src\/renderer\/spa\/mod\.js$/);
114
+ if (distMatch) return distMatch[1] + 'emroute.js';
115
+ // Source (Bun): .../src/renderer/spa/mod.ts → .../dist/emroute.js
116
+ const srcMatch = spaEntry.match(/^(.+\/)src\/renderer\/spa\/mod\.ts$/);
117
+ if (srcMatch) return srcMatch[1] + 'dist/emroute.js';
118
+ } catch { /* not installed as dependency */ }
119
+ // Last resort
120
+ return resolve(process.cwd(), 'dist/emroute.js');
121
+ }
122
+
123
+ // ── Shell generation ──────────────────────────────────────────────────
124
+
125
+ async function writeShell(
126
+ runtime: Runtime,
127
+ paths: { emroute: string; app: string },
128
+ entryPoint: string,
129
+ ): Promise<void> {
130
+ if ((await runtime.query('/index.html')).status !== 404) return;
131
+
132
+ const imports: Record<string, string> = {};
133
+ for (const pkg of EMROUTE_EXTERNALS) {
134
+ imports[pkg] = paths.emroute;
135
+ }
136
+ const importMap = JSON.stringify({ imports }, null, 2);
137
+
138
+ const scripts = [
139
+ `<script type="importmap">\n${importMap}\n </script>`,
140
+ ];
141
+ if (entryPoint) {
142
+ scripts.push(`<script type="module" src="${paths.app}"></script>`);
143
+ }
144
+
145
+ const html = `<!DOCTYPE html>
146
+ <html>
147
+ <head>
148
+ <meta charset="utf-8">
149
+ <meta name="viewport" content="width=device-width, initial-scale=1">
150
+ <title>emroute</title>
151
+ <style>@view-transition { navigation: auto; } router-slot { display: contents; }</style>
152
+ </head>
153
+ <body>
154
+ <router-slot></router-slot>
155
+ ${scripts.join('\n ')}
156
+ </body>
157
+ </html>`;
158
+
159
+ await runtime.command('/index.html', { body: html });
160
+ }
161
+
162
+ // ── esbuild loader ────────────────────────────────────────────────────
163
+
164
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
165
+ async function loadEsbuild(): Promise<any> {
166
+ const consumerRequire = createRequire(process.cwd() + '/');
167
+ return consumerRequire('esbuild');
168
+ }
@@ -12,6 +12,9 @@ import type { SpaMode } from '../src/type/widget.type.ts';
12
12
  /**
13
13
  * Generate a main.ts entry point for SPA bootstrapping.
14
14
  *
15
+ * For `root`/`only` modes: creates an EmrouteServer with FetchRuntime
16
+ * in the browser and wires it to Navigation API via `createEmrouteApp`.
17
+ *
15
18
  * Imports route tree and widget manifests from virtual `emroute:` specifiers
16
19
  * that the esbuild manifest plugin resolves at bundle time.
17
20
  */
@@ -27,23 +30,23 @@ export function generateMainTs(
27
30
 
28
31
  const spaImport = `${importPath}/spa`;
29
32
 
30
- if (hasRoutes) {
31
- imports.push(`import { routeTree, moduleLoaders } from 'emroute:routes';`);
32
- }
33
-
34
33
  if (hasWidgets) {
35
- imports.push(`import { ComponentElement } from '${spaImport}';`);
34
+ imports.push(`import { ComponentElement, WidgetRegistry } from '${spaImport}';`);
36
35
  imports.push(`import { widgetsManifest } from 'emroute:widgets';`);
36
+ body.push('const widgets = new WidgetRegistry();');
37
37
  body.push('for (const entry of widgetsManifest.widgets) {');
38
38
  body.push(
39
39
  ' const mod = await widgetsManifest.moduleLoaders![entry.modulePath]() as Record<string, unknown>;',
40
40
  );
41
41
  body.push(' for (const exp of Object.values(mod)) {');
42
42
  body.push(" if (exp && typeof exp === 'object' && 'getData' in exp) {");
43
+ body.push(' widgets.add(exp as any);');
43
44
  body.push(' ComponentElement.register(exp as any, entry.files);');
44
45
  body.push(' break;');
45
46
  body.push(' }');
46
47
  body.push(" if (typeof exp === 'function' && exp.prototype?.getData) {");
48
+ body.push(' const instance = new (exp as new () => any)();');
49
+ body.push(' widgets.add(instance);');
47
50
  body.push(
48
51
  ' ComponentElement.registerClass(exp as new () => any, entry.name, entry.files);',
49
52
  );
@@ -54,14 +57,29 @@ export function generateMainTs(
54
57
  }
55
58
 
56
59
  if ((spa === 'root' || spa === 'only') && hasRoutes) {
57
- imports.push(`import { RouteTrie } from '${importPath}';`);
58
- imports.push(`import { createSpaHtmlRouter } from '${spaImport}';`);
60
+ imports.push(`import { routeTree, moduleLoaders } from 'emroute:routes';`);
61
+ imports.push(`import { createEmrouteServer } from '${importPath}/server';`);
62
+ imports.push(`import { FetchRuntime } from '${importPath}/runtime/fetch';`);
63
+ imports.push(`import { createEmrouteApp } from '${spaImport}';`);
64
+
65
+ body.push('const runtime = new FetchRuntime(location.origin);');
66
+
67
+ // Merge route + widget module loaders so the browser never calls runtime.loadModule()
68
+ const loadersExpr = hasWidgets
69
+ ? '{ ...moduleLoaders, ...widgetsManifest.moduleLoaders }'
70
+ : 'moduleLoaders';
59
71
 
60
- body.push('const resolver = new RouteTrie(routeTree);');
72
+ const configParts = ['routeTree', `moduleLoaders: ${loadersExpr}`];
73
+ if (hasWidgets) configParts.push('widgets');
74
+ if (basePath) {
75
+ configParts.push(`basePath: { html: '${basePath.html}', md: '${basePath.md}', app: '${basePath.app}' }`);
76
+ }
77
+ body.push(`const server = await createEmrouteServer({ ${configParts.join(', ')} }, runtime);`);
61
78
 
62
- const bpOpt = basePath ? `basePath: { html: '${basePath.html}', md: '${basePath.md}' }` : '';
63
- const optsInner = [bpOpt, 'moduleLoaders'].filter(Boolean).join(', ');
64
- body.push(`await createSpaHtmlRouter(resolver, { ${optsInner} });`);
79
+ const appOpts = basePath
80
+ ? `{ basePath: { html: '${basePath.html}', md: '${basePath.md}', app: '${basePath.app}' } }`
81
+ : '';
82
+ body.push(`await createEmrouteApp(server${appOpts ? ', ' + appOpts : ''});`);
65
83
  }
66
84
 
67
85
  return `/** Auto-generated entry point — do not edit. */\n${imports.join('\n')}\n\n${
@@ -37,6 +37,7 @@ import type { WidgetManifestEntry } from '../src/type/widget.type.ts';
37
37
  import { WidgetRegistry } from '../src/widget/widget.registry.ts';
38
38
  import type { WidgetComponent } from '../src/component/widget.component.ts';
39
39
  import { escapeHtml } from '../src/util/html.util.ts';
40
+ import { rewriteMdLinks } from '../src/util/md.util.ts';
40
41
  import {
41
42
  ROUTES_MANIFEST_PATH,
42
43
  Runtime,
@@ -58,7 +59,8 @@ function createModuleLoaders(
58
59
  const paths = new Set<string>();
59
60
 
60
61
  function walk(node: RouteNode): void {
61
- if (node.files?.ts) paths.add(node.files.ts);
62
+ const modulePath = node.files?.ts ?? node.files?.js;
63
+ if (modulePath) paths.add(modulePath);
62
64
  if (node.redirect) paths.add(node.redirect);
63
65
  if (node.errorBoundary) paths.add(node.errorBoundary);
64
66
 
@@ -106,6 +108,7 @@ async function importWidgets(
106
108
  widgetFiles: Record<string, { html?: string; md?: string; css?: string }>;
107
109
  }> {
108
110
  const registry = new WidgetRegistry();
111
+ const widgetFiles: Record<string, { html?: string; md?: string; css?: string }> = {};
109
112
 
110
113
  for (const entry of entries) {
111
114
  try {
@@ -117,8 +120,17 @@ async function importWidgets(
117
120
  const instance = extractWidgetExport(mod);
118
121
  if (!instance) continue;
119
122
  registry.add(instance);
123
+
124
+ // Prefer inlined __files from merged module over manifest paths
125
+ const inlined = mod.__files;
126
+ if (inlined && typeof inlined === 'object') {
127
+ widgetFiles[entry.name] = inlined as { html?: string; md?: string; css?: string };
128
+ } else if (entry.files) {
129
+ widgetFiles[entry.name] = entry.files;
130
+ }
120
131
  } catch (e) {
121
132
  console.error(`[emroute] Failed to load widget ${entry.modulePath}:`, e);
133
+ if (entry.files) widgetFiles[entry.name] = entry.files;
122
134
  }
123
135
  }
124
136
 
@@ -128,21 +140,17 @@ async function importWidgets(
128
140
  }
129
141
  }
130
142
 
131
- const widgetFiles: Record<string, { html?: string; md?: string; css?: string }> = {};
132
- for (const entry of entries) {
133
- if (entry.files) widgetFiles[entry.name] = entry.files;
134
- }
135
-
136
143
  return { registry, widgetFiles };
137
144
  }
138
145
 
139
146
  // ── HTML shell ─────────────────────────────────────────────────────────
140
147
 
141
148
  /** Build a default HTML shell. */
142
- function buildHtmlShell(title: string): string {
149
+ function buildHtmlShell(title: string, htmlBase: string): string {
150
+ const baseTag = htmlBase ? `\n <base href="${escapeHtml(htmlBase)}/">` : '';
143
151
  return `<!DOCTYPE html>
144
152
  <html>
145
- <head>
153
+ <head>${baseTag}
146
154
  <meta charset="utf-8">
147
155
  <meta name="viewport" content="width=device-width, initial-scale=1">
148
156
  <title>${escapeHtml(title)}</title>
@@ -178,10 +186,11 @@ function injectSsrContent(
178
186
  async function resolveShell(
179
187
  runtime: Runtime,
180
188
  title: string,
189
+ htmlBase: string,
181
190
  ): Promise<string> {
182
191
  const response = await runtime.query('/index.html');
183
192
  if (response.status !== 404) return await response.text();
184
- return buildHtmlShell(title);
193
+ return buildHtmlShell(title, htmlBase);
185
194
  }
186
195
 
187
196
  // ── More path helpers ─────────────────────────────────────────────────
@@ -201,10 +210,7 @@ export async function createEmrouteServer(
201
210
  spa = 'root',
202
211
  } = config;
203
212
 
204
- // Let the runtime know the SPA mode so bundle() can skip when 'none'.
205
- runtime.config.spa = spa;
206
-
207
- const { html: htmlBase, md: mdBase } = config.basePath ?? DEFAULT_BASE_PATH;
213
+ const { html: htmlBase, md: mdBase, app: appBase } = config.basePath ?? DEFAULT_BASE_PATH;
208
214
 
209
215
  // ── Route tree (read from runtime) ──────────────────────────────────
210
216
 
@@ -223,7 +229,7 @@ export async function createEmrouteServer(
223
229
  routeTree = await manifestResponse.json();
224
230
  }
225
231
 
226
- const moduleLoaders = createModuleLoaders(routeTree, runtime);
232
+ const moduleLoaders = config.moduleLoaders ?? createModuleLoaders(routeTree, runtime);
227
233
  const resolver = new RouteTrie(routeTree);
228
234
 
229
235
  // ── Widgets (read from runtime) ────────────────────────────────────
@@ -235,9 +241,17 @@ export async function createEmrouteServer(
235
241
  const widgetsResponse = await runtime.query(WIDGETS_MANIFEST_PATH);
236
242
  if (widgetsResponse.status !== 404) {
237
243
  discoveredWidgetEntries = await widgetsResponse.json();
238
- const imported = await importWidgets(discoveredWidgetEntries, runtime, config.widgets);
239
- widgets = imported.registry;
240
- widgetFiles = imported.widgetFiles;
244
+ if (config.widgets) {
245
+ // Widgets pre-provided (e.g. browser bundle) — just collect file paths
246
+ widgets = config.widgets;
247
+ for (const entry of discoveredWidgetEntries) {
248
+ if (entry.files) widgetFiles[entry.name] = entry.files;
249
+ }
250
+ } else {
251
+ const imported = await importWidgets(discoveredWidgetEntries, runtime);
252
+ widgets = imported.registry;
253
+ widgetFiles = imported.widgetFiles;
254
+ }
241
255
  }
242
256
 
243
257
  // ── SSR routers ──────────────────────────────────────────────────────
@@ -254,7 +268,6 @@ export async function createEmrouteServer(
254
268
 
255
269
  ssrHtmlRouter = new SsrHtmlRouter(resolver, {
256
270
  fileReader: (path) => runtime.query(path, { as: 'text' }),
257
- basePath: htmlBase,
258
271
  moduleLoaders,
259
272
  markdownRenderer: config.markdownRenderer,
260
273
  extendContext: config.extendContext,
@@ -264,7 +277,6 @@ export async function createEmrouteServer(
264
277
 
265
278
  ssrMdRouter = new SsrMdRouter(resolver, {
266
279
  fileReader: (path) => runtime.query(path, { as: 'text' }),
267
- basePath: mdBase,
268
280
  moduleLoaders,
269
281
  extendContext: config.extendContext,
270
282
  widgets,
@@ -274,14 +286,10 @@ export async function createEmrouteServer(
274
286
 
275
287
  buildSsrRouters();
276
288
 
277
- // ── Bundling (runtime decides whether/how to bundle) ────────────────
278
-
279
- await runtime.bundle();
280
-
281
289
  // ── HTML shell ───────────────────────────────────────────────────────
282
290
 
283
291
  const title = config.title ?? 'emroute';
284
- let shell = await resolveShell(runtime, title);
292
+ let shell = await resolveShell(runtime, title, htmlBase);
285
293
 
286
294
  // Auto-discover main.css and inject <link> into <head>
287
295
  if ((await runtime.query('/main.css')).status !== 404) {
@@ -296,6 +304,7 @@ export async function createEmrouteServer(
296
304
 
297
305
  const mdPrefix = mdBase + '/';
298
306
  const htmlPrefix = htmlBase + '/';
307
+ const appPrefix = appBase + '/';
299
308
 
300
309
  // SSR Markdown: /md/*
301
310
  if (
@@ -309,12 +318,13 @@ export async function createEmrouteServer(
309
318
  return Response.redirect(new URL(canonical, url.origin), 301);
310
319
  }
311
320
  try {
312
- const { content, status, redirect } = await ssrMdRouter.render(routePath);
321
+ const routeUrl = new URL(routePath + url.search, url.origin);
322
+ const { content, status, redirect } = await ssrMdRouter.render(routeUrl, req.signal);
313
323
  if (redirect) {
314
324
  const target = redirect.startsWith('/') ? mdBase + redirect : redirect;
315
325
  return Response.redirect(new URL(target, url.origin), status);
316
326
  }
317
- return new Response(content, {
327
+ return new Response(rewriteMdLinks(content, mdBase, [mdBase, htmlBase]), {
318
328
  status,
319
329
  headers: { 'Content-Type': 'text/markdown; charset=utf-8; variant=CommonMark' },
320
330
  });
@@ -336,7 +346,8 @@ export async function createEmrouteServer(
336
346
  return Response.redirect(new URL(canonical, url.origin), 301);
337
347
  }
338
348
  try {
339
- const result = await ssrHtmlRouter.render(routePath);
349
+ const routeUrl = new URL(routePath + url.search, url.origin);
350
+ const result = await ssrHtmlRouter.render(routeUrl, req.signal);
340
351
  if (result.redirect) {
341
352
  const target = result.redirect.startsWith('/') ? htmlBase + result.redirect : result.redirect;
342
353
  return Response.redirect(new URL(target, url.origin), result.status);
@@ -353,7 +364,15 @@ export async function createEmrouteServer(
353
364
  }
354
365
  }
355
366
 
356
- // /html/* or /md/* that wasn't handled by SSR (e.g. 'only' mode) — serve SPA shell
367
+ // /app/* serve shell (browser JS takes over via createEmrouteApp)
368
+ if (pathname.startsWith(appPrefix) || pathname === appBase) {
369
+ return new Response(shell, {
370
+ status: 200,
371
+ headers: { 'Content-Type': 'text/html; charset=utf-8' },
372
+ });
373
+ }
374
+
375
+ // /html/* or /md/* that wasn't handled by SSR (e.g. 'only' mode) — serve shell
357
376
  if (
358
377
  pathname.startsWith(htmlPrefix) || pathname === htmlBase ||
359
378
  pathname.startsWith(mdPrefix) || pathname === mdBase
@@ -372,9 +391,10 @@ export async function createEmrouteServer(
372
391
  return null;
373
392
  }
374
393
 
375
- // Bare paths — redirect to /html/* in all modes.
394
+ // Bare paths — redirect to /app/* in root/only modes, /html/* otherwise.
395
+ const base = (spa === 'root' || spa === 'only') ? appBase : htmlBase;
376
396
  const bare = pathname === '/' ? '' : pathname.slice(1).replace(/\/$/, '');
377
- return Response.redirect(new URL(`${htmlBase}/${bare}`, url.origin), 302);
397
+ return Response.redirect(new URL(`${base}/${bare}`, url.origin), 302);
378
398
  }
379
399
 
380
400
  // ── Return ───────────────────────────────────────────────────────────
@@ -11,6 +11,7 @@
11
11
 
12
12
  import type { Runtime } from '../runtime/abstract.runtime.ts';
13
13
  import { ROUTES_MANIFEST_PATH, WIDGETS_MANIFEST_PATH } from '../runtime/abstract.runtime.ts';
14
+ import { EMROUTE_VIRTUAL_NS } from './build.util.ts';
14
15
 
15
16
  /** Escape a string for use inside a single-quoted JS/TS string literal. */
16
17
  function esc(value: string): string {
@@ -46,12 +47,12 @@ export function createManifestPlugin(options: ManifestPluginOptions): EsbuildPlu
46
47
  build.onResolve(
47
48
  { filter: /^emroute:/ },
48
49
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
49
- (args: any) => ({ path: args.path, namespace: 'emroute' }),
50
+ (args: any) => ({ path: args.path, namespace: EMROUTE_VIRTUAL_NS }),
50
51
  );
51
52
 
52
53
  // ── Load virtual modules ────────────────────────────────────────
53
54
  build.onLoad(
54
- { filter: /.*/, namespace: 'emroute' },
55
+ { filter: /.*/, namespace: EMROUTE_VIRTUAL_NS },
55
56
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
56
57
  async (args: any) => {
57
58
  if (args.path === 'emroute:routes') {
@@ -108,7 +109,8 @@ ${moduleLoadersCode}
108
109
  */
109
110
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
110
111
  function collectModulePaths(node: any, paths: Set<string>): void {
111
- if (node.files?.ts) paths.add(node.files.ts);
112
+ const modulePath = node.files?.ts ?? node.files?.js;
113
+ if (modulePath) paths.add(modulePath);
112
114
  if (node.errorBoundary) paths.add(node.errorBoundary);
113
115
  if (node.redirect) paths.add(node.redirect);
114
116
  if (node.children) {
@@ -58,6 +58,13 @@ export interface EmrouteServerConfig {
58
58
 
59
59
  /** Enrich every ComponentContext with app-level services. */
60
60
  extendContext?: ContextProvider;
61
+
62
+ /**
63
+ * Pre-bundled module loaders (route + widget modules).
64
+ * When provided, skips `runtime.loadModule()` — used in the browser
65
+ * where modules are already bundled into app.js.
66
+ */
67
+ moduleLoaders?: Record<string, () => Promise<unknown>>;
61
68
  }
62
69
 
63
70
  /**
@@ -17,7 +17,7 @@ import { escapeHtml } from '../util/html.util.ts';
17
17
 
18
18
  /**
19
19
  * Context passed to components during rendering.
20
- * Extends RouteInfo (pathname, pattern, params, searchParams)
20
+ * Extends RouteInfo (pathname, pattern, params)
21
21
  * with pre-loaded file content and an abort signal.
22
22
  *
23
23
  * Consumers can extend this interface via module augmentation
@@ -27,12 +27,14 @@ import { escapeHtml } from '../util/html.util.ts';
27
27
  export type FileContents = { html?: string; md?: string; css?: string };
28
28
 
29
29
  export interface ComponentContext extends RouteInfo {
30
+ /** @deprecated Use context.url.pathname */
31
+ readonly pathname: string;
32
+ /** @deprecated Use context.url.searchParams */
33
+ readonly searchParams: URLSearchParams;
30
34
  readonly files?: Readonly<FileContents>;
31
35
  readonly signal?: AbortSignal;
32
36
  /** True when this component is the leaf (matched) route, false when rendered as a layout parent. */
33
37
  readonly isLeaf?: boolean;
34
- /** Base path for SSR HTML links (e.g. '/html'). */
35
- readonly basePath?: string;
36
38
  }
37
39
 
38
40
  /**
@@ -155,11 +155,12 @@ export class ComponentElement<TParams, TData> extends HTMLElementBase {
155
155
  const files = await this.loadFiles();
156
156
  if (signal.aborted) return;
157
157
 
158
+ const currentUrl = globalThis.location ? new URL(location.href) : new URL('http://localhost/');
158
159
  const base: ComponentContext = {
159
- pathname: globalThis.location?.pathname ?? '/',
160
- pattern: '',
161
- params: {},
162
- searchParams: new URLSearchParams(globalThis.location?.search ?? ''),
160
+ url: currentUrl,
161
+ pathname: currentUrl.pathname,
162
+ searchParams: currentUrl.searchParams,
163
+ params: this.params ?? {},
163
164
  files: (files.html || files.md || files.css) ? files : undefined,
164
165
  };
165
166
  this.context = ComponentElement.extendContext ? ComponentElement.extendContext(base) : base;
@@ -2,7 +2,7 @@
2
2
  * SPA (Browser) Module
3
3
  *
4
4
  * Everything needed for the browser bundle:
5
- * - SPA router with client-side navigation
5
+ * - EmrouteApp: Navigation API glue wired to an EmrouteServer
6
6
  * - Custom elements for rendering and hydrating SSR islands
7
7
  * - Widget registry (built-in widgets are opt-in)
8
8
  */
@@ -12,13 +12,7 @@ import { MarkdownElement } from '../../element/markdown.element.ts';
12
12
  import { ComponentElement } from '../../element/component.element.ts';
13
13
  import { WidgetRegistry } from '../../widget/widget.registry.ts';
14
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';
15
+ export { createEmrouteApp, EmrouteApp, type EmrouteAppOptions } from './thin-client.ts';
22
16
  export { ComponentElement, MarkdownElement, RouterSlot, WidgetRegistry };
23
17
  export type { SpaMode, WidgetsManifest } from '../../type/widget.type.ts';
24
18