@emkodev/emroute 1.7.2 → 1.7.3

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 (61) hide show
  1. package/dist/emroute.js +135 -31
  2. package/dist/emroute.js.map +2 -2
  3. package/dist/runtime/abstract.runtime.d.ts +11 -0
  4. package/dist/runtime/abstract.runtime.js +53 -0
  5. package/dist/runtime/abstract.runtime.js.map +1 -1
  6. package/dist/runtime/bun/fs/bun-fs.runtime.js +3 -1
  7. package/dist/runtime/bun/fs/bun-fs.runtime.js.map +1 -1
  8. package/dist/runtime/bun/sqlite/bun-sqlite.runtime.js +3 -1
  9. package/dist/runtime/bun/sqlite/bun-sqlite.runtime.js.map +1 -1
  10. package/dist/runtime/universal/fs/universal-fs.runtime.js +3 -1
  11. package/dist/runtime/universal/fs/universal-fs.runtime.js.map +1 -1
  12. package/dist/server/build.util.js +35 -6
  13. package/dist/server/build.util.js.map +1 -1
  14. package/dist/server/emroute.server.js +17 -7
  15. package/dist/server/emroute.server.js.map +1 -1
  16. package/dist/server/server-api.type.d.ts +3 -0
  17. package/dist/src/component/abstract.component.d.ts +1 -1
  18. package/dist/src/component/abstract.component.js.map +1 -1
  19. package/dist/src/element/component.element.js +17 -3
  20. package/dist/src/element/component.element.js.map +1 -1
  21. package/dist/src/element/markdown.element.js +1 -1
  22. package/dist/src/element/markdown.element.js.map +1 -1
  23. package/dist/src/index.d.ts +1 -0
  24. package/dist/src/index.js.map +1 -1
  25. package/dist/src/renderer/spa/thin-client.js +28 -6
  26. package/dist/src/renderer/spa/thin-client.js.map +1 -1
  27. package/dist/src/renderer/ssr/html.renderer.js +1 -1
  28. package/dist/src/renderer/ssr/html.renderer.js.map +1 -1
  29. package/dist/src/renderer/ssr/md.renderer.js +2 -2
  30. package/dist/src/renderer/ssr/md.renderer.js.map +1 -1
  31. package/dist/src/renderer/ssr/ssr.renderer.js +6 -6
  32. package/dist/src/renderer/ssr/ssr.renderer.js.map +1 -1
  33. package/dist/src/route/route.core.js +21 -7
  34. package/dist/src/route/route.core.js.map +1 -1
  35. package/dist/src/type/element.type.d.ts +19 -0
  36. package/dist/src/type/element.type.js +9 -0
  37. package/dist/src/type/element.type.js.map +1 -0
  38. package/dist/src/util/widget-resolve.util.js +1 -1
  39. package/dist/src/util/widget-resolve.util.js.map +1 -1
  40. package/dist/src/widget/widget.registry.js +1 -1
  41. package/dist/src/widget/widget.registry.js.map +1 -1
  42. package/package.json +1 -1
  43. package/runtime/abstract.runtime.ts +67 -0
  44. package/runtime/bun/fs/bun-fs.runtime.ts +2 -0
  45. package/runtime/bun/sqlite/bun-sqlite.runtime.ts +2 -0
  46. package/runtime/universal/fs/universal-fs.runtime.ts +2 -0
  47. package/server/build.util.ts +37 -5
  48. package/server/emroute.server.ts +20 -6
  49. package/server/server-api.type.ts +4 -0
  50. package/src/component/abstract.component.ts +1 -1
  51. package/src/element/component.element.ts +16 -4
  52. package/src/element/markdown.element.ts +1 -1
  53. package/src/index.ts +1 -0
  54. package/src/renderer/spa/thin-client.ts +30 -5
  55. package/src/renderer/ssr/html.renderer.ts +1 -1
  56. package/src/renderer/ssr/md.renderer.ts +5 -4
  57. package/src/renderer/ssr/ssr.renderer.ts +6 -6
  58. package/src/route/route.core.ts +17 -8
  59. package/src/type/element.type.ts +22 -0
  60. package/src/util/widget-resolve.util.ts +4 -4
  61. package/src/widget/widget.registry.ts +1 -1
