@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,316 @@
1
+ /**
2
+ * Route Core
3
+ *
4
+ * Shared routing logic used by all renderers:
5
+ * - Route matching and hierarchy building
6
+ * - Module loading and caching
7
+ * - Event emission
8
+ * - URL normalization
9
+ */
10
+
11
+ import type {
12
+ MatchedRoute,
13
+ RouteConfig,
14
+ RouteInfo,
15
+ RouteParams,
16
+ RouterEvent,
17
+ RouterEventListener,
18
+ RoutesManifest,
19
+ } from '../type/route.type.ts';
20
+ import type { ComponentContext, ContextProvider } from '../component/abstract.component.ts';
21
+ import { RouteMatcher, toUrl } from './route.matcher.ts';
22
+
23
+ /** Base paths for the two SSR rendering endpoints. */
24
+ export interface BasePath {
25
+ /** Base path for SSR HTML rendering (default: '/html') */
26
+ html: string;
27
+ /** Base path for SSR Markdown rendering (default: '/md') */
28
+ md: string;
29
+ }
30
+
31
+ /** Default base paths — backward compatible with existing /html/ and /md/ prefixes. */
32
+ export const DEFAULT_BASE_PATH: BasePath = { html: '/html', md: '/md' };
33
+
34
+ /**
35
+ * Create a copy of a manifest with basePath prepended to all patterns.
36
+ * Used by the server to prefix bare in-memory manifests before passing to routers.
37
+ */
38
+ export function prefixManifest(manifest: RoutesManifest, basePath: string): RoutesManifest {
39
+ if (!basePath) return manifest;
40
+ return {
41
+ routes: manifest.routes.map((r) => ({
42
+ ...r,
43
+ // Root pattern '/' becomes basePath itself (e.g. '/html'), not '/html/'
44
+ pattern: r.pattern === '/' ? basePath : basePath + r.pattern,
45
+ parent: r.parent ? (r.parent === '/' ? basePath : basePath + r.parent) : undefined,
46
+ })),
47
+ errorBoundaries: manifest.errorBoundaries.map((e) => ({
48
+ ...e,
49
+ pattern: e.pattern === '/' ? basePath : basePath + e.pattern,
50
+ })),
51
+ statusPages: new Map(
52
+ [...manifest.statusPages].map(([status, route]) => [
53
+ status,
54
+ { ...route, pattern: basePath + route.pattern },
55
+ ]),
56
+ ),
57
+ errorHandler: manifest.errorHandler,
58
+ moduleLoaders: manifest.moduleLoaders,
59
+ };
60
+ }
61
+
62
+ const BLOCKED_PROTOCOLS = /^(javascript|data|vbscript):/i;
63
+
64
+ /** Throw if a redirect URL uses a dangerous protocol. */
65
+ export function assertSafeRedirect(url: string): void {
66
+ if (BLOCKED_PROTOCOLS.test(url.trim())) {
67
+ throw new Error(`Unsafe redirect URL blocked: ${url}`);
68
+ }
69
+ }
70
+
71
+ /** Default root route - renders a slot for child routes */
72
+ export const DEFAULT_ROOT_ROUTE: RouteConfig = {
73
+ pattern: '/',
74
+ type: 'page',
75
+ modulePath: '__default_root__',
76
+ };
77
+
78
+ /** Options for RouteCore */
79
+ export interface RouteCoreOptions {
80
+ /**
81
+ * Read a companion file (.html, .md, .css) by path — returns its text content.
82
+ * SSR: `(path) => runtime.query(path, { as: 'text' })`.
83
+ * SPA default: `fetch(path, { headers: { Accept: 'text/plain' } }).then(r => r.text())`.
84
+ */
85
+ fileReader?: (path: string) => Promise<string>;
86
+ /** Enriches every ComponentContext with app-level services before it reaches components. */
87
+ extendContext?: ContextProvider;
88
+ /** Base path prepended to route patterns for URL matching (e.g. '/html'). No trailing slash. */
89
+ basePath?: string;
90
+ }
91
+
92
+ /**
93
+ * Core router functionality shared across all rendering contexts.
94
+ */
95
+ export class RouteCore {
96
+ readonly matcher: RouteMatcher;
97
+ /** Registered context provider (if any). Exposed so renderers can apply it to inline contexts. */
98
+ readonly contextProvider: ContextProvider | undefined;
99
+ /** Base path for URL matching (e.g. '/html'). Empty string when no basePath. */
100
+ readonly basePath: string;
101
+ /** The root pattern — basePath when set, '/' otherwise. */
102
+ get root(): string {
103
+ return this.basePath || '/';
104
+ }
105
+ private listeners: Set<RouterEventListener> = new Set();
106
+ private moduleCache: Map<string, unknown> = new Map();
107
+ private widgetFileCache: Map<string, string> = new Map();
108
+ private moduleLoaders: Record<string, () => Promise<unknown>>;
109
+ currentRoute: MatchedRoute | null = null;
110
+ private readFile: (path: string) => Promise<string>;
111
+
112
+ constructor(manifest: RoutesManifest, options: RouteCoreOptions = {}) {
113
+ this.basePath = options.basePath ?? '';
114
+ this.matcher = new RouteMatcher(manifest);
115
+ this.readFile = options.fileReader ??
116
+ ((path) => fetch(path, { headers: { Accept: 'text/plain' } }).then((r) => r.text()));
117
+ this.contextProvider = options.extendContext;
118
+ this.moduleLoaders = manifest.moduleLoaders ?? {};
119
+ }
120
+
121
+ /**
122
+ * Get current route parameters.
123
+ */
124
+ getParams(): RouteParams {
125
+ return this.currentRoute?.params ?? {};
126
+ }
127
+
128
+ /**
129
+ * Add event listener for router events.
130
+ */
131
+ addEventListener(listener: RouterEventListener): () => void {
132
+ this.listeners.add(listener);
133
+ return () => this.listeners.delete(listener);
134
+ }
135
+
136
+ /**
137
+ * Emit router event to listeners.
138
+ */
139
+ emit(event: RouterEvent): void {
140
+ for (const listener of this.listeners) {
141
+ try {
142
+ listener(event);
143
+ } catch (e) {
144
+ console.error('[Router] Event listener error:', e);
145
+ }
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Match a URL to a route.
151
+ * Falls back to the default root route for the basePath root (or '/' when no basePath).
152
+ */
153
+ match(url: URL | string): MatchedRoute | undefined {
154
+ const matched = this.matcher.match(url);
155
+ if (matched) return matched;
156
+
157
+ const urlObj = toUrl(url);
158
+ if (urlObj.pathname === this.root || urlObj.pathname === this.root + '/') {
159
+ return {
160
+ route: { ...DEFAULT_ROOT_ROUTE, pattern: this.root },
161
+ params: {},
162
+ searchParams: urlObj.searchParams,
163
+ };
164
+ }
165
+
166
+ return undefined;
167
+ }
168
+
169
+ /**
170
+ * Build route hierarchy from a pattern.
171
+ *
172
+ * When basePath is set, the root is the basePath itself and only
173
+ * segments after it are split into ancestors.
174
+ *
175
+ * e.g., basePath='/html', pattern='/html/projects/:id/tasks'
176
+ * → ['/html', '/html/projects', '/html/projects/:id', '/html/projects/:id/tasks']
177
+ *
178
+ * Without basePath: '/projects/:id/tasks'
179
+ * → ['/', '/projects', '/projects/:id', '/projects/:id/tasks']
180
+ */
181
+ buildRouteHierarchy(pattern: string): string[] {
182
+ if (pattern === this.root || pattern === this.root + '/') {
183
+ return [this.root];
184
+ }
185
+
186
+ // Extract the part after basePath
187
+ const tail = this.basePath ? pattern.slice(this.basePath.length) : pattern;
188
+ const segments = tail.split('/').filter(Boolean);
189
+
190
+ const hierarchy: string[] = [this.root];
191
+ let current = this.basePath || '';
192
+ for (const segment of segments) {
193
+ current += '/' + segment;
194
+ hierarchy.push(current);
195
+ }
196
+
197
+ return hierarchy;
198
+ }
199
+
200
+ /**
201
+ * Normalize URL by removing trailing slashes (except bare '/').
202
+ */
203
+ normalizeUrl(url: string): string {
204
+ if (url.length > 1 && url.endsWith('/')) {
205
+ return url.slice(0, -1);
206
+ }
207
+ return url;
208
+ }
209
+
210
+ /**
211
+ * Convert relative path to absolute path.
212
+ */
213
+ toAbsolutePath(path: string): string {
214
+ return path.startsWith('/') ? path : '/' + path;
215
+ }
216
+
217
+ /**
218
+ * Load a module with caching.
219
+ * Uses pre-bundled loaders when available, falls back to dynamic import.
220
+ */
221
+ async loadModule<T>(modulePath: string): Promise<T> {
222
+ if (this.moduleCache.has(modulePath)) {
223
+ return this.moduleCache.get(modulePath) as T;
224
+ }
225
+
226
+ let module: unknown;
227
+ const loader = this.moduleLoaders[modulePath];
228
+ if (loader) {
229
+ module = await loader();
230
+ } else {
231
+ const absolutePath = this.toAbsolutePath(modulePath);
232
+ module = await import(absolutePath);
233
+ }
234
+
235
+ this.moduleCache.set(modulePath, module);
236
+ return module as T;
237
+ }
238
+
239
+ /**
240
+ * Load widget file contents with caching.
241
+ * Returns an object with loaded file contents.
242
+ */
243
+ async loadWidgetFiles(
244
+ widgetFiles: { html?: string; md?: string; css?: string },
245
+ ): Promise<{ html?: string; md?: string; css?: string }> {
246
+ const load = async (path: string): Promise<string | undefined> => {
247
+ const absPath = this.toAbsolutePath(path);
248
+ const cached = this.widgetFileCache.get(absPath);
249
+ if (cached !== undefined) return cached;
250
+
251
+ try {
252
+ const content = await this.readFile(absPath);
253
+ this.widgetFileCache.set(absPath, content);
254
+ return content;
255
+ } catch (e) {
256
+ console.warn(
257
+ `[RouteCore] Failed to load widget file ${path}:`,
258
+ e instanceof Error ? e.message : e,
259
+ );
260
+ return undefined;
261
+ }
262
+ };
263
+
264
+ const [html, md, css] = await Promise.all([
265
+ widgetFiles.html ? load(widgetFiles.html) : undefined,
266
+ widgetFiles.md ? load(widgetFiles.md) : undefined,
267
+ widgetFiles.css ? load(widgetFiles.css) : undefined,
268
+ ]);
269
+
270
+ return { html, md, css };
271
+ }
272
+
273
+ /**
274
+ * Build a RouteInfo from a matched route and the resolved URL pathname.
275
+ * Called once per navigation; the result is reused across the route hierarchy.
276
+ */
277
+ toRouteInfo(matched: MatchedRoute, pathname: string): RouteInfo {
278
+ return {
279
+ pathname,
280
+ pattern: matched.route.pattern,
281
+ params: matched.params,
282
+ searchParams: matched.searchParams ?? new URLSearchParams(),
283
+ };
284
+ }
285
+
286
+ /**
287
+ * Build a ComponentContext by extending RouteInfo with loaded file contents.
288
+ * When a signal is provided it is forwarded to fetch() calls and included
289
+ * in the returned context so that getData() can observe cancellation.
290
+ */
291
+ async buildComponentContext(
292
+ routeInfo: RouteInfo,
293
+ route: RouteConfig,
294
+ signal?: AbortSignal,
295
+ isLeaf?: boolean,
296
+ ): Promise<ComponentContext> {
297
+ const fetchFile = (filePath: string): Promise<string> =>
298
+ this.readFile(this.toAbsolutePath(filePath));
299
+
300
+ const rf = route.files;
301
+ const [html, md, css] = await Promise.all([
302
+ rf?.html ? fetchFile(rf.html) : undefined,
303
+ rf?.md ? fetchFile(rf.md) : undefined,
304
+ rf?.css ? fetchFile(rf.css) : undefined,
305
+ ]);
306
+
307
+ const base: ComponentContext = {
308
+ ...routeInfo,
309
+ files: { html, md, css },
310
+ signal,
311
+ isLeaf,
312
+ basePath: this.basePath || undefined,
313
+ };
314
+ return this.contextProvider ? this.contextProvider(base) : base;
315
+ }
316
+ }
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Route Matcher
3
+ *
4
+ * URLPattern-based route matching with support for:
5
+ * - Static routes (/about)
6
+ * - Dynamic segments (/projects/:id)
7
+ * - Wildcard/catch-all (future)
8
+ *
9
+ * Uses native URLPattern API (supported in all major browsers).
10
+ */
11
+
12
+ /** Parse a URL path string into a URL object. Passes through URL objects unchanged. */
13
+ export function toUrl(url: string | URL): URL {
14
+ return typeof url === 'string' ? new URL(url, 'http://url-parse') : url;
15
+ }
16
+
17
+ import type {
18
+ ErrorBoundary,
19
+ MatchedRoute,
20
+ RouteConfig,
21
+ RouteParams,
22
+ RoutesManifest,
23
+ } from '../type/route.type.ts';
24
+
25
+ /** Compiled route with URLPattern instance */
26
+ interface CompiledRoute {
27
+ route: RouteConfig;
28
+ pattern: URLPattern;
29
+ }
30
+
31
+ /**
32
+ * Route matcher using native URLPattern API.
33
+ *
34
+ * Routes are matched in order of specificity:
35
+ * 1. Exact static matches first
36
+ * 2. Dynamic segment matches
37
+ * 3. More specific patterns before less specific
38
+ */
39
+ export class RouteMatcher {
40
+ private compiledRoutes: CompiledRoute[] = [];
41
+ private errorBoundaries: ErrorBoundary[] = [];
42
+ private statusPages = new Map<number, RouteConfig>();
43
+ private errorHandler?: RouteConfig;
44
+
45
+ /**
46
+ * Initialize matcher with routes manifest.
47
+ * Routes should be pre-sorted by specificity in the manifest.
48
+ */
49
+ constructor(manifest: RoutesManifest) {
50
+ this.errorBoundaries = manifest.errorBoundaries.toSorted(
51
+ (a, b) => b.pattern.length - a.pattern.length,
52
+ );
53
+ this.statusPages = manifest.statusPages;
54
+ this.errorHandler = manifest.errorHandler;
55
+
56
+ // Compile URLPatterns for all routes
57
+ for (const route of manifest.routes) {
58
+ try {
59
+ const pattern = new URLPattern({ pathname: route.pattern });
60
+ this.compiledRoutes.push({ route, pattern });
61
+ } catch (e) {
62
+ console.error(`[Router] Invalid pattern: ${route.pattern}`, e);
63
+ }
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Match a URL against registered routes.
69
+ * Returns the first matching route or undefined.
70
+ */
71
+ match(url: URL | string): MatchedRoute | undefined {
72
+ const urlObj = toUrl(url);
73
+
74
+ const searchParams = urlObj.searchParams;
75
+
76
+ for (const { route, pattern } of this.compiledRoutes) {
77
+ const result = pattern.exec(urlObj);
78
+ if (result) {
79
+ return {
80
+ route,
81
+ params: this.extractParams(result),
82
+ searchParams,
83
+ patternResult: result,
84
+ };
85
+ }
86
+ }
87
+
88
+ return undefined;
89
+ }
90
+
91
+ /**
92
+ * Find error boundary for a given pathname.
93
+ * Searches from most specific to least specific.
94
+ */
95
+ findErrorBoundary(pathname: string): ErrorBoundary | undefined {
96
+ for (const boundary of this.errorBoundaries) {
97
+ const prefix = boundary.pattern.endsWith('/') ? boundary.pattern : boundary.pattern + '/';
98
+ if (pathname === boundary.pattern || pathname.startsWith(prefix)) {
99
+ return boundary;
100
+ }
101
+ }
102
+
103
+ return undefined;
104
+ }
105
+
106
+ /**
107
+ * Get status-specific page (404, 401, 403).
108
+ */
109
+ getStatusPage(status: number): RouteConfig | undefined {
110
+ return this.statusPages.get(status);
111
+ }
112
+
113
+ /**
114
+ * Get generic error handler.
115
+ */
116
+ getErrorHandler(): RouteConfig | undefined {
117
+ return this.errorHandler;
118
+ }
119
+
120
+ /**
121
+ * Find a route by its exact pattern or by matching a pathname.
122
+ * Used for building route hierarchy.
123
+ */
124
+ findRoute(patternOrPath: string): RouteConfig | undefined {
125
+ // First try exact pattern match
126
+ for (const { route } of this.compiledRoutes) {
127
+ if (route.pattern === patternOrPath) {
128
+ return route;
129
+ }
130
+ }
131
+
132
+ // Then try to match as a URL path
133
+ const matched = this.match(toUrl(patternOrPath));
134
+ return matched?.route;
135
+ }
136
+
137
+ /**
138
+ * Extract params from URLPatternResult.
139
+ */
140
+ private extractParams(result: URLPatternResult): RouteParams {
141
+ const groups = result.pathname.groups;
142
+ const params: Record<string, string> = {};
143
+ for (const [key, value] of Object.entries(groups)) {
144
+ if (value !== undefined) {
145
+ params[key] = value;
146
+ }
147
+ }
148
+ return params;
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Convert file-based route path to URLPattern.
154
+ *
155
+ * Examples:
156
+ * - index.page.ts → /
157
+ * - about.page.ts → /about
158
+ * - projects/index.page.ts → /projects/:rest*
159
+ * - projects/[id].page.ts → /projects/:id
160
+ * - projects/[id]/tasks.page.ts → /projects/:id/tasks
161
+ *
162
+ * Directory index files (non-root) become wildcard catch-all routes.
163
+ * See ADR-0002: Wildcard Routes via Directory Index Convention.
164
+ */
165
+ export function filePathToPattern(filePath: string): string {
166
+ // Remove routes/ prefix and file extension
167
+ let pattern = filePath
168
+ .replace(/^routes\//, '')
169
+ .replace(/\.(page|error|redirect)\.(ts|html|md|css)$/, '');
170
+
171
+ // Detect non-root directory index before stripping
172
+ const isDirectoryIndex = pattern.endsWith('/index') && pattern !== 'index';
173
+
174
+ // Handle index files
175
+ pattern = pattern.replace(/\/index$/, '').replace(/^index$/, '');
176
+
177
+ // Convert [param] to :param
178
+ pattern = pattern.replace(/\[(?<param>[^\]]+)\]/g, ':$<param>');
179
+
180
+ // Ensure leading slash
181
+ pattern = '/' + pattern;
182
+
183
+ // Non-root directory index becomes wildcard catch-all
184
+ if (isDirectoryIndex) {
185
+ pattern += '/:rest*';
186
+ }
187
+
188
+ return pattern;
189
+ }
190
+
191
+ /**
192
+ * Determine route type from filename.
193
+ */
194
+ export function getRouteType(
195
+ filename: string,
196
+ ): 'page' | 'error' | 'redirect' | null {
197
+ if (
198
+ filename.endsWith('.page.ts') ||
199
+ filename.endsWith('.page.html') ||
200
+ filename.endsWith('.page.md')
201
+ ) {
202
+ return 'page';
203
+ }
204
+ if (filename.endsWith('.error.ts')) {
205
+ return 'error';
206
+ }
207
+ if (filename.endsWith('.redirect.ts')) {
208
+ return 'redirect';
209
+ }
210
+ return null;
211
+ }
212
+
213
+ /**
214
+ * Get the file extension type from a page filename.
215
+ */
216
+ export function getPageFileType(
217
+ filename: string,
218
+ ): 'ts' | 'html' | 'md' | 'css' | null {
219
+ if (filename.endsWith('.page.ts')) return 'ts';
220
+ if (filename.endsWith('.page.html')) return 'html';
221
+ if (filename.endsWith('.page.md')) return 'md';
222
+ if (filename.endsWith('.page.css')) return 'css';
223
+ return null;
224
+ }
225
+
226
+ /**
227
+ * Sort routes by specificity.
228
+ * Non-wildcards before wildcards, static before dynamic, longer paths first.
229
+ */
230
+ export function sortRoutesBySpecificity(routes: RouteConfig[]): RouteConfig[] {
231
+ return routes.toSorted((a, b) => {
232
+ const aSegments = a.pattern.split('/').filter(Boolean);
233
+ const bSegments = b.pattern.split('/').filter(Boolean);
234
+
235
+ // Wildcards always sort last
236
+ const aIsWildcard = aSegments.some((s) => s.endsWith('*') || s.endsWith('+'));
237
+ const bIsWildcard = bSegments.some((s) => s.endsWith('*') || s.endsWith('+'));
238
+ if (aIsWildcard !== bIsWildcard) {
239
+ return aIsWildcard ? 1 : -1;
240
+ }
241
+
242
+ // More segments = more specific
243
+ if (aSegments.length !== bSegments.length) {
244
+ return bSegments.length - aSegments.length;
245
+ }
246
+
247
+ // Compare segment by segment
248
+ for (let i = 0; i < aSegments.length; i++) {
249
+ const aIsDynamic = aSegments[i].startsWith(':');
250
+ const bIsDynamic = bSegments[i].startsWith(':');
251
+
252
+ // Static segments are more specific than dynamic
253
+ if (aIsDynamic !== bIsDynamic) {
254
+ return aIsDynamic ? 1 : -1;
255
+ }
256
+ }
257
+
258
+ return 0;
259
+ });
260
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Logger Interface
3
+ *
4
+ * Minimal pluggable logger for surfacing errors from silent catch blocks.
5
+ * Structurally compatible with hardkore's StructuredLogger — any instance
6
+ * of that class satisfies this interface without an explicit dependency.
7
+ *
8
+ * Default: no-op (silent degradation). Call setLogger() at startup to wire in.
9
+ */
10
+ export interface Logger {
11
+ error(msg: string, error?: Error): void;
12
+ warn(msg: string): void;
13
+ }
14
+
15
+ const noop = () => {};
16
+
17
+ /** Module-level logger. Always callable — defaults to no-op. */
18
+ export const logger: Logger = { error: noop, warn: noop };
19
+
20
+ /** Replace the logger implementation. Call once at startup. */
21
+ export function setLogger(impl: Logger): void {
22
+ logger.error = impl.error.bind(impl);
23
+ logger.warn = impl.warn.bind(impl);
24
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Markdown Renderer Interface
3
+ *
4
+ * Implement this to provide custom markdown rendering.
5
+ * Used by MarkdownElement (browser) and SsrHtmlRouter (server).
6
+ */
7
+ export interface MarkdownRenderer {
8
+ /**
9
+ * Initialize the renderer (e.g., load WASM).
10
+ * Called once before first render.
11
+ */
12
+ init?(): Promise<void>;
13
+
14
+ /**
15
+ * Render markdown to HTML.
16
+ *
17
+ * **Security:** Output is assigned to `innerHTML` — the renderer must
18
+ * sanitize dangerous markup. See `doc/08-markdown-renderer.md`.
19
+ */
20
+ render(markdown: string): string;
21
+ }