@emkodev/emroute 1.0.3 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/LICENSE +28 -0
  2. package/README.md +147 -12
  3. package/package.json +48 -7
  4. package/runtime/abstract.runtime.ts +441 -0
  5. package/runtime/bun/esbuild-runtime-loader.plugin.ts +94 -0
  6. package/runtime/bun/fs/bun-fs.runtime.ts +245 -0
  7. package/runtime/bun/sqlite/bun-sqlite.runtime.ts +279 -0
  8. package/runtime/sitemap.generator.ts +180 -0
  9. package/server/codegen.util.ts +66 -0
  10. package/server/emroute.server.ts +398 -0
  11. package/server/esbuild-manifest.plugin.ts +243 -0
  12. package/server/scanner.util.ts +243 -0
  13. package/server/server-api.type.ts +90 -0
  14. package/src/component/abstract.component.ts +229 -0
  15. package/src/component/page.component.ts +134 -0
  16. package/src/component/widget.component.ts +85 -0
  17. package/src/element/component.element.ts +353 -0
  18. package/src/element/markdown.element.ts +107 -0
  19. package/src/element/slot.element.ts +31 -0
  20. package/src/index.ts +61 -0
  21. package/src/overlay/mod.ts +10 -0
  22. package/src/overlay/overlay.css.ts +170 -0
  23. package/src/overlay/overlay.service.ts +348 -0
  24. package/src/overlay/overlay.type.ts +38 -0
  25. package/src/renderer/spa/base.renderer.ts +186 -0
  26. package/src/renderer/spa/hash.renderer.ts +215 -0
  27. package/src/renderer/spa/html.renderer.ts +382 -0
  28. package/src/renderer/spa/mod.ts +76 -0
  29. package/src/renderer/ssr/html.renderer.ts +159 -0
  30. package/src/renderer/ssr/md.renderer.ts +142 -0
  31. package/src/renderer/ssr/ssr.renderer.ts +286 -0
  32. package/src/route/route.core.ts +316 -0
  33. package/src/route/route.matcher.ts +260 -0
  34. package/src/type/logger.type.ts +24 -0
  35. package/src/type/markdown.type.ts +21 -0
  36. package/src/type/navigation-api.d.ts +95 -0
  37. package/src/type/route.type.ts +149 -0
  38. package/src/type/widget.type.ts +65 -0
  39. package/src/util/html.util.ts +186 -0
  40. package/src/util/logger.util.ts +83 -0
  41. package/src/util/widget-resolve.util.ts +197 -0
  42. package/src/web-doc/index.md +15 -0
  43. package/src/widget/breadcrumb.widget.ts +106 -0
  44. package/src/widget/page-title.widget.ts +52 -0
  45. package/src/widget/widget.parser.ts +89 -0
  46. package/src/widget/widget.registry.ts +51 -0
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Code Generation Utilities
3
+ *
4
+ * Generates a default main.ts entry point for SPA bootstrapping.
5
+ * Manifest data is resolved at bundle time via the esbuild virtual
6
+ * manifest plugin (`emroute:routes`, `emroute:widgets`).
7
+ */
8
+
9
+ import type { BasePath } from '../src/route/route.core.ts';
10
+ import type { SpaMode } from '../src/type/widget.type.ts';
11
+
12
+ /**
13
+ * Generate a main.ts entry point for SPA bootstrapping.
14
+ *
15
+ * Imports route and widget manifests from virtual `emroute:` specifiers
16
+ * that the esbuild manifest plugin resolves at bundle time.
17
+ */
18
+ export function generateMainTs(
19
+ spa: SpaMode,
20
+ hasRoutes: boolean,
21
+ hasWidgets: boolean,
22
+ importPath: string,
23
+ basePath?: BasePath,
24
+ ): string {
25
+ const imports: string[] = [];
26
+ const body: string[] = [];
27
+
28
+ const spaImport = `${importPath}/spa`;
29
+
30
+ if (hasRoutes) {
31
+ imports.push(`import { routesManifest } from 'emroute:routes';`);
32
+ }
33
+
34
+ if (hasWidgets) {
35
+ imports.push(`import { ComponentElement } from '${spaImport}';`);
36
+ imports.push(`import { widgetsManifest } from 'emroute:widgets';`);
37
+ body.push('for (const entry of widgetsManifest.widgets) {');
38
+ body.push(
39
+ ' const mod = await widgetsManifest.moduleLoaders![entry.modulePath]() as Record<string, unknown>;',
40
+ );
41
+ body.push(' for (const exp of Object.values(mod)) {');
42
+ body.push(" if (exp && typeof exp === 'object' && 'getData' in exp) {");
43
+ body.push(' ComponentElement.register(exp as any, entry.files);');
44
+ body.push(' break;');
45
+ body.push(' }');
46
+ body.push(" if (typeof exp === 'function' && exp.prototype?.getData) {");
47
+ body.push(
48
+ ' ComponentElement.registerClass(exp as new () => any, entry.name, entry.files);',
49
+ );
50
+ body.push(' break;');
51
+ body.push(' }');
52
+ body.push(' }');
53
+ body.push('}');
54
+ }
55
+
56
+ if ((spa === 'root' || spa === 'only') && hasRoutes) {
57
+ imports.push(`import { createSpaHtmlRouter } from '${spaImport}';`);
58
+ const bpOpt = basePath ? `basePath: { html: '${basePath.html}', md: '${basePath.md}' }` : '';
59
+ const opts = bpOpt ? `{ ${bpOpt} }` : '';
60
+ body.push(`await createSpaHtmlRouter(routesManifest${opts ? `, ${opts}` : ''});`);
61
+ }
62
+
63
+ return `/** Auto-generated entry point — do not edit. */\n${imports.join('\n')}\n\n${
64
+ body.join('\n')
65
+ }\n`;
66
+ }
@@ -0,0 +1,398 @@
1
+ /**
2
+ * Emroute Server
3
+ *
4
+ * Runtime-agnostic server that handles SSR rendering, manifest resolution,
5
+ * static file serving, and route matching. Works with any Runtime implementation.
6
+ *
7
+ * Usage (standalone):
8
+ * ```ts
9
+ * import { createEmrouteServer } from '@emkodev/emroute/server';
10
+ * import { BunFsRuntime } from '@emkodev/emroute/runtime/bun/fs';
11
+ *
12
+ * const runtime = new BunFsRuntime('.', { routesDir: '/routes' });
13
+ * const emroute = await createEmrouteServer({ spa: 'root' }, runtime);
14
+ *
15
+ * Bun.serve({ fetch: (req) => emroute.handleRequest(req) ?? new Response('Not Found', { status: 404 }) });
16
+ * ```
17
+ *
18
+ * Usage (composable):
19
+ * ```ts
20
+ * const emroute = await createEmrouteServer(config, runtime);
21
+ *
22
+ * Bun.serve({ async fetch(req) {
23
+ * if (isApiRoute(req)) return handleApi(req);
24
+ * const response = await emroute.handleRequest(req);
25
+ * if (response) return response;
26
+ * return new Response('Not Found', { status: 404 });
27
+ * }});
28
+ * ```
29
+ */
30
+
31
+ import { DEFAULT_BASE_PATH, prefixManifest } from '../src/route/route.core.ts';
32
+ import { SsrHtmlRouter } from '../src/renderer/ssr/html.renderer.ts';
33
+ import { SsrMdRouter } from '../src/renderer/ssr/md.renderer.ts';
34
+ import type { RoutesManifest } from '../src/type/route.type.ts';
35
+ import type { WidgetManifestEntry } from '../src/type/widget.type.ts';
36
+ import { WidgetRegistry } from '../src/widget/widget.registry.ts';
37
+ import type { WidgetComponent } from '../src/component/widget.component.ts';
38
+ import { escapeHtml } from '../src/util/html.util.ts';
39
+ import {
40
+ ROUTES_MANIFEST_PATH,
41
+ Runtime,
42
+ WIDGETS_MANIFEST_PATH,
43
+ } from '../runtime/abstract.runtime.ts';
44
+ import type { EmrouteServer, EmrouteServerConfig } from './server-api.type.ts';
45
+
46
+ // ── Module loaders ─────────────────────────────────────────────────────
47
+
48
+ /**
49
+ * Create module loaders for server-side SSR imports.
50
+ * Uses `runtime.loadModule()` — each runtime decides how to load modules
51
+ * (filesystem import, SQLite transpile + blob URL, etc.).
52
+ */
53
+ function createModuleLoaders(
54
+ manifest: RoutesManifest,
55
+ runtime: Runtime,
56
+ ): Record<string, () => Promise<unknown>> {
57
+ const loaders: Record<string, () => Promise<unknown>> = {};
58
+
59
+ const modulePaths = new Set<string>();
60
+
61
+ for (const route of manifest.routes) {
62
+ if (route.files?.ts) modulePaths.add(route.files.ts);
63
+ if (route.modulePath.endsWith('.ts')) modulePaths.add(route.modulePath);
64
+ }
65
+ for (const boundary of manifest.errorBoundaries) {
66
+ modulePaths.add(boundary.modulePath);
67
+ }
68
+ if (manifest.errorHandler) {
69
+ modulePaths.add(manifest.errorHandler.modulePath);
70
+ }
71
+ for (const [_, statusRoute] of manifest.statusPages) {
72
+ if (statusRoute.modulePath.endsWith('.ts')) {
73
+ modulePaths.add(statusRoute.modulePath);
74
+ }
75
+ }
76
+
77
+ for (const path of modulePaths) {
78
+ loaders[path] = () => runtime.loadModule(path);
79
+ }
80
+
81
+ return loaders;
82
+ }
83
+
84
+ // ── Widget helpers ─────────────────────────────────────────────────────
85
+
86
+ /** Find a WidgetComponent export from a module. */
87
+ function extractWidgetExport(
88
+ mod: Record<string, unknown>,
89
+ ): WidgetComponent | null {
90
+ for (const value of Object.values(mod)) {
91
+ if (!value) continue;
92
+ if (typeof value === 'object' && 'getData' in value) {
93
+ return value as WidgetComponent;
94
+ }
95
+ if (typeof value === 'function' && value.prototype?.getData) {
96
+ return new (value as new () => WidgetComponent)();
97
+ }
98
+ }
99
+ return null;
100
+ }
101
+
102
+ /** Import widget modules for SSR via runtime.loadModule(). */
103
+ async function importWidgets(
104
+ entries: WidgetManifestEntry[],
105
+ runtime: Runtime,
106
+ manual?: WidgetRegistry,
107
+ ): Promise<{
108
+ registry: WidgetRegistry;
109
+ widgetFiles: Record<string, { html?: string; md?: string; css?: string }>;
110
+ }> {
111
+ const registry = new WidgetRegistry();
112
+
113
+ for (const entry of entries) {
114
+ try {
115
+ const runtimePath = entry.modulePath.startsWith('/')
116
+ ? entry.modulePath
117
+ : `/${entry.modulePath}`;
118
+
119
+ const mod = await runtime.loadModule(runtimePath) as Record<string, unknown>;
120
+ const instance = extractWidgetExport(mod);
121
+ if (!instance) continue;
122
+ registry.add(instance);
123
+ } catch (e) {
124
+ console.error(`[emroute] Failed to load widget ${entry.modulePath}:`, e);
125
+ }
126
+ }
127
+
128
+ if (manual) {
129
+ for (const widget of manual) {
130
+ registry.add(widget);
131
+ }
132
+ }
133
+
134
+ const widgetFiles: Record<string, { html?: string; md?: string; css?: string }> = {};
135
+ for (const entry of entries) {
136
+ if (entry.files) widgetFiles[entry.name] = entry.files;
137
+ }
138
+
139
+ return { registry, widgetFiles };
140
+ }
141
+
142
+ // ── HTML shell ─────────────────────────────────────────────────────────
143
+
144
+ /** Build a default HTML shell. */
145
+ function buildHtmlShell(title: string): string {
146
+ return `<!DOCTYPE html>
147
+ <html>
148
+ <head>
149
+ <meta charset="utf-8">
150
+ <meta name="viewport" content="width=device-width, initial-scale=1">
151
+ <title>${escapeHtml(title)}</title>
152
+ <style>@view-transition { navigation: auto; } router-slot { display: contents; }</style>
153
+ </head>
154
+ <body>
155
+ <router-slot></router-slot>
156
+ </body>
157
+ </html>`;
158
+ }
159
+
160
+ /** Inject SSR-rendered content into an HTML shell. */
161
+ function injectSsrContent(
162
+ html: string,
163
+ content: string,
164
+ title: string | undefined,
165
+ ssrRoute?: string,
166
+ ): string {
167
+ const slotPattern = /<router-slot\b[^>]*>.*?<\/router-slot>/s;
168
+ if (!slotPattern.test(html)) return html;
169
+
170
+ const ssrAttr = ssrRoute ? ` data-ssr-route="${ssrRoute}"` : '';
171
+ html = html.replace(slotPattern, `<router-slot${ssrAttr}>${content}</router-slot>`);
172
+
173
+ if (title) {
174
+ html = html.replace(/<title>[^<]*<\/title>/, `<title>${escapeHtml(title)}</title>`);
175
+ }
176
+
177
+ return html;
178
+ }
179
+
180
+ /** Read the HTML shell from runtime, with fallback to a default shell. */
181
+ async function resolveShell(
182
+ runtime: Runtime,
183
+ title: string,
184
+ ): Promise<string> {
185
+ const response = await runtime.query('/index.html');
186
+ if (response.status !== 404) return await response.text();
187
+ return buildHtmlShell(title);
188
+ }
189
+
190
+ // ── More path helpers ─────────────────────────────────────────────────
191
+
192
+ /** Deserialize a routes manifest from JSON (statusPages array → Map). */
193
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
194
+ function deserializeManifest(raw: any): RoutesManifest {
195
+ return {
196
+ routes: raw.routes,
197
+ errorBoundaries: raw.errorBoundaries,
198
+ statusPages: new Map(raw.statusPages ?? []),
199
+ errorHandler: raw.errorHandler,
200
+ };
201
+ }
202
+
203
+ // ── createEmrouteServer ────────────────────────────────────────────────
204
+
205
+ /**
206
+ * Create an emroute server.
207
+ *
208
+ * All paths are Runtime-relative (starting with `/`). Runtime root = appRoot.
209
+ */
210
+ export async function createEmrouteServer(
211
+ config: EmrouteServerConfig,
212
+ runtime: Runtime,
213
+ ): Promise<EmrouteServer> {
214
+ const {
215
+ spa = 'root',
216
+ } = config;
217
+
218
+ // Let the runtime know the SPA mode so bundle() can skip when 'none'.
219
+ runtime.config.spa = spa;
220
+
221
+ const { html: htmlBase, md: mdBase } = config.basePath ?? DEFAULT_BASE_PATH;
222
+
223
+ // ── Routes manifest (read from runtime) ─────────────────────────────
224
+
225
+ let routesManifest: RoutesManifest;
226
+
227
+ if (config.routesManifest) {
228
+ routesManifest = config.routesManifest;
229
+ } else {
230
+ const manifestResponse = await runtime.query(ROUTES_MANIFEST_PATH);
231
+ if (manifestResponse.status === 404) {
232
+ throw new Error(
233
+ `[emroute] ${ROUTES_MANIFEST_PATH} not found in runtime. ` +
234
+ 'Provide routesManifest in config or ensure the runtime produces it.',
235
+ );
236
+ }
237
+ const raw = await manifestResponse.json();
238
+ routesManifest = deserializeManifest(raw);
239
+ }
240
+
241
+ routesManifest.moduleLoaders = createModuleLoaders(routesManifest, runtime);
242
+
243
+ // ── Widgets (read from runtime) ────────────────────────────────────
244
+
245
+ let widgets: WidgetRegistry | undefined = config.widgets;
246
+ let widgetFiles: Record<string, { html?: string; md?: string; css?: string }> = {};
247
+ let discoveredWidgetEntries: WidgetManifestEntry[] = [];
248
+
249
+ const widgetsResponse = await runtime.query(WIDGETS_MANIFEST_PATH);
250
+ if (widgetsResponse.status !== 404) {
251
+ discoveredWidgetEntries = await widgetsResponse.json();
252
+ const imported = await importWidgets(discoveredWidgetEntries, runtime, config.widgets);
253
+ widgets = imported.registry;
254
+ widgetFiles = imported.widgetFiles;
255
+ }
256
+
257
+ // ── SSR routers ──────────────────────────────────────────────────────
258
+
259
+ let ssrHtmlRouter: SsrHtmlRouter | null = null;
260
+ let ssrMdRouter: SsrMdRouter | null = null;
261
+
262
+ function buildSsrRouters(): void {
263
+ if (spa === 'only') {
264
+ ssrHtmlRouter = null;
265
+ ssrMdRouter = null;
266
+ return;
267
+ }
268
+
269
+ ssrHtmlRouter = new SsrHtmlRouter(prefixManifest(routesManifest, htmlBase), {
270
+ fileReader: (path) => runtime.query(path, { as: 'text' }),
271
+ basePath: htmlBase,
272
+ markdownRenderer: config.markdownRenderer,
273
+ extendContext: config.extendContext,
274
+ widgets,
275
+ widgetFiles,
276
+ });
277
+
278
+ ssrMdRouter = new SsrMdRouter(prefixManifest(routesManifest, mdBase), {
279
+ fileReader: (path) => runtime.query(path, { as: 'text' }),
280
+ basePath: mdBase,
281
+ extendContext: config.extendContext,
282
+ widgets,
283
+ widgetFiles,
284
+ });
285
+ }
286
+
287
+ buildSsrRouters();
288
+
289
+ // ── Bundling (runtime decides whether/how to bundle) ────────────────
290
+
291
+ await runtime.bundle();
292
+
293
+ // ── HTML shell ───────────────────────────────────────────────────────
294
+
295
+ const title = config.title ?? 'emroute';
296
+ let shell = await resolveShell(runtime, title);
297
+
298
+ // Auto-discover main.css and inject <link> into <head>
299
+ if ((await runtime.query('/main.css')).status !== 404) {
300
+ shell = shell.replace('</head>', ' <link rel="stylesheet" href="/main.css">\n</head>');
301
+ }
302
+
303
+ // ── handleRequest ────────────────────────────────────────────────────
304
+
305
+ async function handleRequest(req: Request): Promise<Response | null> {
306
+ const url = new URL(req.url);
307
+ const pathname = url.pathname;
308
+
309
+ const mdPrefix = mdBase + '/';
310
+ const htmlPrefix = htmlBase + '/';
311
+
312
+ // SSR Markdown: /md/*
313
+ if (
314
+ ssrMdRouter &&
315
+ (pathname.startsWith(mdPrefix) || pathname === mdBase)
316
+ ) {
317
+ try {
318
+ const { content, status, redirect } = await ssrMdRouter.render(pathname);
319
+ if (redirect) {
320
+ return Response.redirect(new URL(redirect, url.origin), status);
321
+ }
322
+ return new Response(content, {
323
+ status,
324
+ headers: { 'Content-Type': 'text/markdown; charset=utf-8; variant=CommonMark' },
325
+ });
326
+ } catch (e) {
327
+ console.error(`[emroute] Error rendering ${pathname}:`, e);
328
+ return new Response('Internal Server Error', { status: 500 });
329
+ }
330
+ }
331
+
332
+ // SSR HTML: /html/*
333
+ if (
334
+ ssrHtmlRouter &&
335
+ (pathname.startsWith(htmlPrefix) || pathname === htmlBase)
336
+ ) {
337
+ try {
338
+ const result = await ssrHtmlRouter.render(pathname);
339
+ if (result.redirect) {
340
+ return Response.redirect(new URL(result.redirect, url.origin), result.status);
341
+ }
342
+ const ssrTitle = result.title ?? title;
343
+ const html = injectSsrContent(shell, result.content, ssrTitle, pathname);
344
+ return new Response(html, {
345
+ status: result.status,
346
+ headers: { 'Content-Type': 'text/html; charset=utf-8' },
347
+ });
348
+ } catch (e) {
349
+ console.error(`[emroute] Error rendering ${pathname}:`, e);
350
+ return new Response('Internal Server Error', { status: 500 });
351
+ }
352
+ }
353
+
354
+ // /html/* or /md/* that wasn't handled by SSR (e.g. 'only' mode) — serve SPA shell
355
+ if (
356
+ pathname.startsWith(htmlPrefix) || pathname === htmlBase ||
357
+ pathname.startsWith(mdPrefix) || pathname === mdBase
358
+ ) {
359
+ return new Response(shell, {
360
+ status: 200,
361
+ headers: { 'Content-Type': 'text/html; charset=utf-8' },
362
+ });
363
+ }
364
+
365
+ // Static files — only try runtime for paths with a file extension
366
+ const lastSegment = pathname.split('/').pop() ?? '';
367
+ if (lastSegment.includes('.')) {
368
+ const fileResponse = await runtime.handle(pathname);
369
+ if (fileResponse.status === 200) return fileResponse;
370
+ return null;
371
+ }
372
+
373
+ // Bare paths — redirect to /html/* in all modes.
374
+ const bare = pathname === '/' ? '' : pathname.slice(1).replace(/\/$/, '');
375
+ return Response.redirect(new URL(`${htmlBase}/${bare}`, url.origin), 302);
376
+ }
377
+
378
+ // ── Return ───────────────────────────────────────────────────────────
379
+
380
+ return {
381
+ handleRequest,
382
+ get htmlRouter() {
383
+ return ssrHtmlRouter;
384
+ },
385
+ get mdRouter() {
386
+ return ssrMdRouter;
387
+ },
388
+ get manifest() {
389
+ return routesManifest;
390
+ },
391
+ get widgetEntries() {
392
+ return discoveredWidgetEntries;
393
+ },
394
+ get shell() {
395
+ return shell;
396
+ },
397
+ };
398
+ }