@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,243 @@
1
+ /**
2
+ * esbuild Virtual Manifest Plugin
3
+ *
4
+ * Intercepts `emroute:routes` and `emroute:widgets` import specifiers.
5
+ * Reads JSON manifests from the runtime and generates TypeScript modules
6
+ * with `moduleLoaders` (dynamic `import()` calls) in-memory — no .g.ts
7
+ * files on disk.
8
+ *
9
+ * This is the single source of truth: JSON manifest → esbuild bundle.
10
+ */
11
+
12
+ import type { Runtime } from '../runtime/abstract.runtime.ts';
13
+ import { ROUTES_MANIFEST_PATH, WIDGETS_MANIFEST_PATH } from '../runtime/abstract.runtime.ts';
14
+
15
+ /** Escape a string for use inside a single-quoted JS/TS string literal. */
16
+ function esc(value: string): string {
17
+ return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
18
+ }
19
+
20
+ interface ManifestPluginOptions {
21
+ runtime: Runtime;
22
+ /** HTML base path prefix for route patterns (e.g. '/html'). */
23
+ basePath?: string;
24
+ /**
25
+ * Directory prefix to strip from module paths so that import() calls
26
+ * are relative to the entry point (e.g. 'routes/' strips '/routes/').
27
+ */
28
+ stripPrefix?: string;
29
+ /** Absolute directory for resolving relative import() paths in generated code. */
30
+ resolveDir: string;
31
+ }
32
+
33
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
34
+ type EsbuildPlugin = any;
35
+
36
+ export function createManifestPlugin(options: ManifestPluginOptions): EsbuildPlugin {
37
+ const { runtime, basePath = '', stripPrefix = '', resolveDir } = options;
38
+
39
+ const prefixPattern = (pattern: string): string =>
40
+ basePath ? (pattern === '/' ? basePath : basePath + pattern) : pattern;
41
+
42
+ const strip = (p: string): string =>
43
+ stripPrefix && p.startsWith(stripPrefix) ? p.slice(stripPrefix.length) : p;
44
+
45
+ return {
46
+ name: 'emroute-manifest',
47
+
48
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
49
+ setup(build: any) {
50
+ // ── Resolve virtual specifiers ──────────────────────────────────
51
+ build.onResolve(
52
+ { filter: /^emroute:/ },
53
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
54
+ (args: any) => ({ path: args.path, namespace: 'emroute' }),
55
+ );
56
+
57
+ // ── Load virtual modules ────────────────────────────────────────
58
+ build.onLoad(
59
+ { filter: /.*/, namespace: 'emroute' },
60
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
61
+ async (args: any) => {
62
+ if (args.path === 'emroute:routes') {
63
+ return { contents: await generateRoutesModule(), loader: 'ts' as const, resolveDir };
64
+ }
65
+ if (args.path === 'emroute:widgets') {
66
+ return { contents: await generateWidgetsModule(), loader: 'ts' as const, resolveDir };
67
+ }
68
+ return undefined;
69
+ },
70
+ );
71
+ },
72
+ };
73
+
74
+ // ── Routes module generator ───────────────────────────────────────
75
+
76
+ async function generateRoutesModule(): Promise<string> {
77
+ const response = await runtime.query(ROUTES_MANIFEST_PATH);
78
+ if (response.status === 404) {
79
+ return `export const routesManifest = { routes: [], errorBoundaries: [], statusPages: new Map(), moduleLoaders: {} };`;
80
+ }
81
+ const raw = await response.json();
82
+
83
+ // Routes array
84
+ const routesArray = (raw.routes ?? [])
85
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
86
+ .map((r: any) => {
87
+ const filesStr = r.files
88
+ ? `\n files: { ${
89
+ Object.entries(r.files)
90
+ .filter(([_, v]) => v)
91
+ .map(([k, v]) => `${k}: '${esc(strip(v as string))}'`)
92
+ .join(', ')
93
+ } },`
94
+ : '';
95
+
96
+ return ` {
97
+ pattern: '${esc(prefixPattern(r.pattern))}',
98
+ type: '${esc(r.type)}',
99
+ modulePath: '${esc(strip(r.modulePath))}',${filesStr}${
100
+ r.parent ? `\n parent: '${esc(prefixPattern(r.parent))}',` : ''
101
+ }${r.statusCode ? `\n statusCode: ${r.statusCode},` : ''}
102
+ }`;
103
+ })
104
+ .join(',\n');
105
+
106
+ // Error boundaries
107
+ const errorBoundariesArray = (raw.errorBoundaries ?? [])
108
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
109
+ .map((e: any) => ` {
110
+ pattern: '${esc(prefixPattern(e.pattern))}',
111
+ modulePath: '${esc(strip(e.modulePath))}',
112
+ }`)
113
+ .join(',\n');
114
+
115
+ // Status pages
116
+ const statusPagesEntries = (raw.statusPages ?? [])
117
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
118
+ .map(([status, route]: [number, any]) => {
119
+ const filesStr = route.files
120
+ ? `, files: { ${
121
+ Object.entries(route.files)
122
+ .map(([k, v]) => `${k}: '${esc(strip(v as string))}'`)
123
+ .join(', ')
124
+ } }`
125
+ : '';
126
+ return ` [${status}, { pattern: '${esc(prefixPattern(route.pattern))}', type: '${
127
+ esc(route.type)
128
+ }', modulePath: '${
129
+ esc(strip(route.modulePath))
130
+ }', statusCode: ${status}${filesStr} }]`;
131
+ })
132
+ .join(',\n');
133
+
134
+ // Error handler
135
+ const errorHandlerCode = raw.errorHandler
136
+ ? `{
137
+ pattern: '${esc(prefixPattern(raw.errorHandler.pattern))}',
138
+ type: '${esc(raw.errorHandler.type)}',
139
+ modulePath: '${esc(strip(raw.errorHandler.modulePath))}',
140
+ }`
141
+ : 'undefined';
142
+
143
+ // Module loaders — collect all .ts module paths
144
+ const tsModulePaths = new Set<string>();
145
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
146
+ for (const route of (raw.routes ?? []) as any[]) {
147
+ if (route.files?.ts) tsModulePaths.add(route.files.ts);
148
+ if (route.modulePath.endsWith('.ts')) tsModulePaths.add(route.modulePath);
149
+ }
150
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
151
+ for (const boundary of (raw.errorBoundaries ?? []) as any[]) {
152
+ tsModulePaths.add(boundary.modulePath);
153
+ }
154
+ if (raw.errorHandler) {
155
+ tsModulePaths.add(raw.errorHandler.modulePath);
156
+ }
157
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
158
+ for (const [_, statusRoute] of (raw.statusPages ?? []) as [number, any][]) {
159
+ if (statusRoute.modulePath.endsWith('.ts')) {
160
+ tsModulePaths.add(statusRoute.modulePath);
161
+ }
162
+ }
163
+
164
+ const moduleLoadersCode = [...tsModulePaths]
165
+ .map((p) => {
166
+ const key = strip(p);
167
+ const rel = key.replace(/^\.?\//, '');
168
+ return ` '${esc(key)}': () => import('./${esc(rel)}'),`;
169
+ })
170
+ .join('\n');
171
+
172
+ return `import type { RoutesManifest } from '@emkodev/emroute';
173
+
174
+ export const routesManifest: RoutesManifest = {
175
+ routes: [
176
+ ${routesArray}
177
+ ],
178
+
179
+ errorBoundaries: [
180
+ ${errorBoundariesArray}
181
+ ],
182
+
183
+ statusPages: new Map([
184
+ ${statusPagesEntries}
185
+ ]),
186
+
187
+ errorHandler: ${errorHandlerCode},
188
+
189
+ moduleLoaders: {
190
+ ${moduleLoadersCode}
191
+ },
192
+ };
193
+ `;
194
+ }
195
+
196
+ // ── Widgets module generator ──────────────────────────────────────
197
+
198
+ async function generateWidgetsModule(): Promise<string> {
199
+ const response = await runtime.query(WIDGETS_MANIFEST_PATH);
200
+ if (response.status === 404) {
201
+ return `export const widgetsManifest = { widgets: [], moduleLoaders: {} };`;
202
+ }
203
+ const entries = await response.json();
204
+
205
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
206
+ const widgetEntries = (entries as any[]).map((e) => {
207
+ const filesStr = e.files
208
+ ? `\n files: { ${
209
+ Object.entries(e.files)
210
+ .filter(([_, v]) => v)
211
+ .map(([k, v]) => `${k}: '${esc(strip(v as string))}'`)
212
+ .join(', ')
213
+ } },`
214
+ : '';
215
+
216
+ return ` {
217
+ name: '${esc(e.name)}',
218
+ modulePath: '${esc(strip(e.modulePath))}',
219
+ tagName: '${esc(e.tagName)}',${filesStr}
220
+ }`;
221
+ }).join(',\n');
222
+
223
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
224
+ const loaderEntries = (entries as any[]).map((e) => {
225
+ const key = strip(e.modulePath);
226
+ const rel = key.replace(/^\.?\//, '');
227
+ return ` '${esc(key)}': () => import('./${esc(rel)}'),`;
228
+ }).join('\n');
229
+
230
+ return `import type { WidgetsManifest } from '@emkodev/emroute';
231
+
232
+ export const widgetsManifest: WidgetsManifest = {
233
+ widgets: [
234
+ ${widgetEntries}
235
+ ],
236
+
237
+ moduleLoaders: {
238
+ ${loaderEntries}
239
+ },
240
+ };
241
+ `;
242
+ }
243
+ }
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Standalone Scanning Utilities
3
+ *
4
+ * Runtime-agnostic route and widget scanning. Works with any Runtime
5
+ * that implements query() — used by tests with mock runtimes and by
6
+ * the CLI generate command.
7
+ *
8
+ * Note: BunFsRuntime has these as instance methods with lazy caching.
9
+ * These standalone functions are the same logic without caching.
10
+ */
11
+
12
+ import type { Runtime } from '../runtime/abstract.runtime.ts';
13
+ import {
14
+ filePathToPattern,
15
+ getPageFileType,
16
+ getRouteType,
17
+ sortRoutesBySpecificity,
18
+ } from '../src/route/route.matcher.ts';
19
+ import type {
20
+ ErrorBoundary,
21
+ RouteConfig,
22
+ RouteFiles,
23
+ RoutesManifest,
24
+ } from '../src/type/route.type.ts';
25
+ import type { WidgetManifestEntry } from '../src/type/widget.type.ts';
26
+
27
+ export interface GeneratorResult extends RoutesManifest {
28
+ warnings: string[];
29
+ }
30
+
31
+ /** Walk directory recursively and collect files via Runtime. */
32
+ async function* walkDirectory(runtime: Runtime, dir: string): AsyncGenerator<string> {
33
+ const trailingDir = dir.endsWith('/') ? dir : dir + '/';
34
+ const response = await runtime.query(trailingDir);
35
+ const entries: string[] = await response.json();
36
+
37
+ for (const entry of entries) {
38
+ const path = `${trailingDir}${entry}`;
39
+ if (entry.endsWith('/')) {
40
+ yield* walkDirectory(runtime, path);
41
+ } else {
42
+ yield path;
43
+ }
44
+ }
45
+ }
46
+
47
+ /** Generate routes manifest by scanning a directory via Runtime. */
48
+ export async function generateRoutesManifest(
49
+ routesDir: string,
50
+ runtime: Runtime,
51
+ ): Promise<GeneratorResult> {
52
+ const pageFiles: Array<{
53
+ path: string;
54
+ pattern: string;
55
+ fileType: 'ts' | 'html' | 'md' | 'css';
56
+ }> = [];
57
+ const redirects: RouteConfig[] = [];
58
+ const errorBoundaries: ErrorBoundary[] = [];
59
+ const statusPages = new Map<number, RouteConfig>();
60
+ let errorHandler: RouteConfig | undefined;
61
+
62
+ const allFiles: string[] = [];
63
+ for await (const file of walkDirectory(runtime, routesDir)) {
64
+ allFiles.push(file);
65
+ }
66
+
67
+ for (const filePath of allFiles) {
68
+ const relativePath = filePath.replace(`${routesDir}/`, '');
69
+ const filename = relativePath.split('/').pop() ?? '';
70
+
71
+ if (filename === 'index.error.ts' && relativePath === 'index.error.ts') {
72
+ errorHandler = {
73
+ pattern: '/',
74
+ type: 'error',
75
+ modulePath: filePath,
76
+ };
77
+ continue;
78
+ }
79
+
80
+ const cssFileType = getPageFileType(filename);
81
+ if (cssFileType === 'css') {
82
+ const pattern = filePathToPattern(relativePath);
83
+ pageFiles.push({ path: filePath, pattern, fileType: 'css' });
84
+ continue;
85
+ }
86
+
87
+ const routeType = getRouteType(filename);
88
+ if (!routeType) continue;
89
+
90
+ const statusMatch = filename.match(/^(\d{3})\.page\.(ts|html|md)$/);
91
+ if (statusMatch) {
92
+ const statusCode = parseInt(statusMatch[1], 10);
93
+ const fileType = getPageFileType(filename);
94
+ if (fileType) {
95
+ const existing = statusPages.get(statusCode);
96
+ if (existing) {
97
+ existing.files ??= {};
98
+ existing.files[fileType] = filePath;
99
+ existing.modulePath = existing.files.ts ?? existing.files.html ?? existing.files.md ?? '';
100
+ } else {
101
+ const files: RouteFiles = { [fileType]: filePath };
102
+ statusPages.set(statusCode, {
103
+ pattern: `/${statusCode}`,
104
+ type: 'page',
105
+ modulePath: filePath,
106
+ statusCode,
107
+ files,
108
+ });
109
+ }
110
+ }
111
+ continue;
112
+ }
113
+
114
+ const pattern = filePathToPattern(relativePath);
115
+
116
+ if (routeType === 'error') {
117
+ const boundaryPattern = pattern.replace(/\/[^/]+$/, '') || '/';
118
+ errorBoundaries.push({ pattern: boundaryPattern, modulePath: filePath });
119
+ continue;
120
+ }
121
+
122
+ if (routeType === 'redirect') {
123
+ redirects.push({ pattern, type: 'redirect', modulePath: filePath });
124
+ continue;
125
+ }
126
+
127
+ const fileType = getPageFileType(filename);
128
+ if (fileType) {
129
+ pageFiles.push({ path: filePath, pattern, fileType });
130
+ }
131
+ }
132
+
133
+ // Group files by pattern
134
+ const groups = new Map<string, { pattern: string; files: RouteFiles; parent?: string }>();
135
+ for (const { path, pattern, fileType } of pageFiles) {
136
+ let group = groups.get(pattern);
137
+ if (!group) {
138
+ group = { pattern, files: {} };
139
+ const segments = pattern.split('/').filter(Boolean);
140
+ if (segments.length > 1) {
141
+ group.parent = '/' + segments.slice(0, -1).join('/');
142
+ }
143
+ groups.set(pattern, group);
144
+ }
145
+ const existing = group.files[fileType];
146
+ if (existing?.includes('/index.page.') && !path.includes('/index.page.')) {
147
+ continue;
148
+ }
149
+ group.files[fileType] = path;
150
+ }
151
+
152
+ // Detect collisions
153
+ const warnings: string[] = [];
154
+ for (const [pattern, group] of groups) {
155
+ const filePaths = Object.values(group.files).filter(Boolean);
156
+ const hasIndex = filePaths.some((p) => p?.includes('/index.page.'));
157
+ const hasFlat = filePaths.some((p) => p && !p.includes('/index.page.'));
158
+ if (hasIndex && hasFlat) {
159
+ warnings.push(
160
+ `Warning: Mixed file structure for ${pattern}:\n` +
161
+ filePaths.map((p) => ` ${p}`).join('\n') +
162
+ `\n Both folder/index and flat files detected`,
163
+ );
164
+ }
165
+ }
166
+
167
+ // Convert groups to RouteConfig array
168
+ const routes: RouteConfig[] = [];
169
+ for (const [_, group] of groups) {
170
+ const modulePath = group.files.ts ?? group.files.html ?? group.files.md ?? '';
171
+ if (!modulePath) continue;
172
+ const route: RouteConfig = {
173
+ pattern: group.pattern,
174
+ type: 'page',
175
+ modulePath,
176
+ files: group.files,
177
+ };
178
+ if (group.parent) route.parent = group.parent;
179
+ routes.push(route);
180
+ }
181
+
182
+ routes.push(...redirects);
183
+ const sortedRoutes = sortRoutesBySpecificity(routes);
184
+
185
+ return {
186
+ routes: sortedRoutes,
187
+ errorBoundaries,
188
+ statusPages,
189
+ errorHandler,
190
+ warnings,
191
+ };
192
+ }
193
+
194
+ /**
195
+ * Discover widget modules and companion files by scanning a directory.
196
+ */
197
+ export async function discoverWidgets(
198
+ widgetsDir: string,
199
+ runtime: Runtime,
200
+ pathPrefix?: string,
201
+ ): Promise<WidgetManifestEntry[]> {
202
+ const COMPANION_EXTENSIONS = ['html', 'md', 'css'] as const;
203
+ const WIDGET_FILE_SUFFIX = '.widget.ts';
204
+ const entries: WidgetManifestEntry[] = [];
205
+
206
+ const trailingDir = widgetsDir.endsWith('/') ? widgetsDir : widgetsDir + '/';
207
+ const response = await runtime.query(trailingDir);
208
+ const listing: string[] = await response.json();
209
+
210
+ for (const item of listing) {
211
+ if (!item.endsWith('/')) continue;
212
+
213
+ const name = item.slice(0, -1);
214
+ const moduleFile = `${name}${WIDGET_FILE_SUFFIX}`;
215
+ const modulePath = `${trailingDir}${name}/${moduleFile}`;
216
+
217
+ if ((await runtime.query(modulePath)).status === 404) continue;
218
+
219
+ const prefix = pathPrefix ? `${pathPrefix}/` : '';
220
+ const entry: WidgetManifestEntry = {
221
+ name,
222
+ modulePath: `${prefix}${name}/${moduleFile}`,
223
+ tagName: `widget-${name}`,
224
+ };
225
+
226
+ const files: { html?: string; md?: string; css?: string } = {};
227
+ let hasFiles = false;
228
+ for (const ext of COMPANION_EXTENSIONS) {
229
+ const companionFile = `${name}.widget.${ext}`;
230
+ const companionPath = `${trailingDir}${name}/${companionFile}`;
231
+ if ((await runtime.query(companionPath)).status !== 404) {
232
+ files[ext] = `${prefix}${name}/${companionFile}`;
233
+ hasFiles = true;
234
+ }
235
+ }
236
+
237
+ if (hasFiles) entry.files = files;
238
+ entries.push(entry);
239
+ }
240
+
241
+ entries.sort((a, b) => a.name.localeCompare(b.name));
242
+ return entries;
243
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Server API Types
3
+ *
4
+ * Interfaces for the emroute server.
5
+ * Consumers use `createEmrouteServer()` to get a server that handles
6
+ * SSR rendering, static file serving, and route matching.
7
+ */
8
+
9
+ import type { RoutesManifest } from '../src/type/route.type.ts';
10
+ import type { MarkdownRenderer } from '../src/type/markdown.type.ts';
11
+ import type { SpaMode, WidgetManifestEntry } from '../src/type/widget.type.ts';
12
+ import type { ContextProvider } from '../src/component/abstract.component.ts';
13
+ import type { BasePath } from '../src/route/route.core.ts';
14
+ import type { WidgetRegistry } from '../src/widget/widget.registry.ts';
15
+ import type { SsrHtmlRouter } from '../src/renderer/ssr/html.renderer.ts';
16
+ import type { SsrMdRouter } from '../src/renderer/ssr/md.renderer.ts';
17
+
18
+ // ── SSR Render Result ──────────────────────────────────────────────────
19
+
20
+ /** Result of rendering a URL through an SSR renderer. */
21
+ export interface SsrRenderResult {
22
+ /** Rendered content (HTML or Markdown) */
23
+ content: string;
24
+ /** HTTP status code */
25
+ status: number;
26
+ /** Page title (from the leaf route's getTitle) */
27
+ title?: string;
28
+ /** Redirect target URL (for 301/302 responses) */
29
+ redirect?: string;
30
+ }
31
+
32
+ // ── Server ─────────────────────────────────────────────────────────────
33
+
34
+ /**
35
+ * Config for `createEmrouteServer()`.
36
+ *
37
+ * The server reads manifests from the Runtime and handles SSR rendering,
38
+ * static file serving, and route matching.
39
+ */
40
+ export interface EmrouteServerConfig {
41
+ /** Pre-built manifest (alternative to reading from runtime) */
42
+ routesManifest?: RoutesManifest;
43
+
44
+ /** Pre-built widget registry (alternative to reading from runtime) */
45
+ widgets?: WidgetRegistry;
46
+
47
+ /** SPA mode — controls which routers are constructed and what gets served */
48
+ spa?: SpaMode;
49
+
50
+ /** Base paths for SSR endpoints (default: { html: '/html', md: '/md' }) */
51
+ basePath?: BasePath;
52
+
53
+ /** Page title (fallback when no route provides one) */
54
+ title?: string;
55
+
56
+ /** Markdown renderer for server-side <mark-down> expansion */
57
+ markdownRenderer?: MarkdownRenderer;
58
+
59
+ /** Enrich every ComponentContext with app-level services. */
60
+ extendContext?: ContextProvider;
61
+ }
62
+
63
+ /**
64
+ * An emroute server instance.
65
+ *
66
+ * Handles SSR rendering, static file serving, and route matching.
67
+ * Use `handleRequest(req)` to compose with your own request handling.
68
+ */
69
+ export interface EmrouteServer {
70
+ /**
71
+ * Handle an HTTP request for SSR routes and bare paths.
72
+ * Returns `null` for unmatched file requests — consumer handles 404.
73
+ */
74
+ handleRequest(req: Request): Promise<Response | null>;
75
+
76
+ /** The SSR HTML router (null in 'only' mode — no server rendering). */
77
+ readonly htmlRouter: SsrHtmlRouter | null;
78
+
79
+ /** The SSR Markdown router (null in 'only' mode). */
80
+ readonly mdRouter: SsrMdRouter | null;
81
+
82
+ /** The resolved routes manifest. */
83
+ readonly manifest: RoutesManifest;
84
+
85
+ /** Discovered widget entries. */
86
+ readonly widgetEntries: WidgetManifestEntry[];
87
+
88
+ /** The resolved HTML shell. */
89
+ readonly shell: string;
90
+ }