@@ -1 +1 @@
1
- {"version":3,"file":"widget.registry.js","sourceRoot":"","sources":["../../../src/widget/widget.registry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAKH,MAAM,OAAO,cAAc;IACjB,OAAO,GAAG,IAAI,GAAG,EAA2B,CAAC;IAErD,qCAAqC;IACrC,GAAG,CAAC,MAAuB;QACzB,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;IACxC,CAAC;IAED,gCAAgC;IAChC,GAAG,CAAC,IAAY;QACd,OAAO,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IAChC,CAAC;IAED,sCAAsC;IACtC,CAAC,MAAM,CAAC,QAAQ,CAAC;QACf,OAAO,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,CAAC;IAC/B,CAAC;IAED,sDAAsD;IACtD,UAAU;QACR,MAAM,OAAO,GAA0B,EAAE,CAAC;QAC1C,MAAM,aAAa,GAA2C,EAAE,CAAC;QAEjE,KAAK,MAAM,CAAC,IAAI,EAAE,MAAM,CAAC,IAAI,IAAI,CAAC,OAAO,EAAE,CAAC;YAC1C,MAAM,KAAK,GAAwB;gBACjC,IAAI;gBACJ,UAAU,EAAE,IAAI;gBAChB,OAAO,EAAE,UAAU,IAAI,EAAE;gBACzB,KAAK,EAAE,MAAM,CAAC,KAAK;aACpB,CAAC;YACF,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;YACpB,aAAa,CAAC,IAAI,CAAC,GAAG,GAAG,EAAE,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,OAAO,EAAE,MAAM,CAAC,WAAW,EAAE,CAAC,CAAC;QAC/E,CAAC;QAED,OAAO,EAAE,OAAO,EAAE,aAAa,EAAE,CAAC;IACpC,CAAC;CACF"}
1
+ {"version":3,"file":"widget.registry.js","sourceRoot":"","sources":["../../../src/widget/widget.registry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAKH,MAAM,OAAO,cAAc;IACjB,OAAO,GAAG,IAAI,GAAG,EAA2B,CAAC;IAErD,qCAAqC;IACrC,GAAG,CAAC,MAAuB;QACzB,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;IACxC,CAAC;IAED,gCAAgC;IAChC,GAAG,CAAC,IAAY;QACd,OAAO,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IAChC,CAAC;IAED,sCAAsC;IACtC,CAAC,MAAM,CAAC,QAAQ,CAAC;QACf,OAAO,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,CAAC;IAC/B,CAAC;IAED,sDAAsD;IACtD,UAAU;QACR,MAAM,OAAO,GAA0B,EAAE,CAAC;QAC1C,MAAM,aAAa,GAA2C,EAAE,CAAC;QAEjE,KAAK,MAAM,CAAC,IAAI,EAAE,MAAM,CAAC,IAAI,IAAI,CAAC,OAAO,EAAE,CAAC;YAC1C,MAAM,KAAK,GAAwB;gBACjC,IAAI;gBACJ,UAAU,EAAE,IAAI;gBAChB,OAAO,EAAE,UAAU,IAAI,EAAE;gBACzB,GAAG,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;aACjD,CAAC;YACF,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;YACpB,aAAa,CAAC,IAAI,CAAC,GAAG,GAAG,EAAE,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,OAAO,EAAE,MAAM,CAAC,WAAW,EAAE,CAAC,CAAC;QAC/E,CAAC;QAED,OAAO,EAAE,OAAO,EAAE,aAAa,EAAE,CAAC;IACpC,CAAC;CACF"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@emkodev/emroute",
3
- "version": "1.7.2",
3
+ "version": "1.7.3",
4
4
  "description": "File-based (but storage-agnostic) router with triple rendering (SPA, SSR HTML, SSR Markdown). Zero dependencies.",
5
5
  "license": "BSD-3-Clause",
6
6
  "author": "emko.dev",
@@ -1,6 +1,7 @@
1
1
  import type { RouteNode, RouteFiles } from '../src/type/route-tree.type.ts';
2
2
  import { resolveTargetNode } from '../src/route/route-tree.util.ts';
3
3
  import type { WidgetManifestEntry } from '../src/type/widget.type.ts';
4
+ import type { ElementManifestEntry } from '../src/type/element.type.ts';
4
5
 
5
6
  export const CONTENT_TYPES: Map<string, string> = new Map<string, string>([
6
7
  ['.html', 'text/html; charset=utf-8'],
@@ -31,12 +32,15 @@ export type FetchReturn = ReturnType<typeof fetch>;
31
32
 
32
33
  export const DEFAULT_ROUTES_DIR = '/routes';
33
34
  export const DEFAULT_WIDGETS_DIR = '/widgets';
35
+ export const DEFAULT_ELEMENTS_DIR = '/elements';
34
36
  export const ROUTES_MANIFEST_PATH = '/routes.manifest.json';
35
37
  export const WIDGETS_MANIFEST_PATH = '/widgets.manifest.json';
38
+ export const ELEMENTS_MANIFEST_PATH = '/elements.manifest.json';
36
39
 
37
40
  export interface RuntimeConfig {
38
41
  routesDir?: string;
39
42
  widgetsDir?: string;
43
+ elementsDir?: string;
40
44
  }
41
45
 
42
46
  /**
@@ -168,11 +172,13 @@ export abstract class Runtime {
168
172
 
169
173
  private routesManifestCache: Response | null = null;
170
174
  private widgetsManifestCache: Response | null = null;
175
+ private elementsManifestCache: Response | null = null;
171
176
 
172
177
  /** Clear cached manifests so the next query triggers a fresh scan. */
173
178
  invalidateManifests(): void {
174
179
  this.routesManifestCache = null;
175
180
  this.widgetsManifestCache = null;
181
+ this.elementsManifestCache = null;
176
182
  }
177
183
 
178
184
  /**
@@ -215,6 +221,25 @@ export abstract class Runtime {
215
221
  return this.widgetsManifestCache.clone();
216
222
  }
217
223
 
224
+ /**
225
+ * Resolve the elements manifest. Called when the concrete runtime returns
226
+ * 404 for ELEMENTS_MANIFEST_PATH. Scans `config.elementsDir` (or default).
227
+ */
228
+ async resolveElementsManifest(): Promise<Response> {
229
+ if (this.elementsManifestCache) return this.elementsManifestCache.clone();
230
+
231
+ const elementsDir = this.config.elementsDir ?? DEFAULT_ELEMENTS_DIR;
232
+
233
+ const dirResponse = await this.query(elementsDir + '/');
234
+ if (dirResponse.status === 404) {
235
+ return new Response('Not Found', { status: 404 });
236
+ }
237
+
238
+ const entries = await this.scanElements(elementsDir, elementsDir.replace(/^\//, ''));
239
+ this.elementsManifestCache = Response.json(entries);
240
+ return this.elementsManifestCache.clone();
241
+ }
242
+
218
243
  // ── Scanning ──────────────────────────────────────────────────────────
219
244
 
220
245
  protected async *walkDirectory(dir: string): AsyncGenerator<string> {
@@ -344,4 +369,46 @@ export abstract class Runtime {
344
369
  entries.sort((a, b) => a.name.localeCompare(b.name));
345
370
  return entries;
346
371
  }
372
+
373
+ protected async scanElements(
374
+ elementsDir: string,
375
+ pathPrefix?: string,
376
+ ): Promise<ElementManifestEntry[]> {
377
+ const entries: ElementManifestEntry[] = [];
378
+
379
+ const trailingDir = elementsDir.endsWith('/') ? elementsDir : elementsDir + '/';
380
+ const response = await this.query(trailingDir);
381
+ const listing: string[] = await response.json();
382
+
383
+ for (const item of listing) {
384
+ if (!item.endsWith('/')) continue;
385
+
386
+ const name = item.slice(0, -1);
387
+
388
+ // Custom element names must contain a hyphen (web spec requirement)
389
+ if (!name.includes('-')) {
390
+ console.warn(`[emroute] Skipping element "${name}": custom element names must contain a hyphen (e.g. "my-element")`);
391
+ continue;
392
+ }
393
+
394
+ // Try .element.ts first, then .element.js
395
+ let moduleFile = `${name}.element.ts`;
396
+ let modulePath = `${trailingDir}${name}/${moduleFile}`;
397
+ if ((await this.query(modulePath)).status === 404) {
398
+ moduleFile = `${name}.element.js`;
399
+ modulePath = `${trailingDir}${name}/${moduleFile}`;
400
+ if ((await this.query(modulePath)).status === 404) continue;
401
+ }
402
+
403
+ const prefix = pathPrefix ? `${pathPrefix}/` : '';
404
+ entries.push({
405
+ name,
406
+ modulePath: `${prefix}${name}/${moduleFile}`,
407
+ tagName: name,
408
+ });
409
+ }
410
+
411
+ entries.sort((a, b) => a.name.localeCompare(b.name));
412
+ return entries;
413
+ }
347
414
  }
@@ -8,6 +8,7 @@ import {
8
8
  Runtime,
9
9
  type RuntimeConfig,
10
10
  WIDGETS_MANIFEST_PATH,
11
+ ELEMENTS_MANIFEST_PATH,
11
12
  } from '../../abstract.runtime.ts';
12
13
 
13
14
  export class BunFsRuntime extends Runtime {
@@ -99,6 +100,7 @@ export class BunFsRuntime extends Runtime {
99
100
  const pathname = path.slice(this.root.length);
100
101
  if (pathname === ROUTES_MANIFEST_PATH) return this.resolveRoutesManifest();
101
102
  if (pathname === WIDGETS_MANIFEST_PATH) return this.resolveWidgetsManifest();
103
+ if (pathname === ELEMENTS_MANIFEST_PATH) return this.resolveElementsManifest();
102
104
  return new Response('Not Found', { status: 404 });
103
105
  }
104
106
  return new Response(`Internal Error: ${error}`, { status: 500 });
@@ -7,6 +7,7 @@ import {
7
7
  Runtime,
8
8
  type RuntimeConfig,
9
9
  WIDGETS_MANIFEST_PATH,
10
+ ELEMENTS_MANIFEST_PATH,
10
11
  } from '../../abstract.runtime.ts';
11
12
 
12
13
  export class BunSqliteRuntime extends Runtime {
@@ -108,6 +109,7 @@ export class BunSqliteRuntime extends Runtime {
108
109
  if (!row) {
109
110
  if (path === ROUTES_MANIFEST_PATH) return this.resolveRoutesManifest();
110
111
  if (path === WIDGETS_MANIFEST_PATH) return this.resolveWidgetsManifest();
112
+ if (path === ELEMENTS_MANIFEST_PATH) return this.resolveElementsManifest();
111
113
  return new Response('Not Found', { status: 404 });
112
114
  }
113
115
 
@@ -8,6 +8,7 @@ import {
8
8
  Runtime,
9
9
  type RuntimeConfig,
10
10
  WIDGETS_MANIFEST_PATH,
11
+ ELEMENTS_MANIFEST_PATH,
11
12
  } from '../../abstract.runtime.ts';
12
13
 
13
14
  /**
@@ -107,6 +108,7 @@ export class UniversalFsRuntime extends Runtime {
107
108
  const pathname = path.slice(this.root.length);
108
109
  if (pathname === ROUTES_MANIFEST_PATH) return this.resolveRoutesManifest();
109
110
  if (pathname === WIDGETS_MANIFEST_PATH) return this.resolveWidgetsManifest();
111
+ if (pathname === ELEMENTS_MANIFEST_PATH) return this.resolveElementsManifest();
110
112
  return new Response('Not Found', { status: 404 });
111
113
  }
112
114
  return new Response(`Internal Error: ${error}`, { status: 500 });
@@ -20,9 +20,11 @@ import type { Runtime } from '../runtime/abstract.runtime.ts';
20
20
  import {
21
21
  ROUTES_MANIFEST_PATH,
22
22
  WIDGETS_MANIFEST_PATH,
23
+ ELEMENTS_MANIFEST_PATH,
23
24
  } from '../runtime/abstract.runtime.ts';
24
25
  import type { RouteNode } from '../src/type/route-tree.type.ts';
25
26
  import type { WidgetManifestEntry } from '../src/type/widget.type.ts';
27
+ import type { ElementManifestEntry } from '../src/type/element.type.ts';
26
28
  import { generateMainTs } from './codegen.util.ts';
27
29
  import type { SpaMode } from '../src/type/widget.type.ts';
28
30
 
@@ -146,10 +148,23 @@ async function writeShell(
146
148
  ): Promise<void> {
147
149
  if ((await runtime.query('/index.html')).status !== 404) return;
148
150
 
151
+ // Base emroute imports
149
152
  const imports: Record<string, string> = {};
150
153
  for (const pkg of EMROUTE_EXTERNALS) {
151
154
  imports[pkg] = paths.emroute;
152
155
  }
156
+
157
+ // Merge user-provided importmap.json (user entries win on conflict)
158
+ const mapResponse = await runtime.query('/importmap.json');
159
+ if (mapResponse.status !== 404) {
160
+ const userMap = await mapResponse.json() as { imports?: Record<string, string> };
161
+ if (userMap.imports) {
162
+ for (const [key, value] of Object.entries(userMap.imports)) {
163
+ imports[key] = value;
164
+ }
165
+ }
166
+ }
167
+
153
168
  const importMap = JSON.stringify({ imports }, null, 2);
154
169
 
155
170
  const html = `<!DOCTYPE html>
@@ -251,11 +266,10 @@ async function mergeModules(runtime: Runtime): Promise<void> {
251
266
  // Merge route modules
252
267
  async function walkRoutes(node: RouteNode): Promise<void> {
253
268
  if (node.files?.ts) {
254
- const companions = {
255
- html: node.files.html,
256
- md: node.files.md,
257
- css: node.files.css,
258
- };
269
+ const companions: { html?: string; md?: string; css?: string } = {};
270
+ if (node.files.html) companions.html = node.files.html;
271
+ if (node.files.md) companions.md = node.files.md;
272
+ if (node.files.css) companions.css = node.files.css;
259
273
  node.files.js = await transpileAndMerge(runtime, node.files.ts, companions);
260
274
  delete node.files.ts;
261
275
  delete node.files.html;
@@ -290,6 +304,19 @@ async function mergeModules(runtime: Runtime): Promise<void> {
290
304
  }
291
305
  }
292
306
 
307
+ // Read element manifest
308
+ const elementsResponse = await runtime.query(ELEMENTS_MANIFEST_PATH);
309
+ const elementEntries: ElementManifestEntry[] = elementsResponse.status !== 404
310
+ ? await elementsResponse.json()
311
+ : [];
312
+
313
+ // Merge element modules
314
+ for (const entry of elementEntries) {
315
+ if (entry.modulePath.endsWith('.ts')) {
316
+ entry.modulePath = await transpileAndMerge(runtime, entry.modulePath);
317
+ }
318
+ }
319
+
293
320
  // Write updated manifests back
294
321
  await runtime.command(ROUTES_MANIFEST_PATH, {
295
322
  body: JSON.stringify(routeTree),
@@ -297,6 +324,11 @@ async function mergeModules(runtime: Runtime): Promise<void> {
297
324
  await runtime.command(WIDGETS_MANIFEST_PATH, {
298
325
  body: JSON.stringify(widgetEntries),
299
326
  });
327
+ if (elementEntries.length > 0) {
328
+ await runtime.command(ELEMENTS_MANIFEST_PATH, {
329
+ body: JSON.stringify(elementEntries),
330
+ });
331
+ }
300
332
  }
301
333
 
302
334
  // ── esbuild loader ────────────────────────────────────────────────────
@@ -34,6 +34,7 @@ import { SsrHtmlRouter } from '../src/renderer/ssr/html.renderer.ts';
34
34
  import { SsrMdRouter } from '../src/renderer/ssr/md.renderer.ts';
35
35
  import type { RouteNode } from '../src/type/route-tree.type.ts';
36
36
  import type { WidgetManifestEntry } from '../src/type/widget.type.ts';
37
+ import type { ElementManifestEntry } from '../src/type/element.type.ts';
37
38
  import { WidgetRegistry } from '../src/widget/widget.registry.ts';
38
39
  import type { WidgetComponent } from '../src/component/widget.component.ts';
39
40
  import { escapeHtml } from '../src/util/html.util.ts';
@@ -42,6 +43,7 @@ import {
42
43
  ROUTES_MANIFEST_PATH,
43
44
  Runtime,
44
45
  WIDGETS_MANIFEST_PATH,
46
+ ELEMENTS_MANIFEST_PATH,
45
47
  } from '../runtime/abstract.runtime.ts';
46
48
  import type { EmrouteServer, EmrouteServerConfig } from './server-api.type.ts';
47
49
 
@@ -254,6 +256,14 @@ export async function createEmrouteServer(
254
256
  }
255
257
  }
256
258
 
259
+ // ── Elements (read from runtime) ──────────────────────────────────
260
+
261
+ let discoveredElementEntries: ElementManifestEntry[] = [];
262
+ const elementsResponse = await runtime.query(ELEMENTS_MANIFEST_PATH);
263
+ if (elementsResponse.status !== 404) {
264
+ discoveredElementEntries = await elementsResponse.json();
265
+ }
266
+
257
267
  // ── SSR routers ──────────────────────────────────────────────────────
258
268
 
259
269
  let ssrHtmlRouter: SsrHtmlRouter | null = null;
@@ -269,17 +279,17 @@ export async function createEmrouteServer(
269
279
  ssrHtmlRouter = new SsrHtmlRouter(resolver, {
270
280
  fileReader: (path) => runtime.query(path, { as: 'text' }),
271
281
  moduleLoaders,
272
- markdownRenderer: config.markdownRenderer,
273
- extendContext: config.extendContext,
274
- widgets,
282
+ ...(config.markdownRenderer ? { markdownRenderer: config.markdownRenderer } : {}),
283
+ ...(config.extendContext ? { extendContext: config.extendContext } : {}),
284
+ ...(widgets ? { widgets } : {}),
275
285
  widgetFiles,
276
286
  });
277
287
 
278
288
  ssrMdRouter = new SsrMdRouter(resolver, {
279
289
  fileReader: (path) => runtime.query(path, { as: 'text' }),
280
290
  moduleLoaders,
281
- extendContext: config.extendContext,
282
- widgets,
291
+ ...(config.extendContext ? { extendContext: config.extendContext } : {}),
292
+ ...(widgets ? { widgets } : {}),
283
293
  widgetFiles,
284
294
  });
285
295
  }
@@ -289,7 +299,8 @@ export async function createEmrouteServer(
289
299
  // ── HTML shell ───────────────────────────────────────────────────────
290
300
 
291
301
  const title = config.title ?? 'emroute';
292
- let shell = await resolveShell(runtime, title, htmlBase);
302
+ const shellBase = (spa === 'root' || spa === 'only') ? appBase : htmlBase;
303
+ let shell = await resolveShell(runtime, title, shellBase);
293
304
 
294
305
  // Auto-discover main.css and inject <link> into <head>
295
306
  if ((await runtime.query('/main.css')).status !== 404) {
@@ -413,6 +424,9 @@ export async function createEmrouteServer(
413
424
  get widgetEntries() {
414
425
  return discoveredWidgetEntries;
415
426
  },
427
+ get elementEntries() {
428
+ return discoveredElementEntries;
429
+ },
416
430
  get shell() {
417
431
  return shell;
418
432
  },
@@ -9,6 +9,7 @@
9
9
  import type { RouteNode } from '../src/type/route-tree.type.ts';
10
10
  import type { MarkdownRenderer } from '../src/type/markdown.type.ts';
11
11
  import type { SpaMode, WidgetManifestEntry } from '../src/type/widget.type.ts';
12
+ import type { ElementManifestEntry } from '../src/type/element.type.ts';
12
13
  import type { ContextProvider } from '../src/component/abstract.component.ts';
13
14
  import type { BasePath } from '../src/route/route.core.ts';
14
15
  import type { WidgetRegistry } from '../src/widget/widget.registry.ts';
@@ -92,6 +93,9 @@ export interface EmrouteServer {
92
93
  /** Discovered widget entries. */
93
94
  readonly widgetEntries: WidgetManifestEntry[];
94
95
 
96
+ /** Discovered element entries. */
97
+ readonly elementEntries: ElementManifestEntry[];
98
+
95
99
  /** The resolved HTML shell. */
96
100
  readonly shell: string;
97
101
  }
@@ -105,7 +105,7 @@ export abstract class Component<
105
105
  abstract readonly name: string;
106
106
 
107
107
  /** Host element reference, set by ComponentElement in the browser. */
108
- element?: HTMLElement;
108
+ element?: HTMLElement | undefined;
109
109
 
110
110
  /** Associated file paths for pre-loaded content (html, md, css). */
111
111
  readonly files?: { html?: string; md?: string; css?: string };
@@ -18,6 +18,17 @@ import { HTMLElementBase, LAZY_ATTR, SSR_ATTR } from '../util/html.util.ts';
18
18
 
19
19
  type ComponentState = 'idle' | 'loading' | 'ready' | 'error';
20
20
 
21
+ /** Strip keys with undefined values — returns the filtered object, or undefined if all values are undefined. */
22
+ function filterUndefined<T extends Record<string, unknown>>(obj: T): { [K in keyof T as T[K] extends undefined ? never : K]: NonNullable<T[K]> } | undefined {
23
+ const result: Record<string, unknown> = {};
24
+ let hasValue = false;
25
+ for (const [k, v] of Object.entries(obj)) {
26
+ if (v !== undefined) { result[k] = v; hasValue = true; }
27
+ }
28
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
29
+ return hasValue ? result as any : undefined;
30
+ }
31
+
21
32
  type WidgetFiles = { html?: string; md?: string; css?: string };
22
33
 
23
34
  /**
@@ -42,7 +53,7 @@ export class ComponentElement<TParams, TData> extends HTMLElementBase {
42
53
  }
43
54
 
44
55
  private component: Component<TParams, TData>;
45
- private effectiveFiles?: WidgetFiles;
56
+ private effectiveFiles?: WidgetFiles | undefined;
46
57
  private params: TParams | null = null;
47
58
  private data: TData | null = null;
48
59
  private context!: ComponentContext;
@@ -231,12 +242,13 @@ export class ComponentElement<TParams, TData> extends HTMLElementBase {
231
242
  if (signal.aborted) return;
232
243
 
233
244
  const currentUrl = globalThis.location ? new URL(location.href) : new URL('http://localhost/');
245
+ const filteredFiles = filterUndefined(files);
234
246
  const base: ComponentContext = {
235
247
  url: currentUrl,
236
248
  pathname: currentUrl.pathname,
237
249
  searchParams: currentUrl.searchParams,
238
250
  params: this.params ?? {},
239
- files: (files.html || files.md || files.css) ? files : undefined,
251
+ ...(filteredFiles ? { files: filteredFiles } : {}),
240
252
  };
241
253
  this.context = ComponentElement.extendContext ? ComponentElement.extendContext(base) : base;
242
254
 
@@ -350,7 +362,7 @@ export class ComponentElement<TParams, TData> extends HTMLElementBase {
350
362
  filePaths.css ? ComponentElement.loadFile(filePaths.css) : undefined,
351
363
  ]);
352
364
 
353
- return { html, md, css };
365
+ return filterUndefined({ html, md, css }) ?? {};
354
366
  }
355
367
 
356
368
  private async loadData(): Promise<void> {
@@ -364,7 +376,7 @@ export class ComponentElement<TParams, TData> extends HTMLElementBase {
364
376
  try {
365
377
  const promise = this.component.getData({
366
378
  params: this.params,
367
- signal,
379
+ ...(signal ? { signal } : {}),
368
380
  context: this.context,
369
381
  });
370
382
  this.dataPromise = promise;
@@ -80,7 +80,7 @@ export class MarkdownElement extends HTMLElementBase {
80
80
  const signal = this.abortController?.signal;
81
81
 
82
82
  try {
83
- const response = await fetch(src, { signal });
83
+ const response = await fetch(src, signal ? { signal } : {});
84
84
 
85
85
  if (!response.ok) {
86
86
  throw new Error(`Failed to fetch ${src}: ${response.status}`);
package/src/index.ts CHANGED
@@ -40,6 +40,7 @@ export type {
40
40
  WidgetsManifest,
41
41
  } from './type/widget.type.ts';
42
42
 
43
+ export type { ElementManifestEntry } from './type/element.type.ts';
43
44
  export type { MarkdownRenderer } from './type/markdown.type.ts';
44
45
  export { type Logger, setLogger } from './type/logger.type.ts';
45
46
 
@@ -11,10 +11,11 @@
11
11
  import type { EmrouteServer } from '../../../server/server-api.type.ts';
12
12
  import { createEmrouteServer } from '../../../server/emroute.server.ts';
13
13
  import { FetchRuntime } from '../../../runtime/fetch.runtime.ts';
14
- import { ROUTES_MANIFEST_PATH, WIDGETS_MANIFEST_PATH } from '../../../runtime/abstract.runtime.ts';
14
+ import { ROUTES_MANIFEST_PATH, WIDGETS_MANIFEST_PATH, ELEMENTS_MANIFEST_PATH } from '../../../runtime/abstract.runtime.ts';
15
15
  import type { RouteNode } from '../../type/route-tree.type.ts';
16
16
  import type { NavigateOptions } from '../../type/route.type.ts';
17
17
  import type { WidgetManifestEntry } from '../../type/widget.type.ts';
18
+ import type { ElementManifestEntry } from '../../type/element.type.ts';
18
19
  import { assertSafeRedirect, type BasePath, DEFAULT_BASE_PATH } from '../../route/route.core.ts';
19
20
  import { escapeHtml } from '../../util/html.util.ts';
20
21
  import { ComponentElement } from '../../element/component.element.ts';
@@ -207,8 +208,14 @@ export async function bootEmrouteApp(options?: BootOptions): Promise<EmrouteApp>
207
208
  ? await widgetsResponse.json()
208
209
  : [];
209
210
 
210
- // Build lazy module loaders for all route + widget modules
211
- const moduleLoaders = buildLazyLoaders(routeTree, widgetEntries, runtime);
211
+ // Fetch element manifest (optional app may have no custom elements)
212
+ const elementsResponse = await runtime.handle(ELEMENTS_MANIFEST_PATH);
213
+ const elementEntries: ElementManifestEntry[] = elementsResponse.ok
214
+ ? await elementsResponse.json()
215
+ : [];
216
+
217
+ // Build lazy module loaders for all route + widget + element modules
218
+ const moduleLoaders = buildLazyLoaders(routeTree, widgetEntries, elementEntries, runtime);
212
219
 
213
220
  // Register widgets eagerly (tag defined immediately, module loads on connectedCallback)
214
221
  const widgets = new WidgetRegistry();
@@ -216,24 +223,41 @@ export async function bootEmrouteApp(options?: BootOptions): Promise<EmrouteApp>
216
223
  ComponentElement.registerLazy(entry.name, entry.files, moduleLoaders[entry.modulePath]);
217
224
  }
218
225
 
226
+ // Register custom elements — import all modules, define when loaded
227
+ for (const entry of elementEntries) {
228
+ const loader = moduleLoaders[entry.modulePath];
229
+ if (loader) {
230
+ loader().then((mod) => {
231
+ const cls = (mod as Record<string, unknown>).default;
232
+ if (typeof cls === 'function' && !customElements.get(entry.tagName)) {
233
+ customElements.define(entry.tagName, cls as CustomElementConstructor);
234
+ }
235
+ }).catch((e) => {
236
+ console.error(`[emroute] Failed to load element ${entry.tagName}:`, e);
237
+ });
238
+ }
239
+ }
240
+
219
241
  // Create the server (reuses the same createEmrouteServer as SSR)
242
+ const mdRenderer = MarkdownElement.getConfiguredRenderer();
220
243
  const server = await createEmrouteServer({
221
244
  routeTree,
222
245
  widgets,
223
246
  moduleLoaders,
224
- markdownRenderer: MarkdownElement.getConfiguredRenderer() ?? undefined,
247
+ ...(mdRenderer ? { markdownRenderer: mdRenderer } : {}),
225
248
  }, runtime);
226
249
 
227
250
  return createEmrouteApp(server, options);
228
251
  }
229
252
 
230
253
  /**
231
- * Walk the route tree and widget entries to build a map of
254
+ * Walk the route tree, widget entries, and element entries to build a map of
232
255
  * `path → () => runtime.loadModule(path)` lazy loaders.
233
256
  */
234
257
  function buildLazyLoaders(
235
258
  tree: RouteNode,
236
259
  widgetEntries: WidgetManifestEntry[],
260
+ elementEntries: ElementManifestEntry[],
237
261
  runtime: FetchRuntime,
238
262
  ): Record<string, () => Promise<unknown>> {
239
263
  const paths = new Set<string>();
@@ -252,6 +276,7 @@ function buildLazyLoaders(
252
276
 
253
277
  walk(tree);
254
278
  for (const entry of widgetEntries) paths.add(entry.modulePath);
279
+ for (const entry of elementEntries) paths.add(entry.modulePath);
255
280
 
256
281
  const loaders: Record<string, () => Promise<unknown>> = {};
257
282
  for (const path of paths) {
@@ -87,7 +87,7 @@ export class SsrHtmlRouter extends SsrRenderer {
87
87
  );
88
88
  }
89
89
 
90
- return { content, title };
90
+ return { content, ...(title != null ? { title } : {}) };
91
91
  }
92
92
 
93
93
  protected override renderContent(
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import type { RouteConfig, RouteInfo } from '../../type/route.type.ts';
9
+ import type { ComponentContext } from '../../component/abstract.component.ts';
9
10
  import type { RouteResolver } from '../../route/route.resolver.ts';
10
11
  import type { PageComponent } from '../../component/page.component.ts';
11
12
  import { DEFAULT_ROOT_ROUTE } from '../../route/route.core.ts';
@@ -68,7 +69,7 @@ export class SsrMdRouter extends SsrRenderer {
68
69
  content = await this.resolveWidgets(content, routeInfo);
69
70
  }
70
71
 
71
- return { content, title };
72
+ return { content, ...(title != null ? { title } : {}) };
72
73
  }
73
74
 
74
75
  protected override renderContent(
@@ -118,13 +119,13 @@ export class SsrMdRouter extends SsrRenderer {
118
119
  files = await this.core.loadWidgetFiles(filePaths);
119
120
  }
120
121
 
121
- const baseContext = {
122
+ const baseContext: ComponentContext = {
122
123
  ...routeInfo,
123
124
  pathname: routeInfo.url.pathname,
124
125
  searchParams: routeInfo.url.searchParams,
125
- files,
126
+ ...(files ? { files } : {}),
126
127
  };
127
- const context = this.core.contextProvider
128
+ const context: ComponentContext = this.core.contextProvider
128
129
  ? this.core.contextProvider(baseContext)
129
130
  : baseContext;
130
131
  const data = await widget.getData({ params: block.params, context });
@@ -60,7 +60,7 @@ export abstract class SsrRenderer {
60
60
  try {
61
61
  const ri: RouteInfo = { url, params: {} };
62
62
  const result = await this.renderRouteContent(ri, statusPage, undefined, signal);
63
- return { content: this.stripSlots(result.content), status: 404, title: result.title };
63
+ return { content: this.stripSlots(result.content), status: 404, ...(result.title != null ? { title: result.title } : {}) };
64
64
  } catch (e) {
65
65
  logger.error(
66
66
  `[${this.label}] Failed to render 404 status page for ${url.pathname}`,
@@ -89,7 +89,7 @@ export abstract class SsrRenderer {
89
89
 
90
90
  try {
91
91
  const { content, title } = await this.renderPage(routeInfo, matched, signal);
92
- return { content, status: 200, title };
92
+ return { content, status: 200, ...(title != null ? { title } : {}) };
93
93
  } catch (error) {
94
94
  if (error instanceof Response) {
95
95
  const statusPage = this.core.getStatusPage(error.status);
@@ -100,7 +100,7 @@ export abstract class SsrRenderer {
100
100
  return {
101
101
  content: this.stripSlots(result.content),
102
102
  status: error.status,
103
- title: result.title,
103
+ ...(result.title != null ? { title: result.title } : {}),
104
104
  };
105
105
  } catch (e) {
106
106
  logger.error(
@@ -196,7 +196,7 @@ export abstract class SsrRenderer {
196
196
 
197
197
  result = this.stripSlots(result);
198
198
 
199
- return { content: result, title: pageTitle };
199
+ return { content: result, ...(pageTitle != null ? { title: pageTitle } : {}) };
200
200
  }
201
201
 
202
202
  protected abstract renderRouteContent(
@@ -221,11 +221,11 @@ export abstract class SsrRenderer {
221
221
  : defaultPageComponent;
222
222
 
223
223
  const context = await this.core.buildComponentContext(routeInfo, route, signal, isLeaf);
224
- const data = await component.getData({ params: routeInfo.params, signal, context });
224
+ const data = await component.getData({ params: routeInfo.params, ...(signal ? { signal } : {}), context });
225
225
  const content = this.renderContent(component, { data, params: routeInfo.params, context });
226
226
  const title = component.getTitle({ data, params: routeInfo.params, context });
227
227
 
228
- return { content, title };
228
+ return { content, ...(title != null ? { title } : {}) };
229
229
  }
230
230
 
231
231
  /** Render a component to the output format (HTML or Markdown). */