@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,353 @@
1
+ /**
2
+ * Widget Element - Browser Custom Element
3
+ *
4
+ * Renders Widget instances in the browser as `widget-{name}` elements.
5
+ * Handles:
6
+ * - SSR hydration (ssr attribute)
7
+ * - Client-side data fetching with AbortSignal
8
+ * - Companion file loading (html, md, css) with caching
9
+ * - Loading/error states
10
+ */
11
+
12
+ import type {
13
+ Component,
14
+ ComponentContext,
15
+ ContextProvider,
16
+ } from '../component/abstract.component.ts';
17
+ import { HTMLElementBase, LAZY_ATTR, SSR_ATTR } from '../util/html.util.ts';
18
+
19
+ type ComponentState = 'idle' | 'loading' | 'ready' | 'error';
20
+
21
+ type WidgetFiles = { html?: string; md?: string; css?: string };
22
+
23
+ /**
24
+ * Custom element that renders a Component in the browser.
25
+ */
26
+ export class ComponentElement<TParams, TData> extends HTMLElementBase {
27
+ /** Shared file content cache — deduplicates fetches across all widget instances. */
28
+ private static fileCache = new Map<string, Promise<string | undefined>>();
29
+
30
+ /** App-level context provider set once during router initialization. */
31
+ private static extendContext: ContextProvider | undefined;
32
+
33
+ /** Register (or clear) the context provider that enriches every widget's ComponentContext. */
34
+ static setContextProvider(provider: ContextProvider | undefined): void {
35
+ ComponentElement.extendContext = provider;
36
+ }
37
+
38
+ private component: Component<TParams, TData>;
39
+ private effectiveFiles?: WidgetFiles;
40
+ private params: TParams | null = null;
41
+ private data: TData | null = null;
42
+ private context!: ComponentContext;
43
+ private state: ComponentState = 'idle';
44
+ private errorMessage = '';
45
+ private deferred: PromiseWithResolvers<void> | null = null;
46
+ private abortController: AbortController | null = null;
47
+ private intersectionObserver: IntersectionObserver | null = null;
48
+
49
+ /** Promise that resolves with fetched data (available after loadData starts) */
50
+ dataPromise: Promise<TData | null> | null = null;
51
+
52
+ constructor(component: Component<TParams, TData>, files?: WidgetFiles) {
53
+ super();
54
+ this.component = component;
55
+ this.effectiveFiles = files;
56
+ // Attach shadow root if not already present (Declarative Shadow DOM creates it from <template shadowrootmode="open">)
57
+ // This enables progressive enhancement: SSR with DSD works without JS, then hydrates when JS loads
58
+ if (!this.shadowRoot) {
59
+ this.attachShadow({ mode: 'open' });
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Register a widget as a custom element: `widget-{name}`.
65
+ * Creates a fresh widget instance per DOM element (per-element state).
66
+ * Optional `files` parameter provides discovered file paths without mutating
67
+ * the component instance.
68
+ */
69
+ static register<TP, TD>(
70
+ component: Component<TP, TD>,
71
+ files?: WidgetFiles,
72
+ ): void {
73
+ const tagName = `widget-${component.name}`;
74
+
75
+ if (!globalThis.customElements || customElements.get(tagName)) {
76
+ return;
77
+ }
78
+
79
+ const WidgetClass = component.constructor as new () => Component<TP, TD>;
80
+
81
+ const BoundElement = class extends ComponentElement<TP, TD> {
82
+ constructor() {
83
+ super(new WidgetClass(), files);
84
+ }
85
+ };
86
+
87
+ customElements.define(tagName, BoundElement);
88
+ }
89
+
90
+ /**
91
+ * Register a widget class (not instance) as a custom element: `widget-{name}`.
92
+ * Used for manifest-based registration where classes are loaded dynamically.
93
+ */
94
+ static registerClass<TP, TD>(
95
+ WidgetClass: new () => Component<TP, TD>,
96
+ name: string,
97
+ files?: WidgetFiles,
98
+ ): void {
99
+ const tagName = `widget-${name}`;
100
+
101
+ if (!globalThis.customElements || customElements.get(tagName)) {
102
+ return;
103
+ }
104
+
105
+ const BoundElement = class extends ComponentElement<TP, TD> {
106
+ constructor() {
107
+ super(new WidgetClass(), files);
108
+ }
109
+ };
110
+
111
+ customElements.define(tagName, BoundElement);
112
+ }
113
+
114
+ /**
115
+ * Promise that resolves when component is ready (data loaded and rendered).
116
+ * Used by router to wait for async components.
117
+ */
118
+ get ready(): Promise<void> {
119
+ if (this.state === 'ready') {
120
+ return Promise.resolve();
121
+ }
122
+ this.deferred ??= Promise.withResolvers<void>();
123
+ return this.deferred.promise;
124
+ }
125
+
126
+ async connectedCallback(): Promise<void> {
127
+ this.component.element = this;
128
+ this.style.contentVisibility = 'auto';
129
+ this.abortController = new AbortController();
130
+ const signal = this.abortController.signal;
131
+
132
+ // Parse params from element attributes
133
+ const params: Record<string, unknown> = {};
134
+ for (const attr of this.attributes) {
135
+ if (attr.name === SSR_ATTR || attr.name === LAZY_ATTR) continue;
136
+ const key = attr.name.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
137
+ try {
138
+ params[key] = JSON.parse(attr.value);
139
+ } catch {
140
+ params[key] = attr.value;
141
+ }
142
+ }
143
+ this.params = params as TParams;
144
+
145
+ // Validate params
146
+ if (this.component.validateParams && this.params !== null) {
147
+ const error = this.component.validateParams(this.params);
148
+ if (error) {
149
+ this.setError(error);
150
+ return;
151
+ }
152
+ }
153
+
154
+ // Load companion files (html, md, css) if declared
155
+ const files = await this.loadFiles();
156
+ if (signal.aborted) return;
157
+
158
+ const base: ComponentContext = {
159
+ pathname: globalThis.location?.pathname ?? '/',
160
+ pattern: '',
161
+ params: {},
162
+ searchParams: new URLSearchParams(globalThis.location?.search ?? ''),
163
+ files: (files.html || files.md || files.css) ? files : undefined,
164
+ };
165
+ this.context = ComponentElement.extendContext ? ComponentElement.extendContext(base) : base;
166
+
167
+ // Hydrate from SSR: adopt content from Declarative Shadow DOM
168
+ if (this.hasAttribute(SSR_ATTR)) {
169
+ this.removeAttribute(SSR_ATTR);
170
+
171
+ // Read SSR data from light DOM (JSON text placed alongside shadow root)
172
+ const lightText = this.textContent?.trim();
173
+ if (lightText) {
174
+ try {
175
+ this.data = JSON.parse(lightText);
176
+ } catch {
177
+ // Not valid JSON — proceed with data: null
178
+ }
179
+ }
180
+ // Clear light DOM content (JSON text)
181
+ this.textContent = '';
182
+
183
+ this.state = 'ready';
184
+
185
+ // Call hydrate() hook to attach event listeners
186
+ if (this.component.hydrate) {
187
+ const args = { data: this.data, params: this.params!, context: this.context };
188
+ queueMicrotask(() => {
189
+ this.component.hydrate!(args);
190
+ });
191
+ }
192
+
193
+ this.signalReady();
194
+ return;
195
+ }
196
+
197
+ // Lazy: defer loadData until element is visible
198
+ if (this.hasAttribute(LAZY_ATTR)) {
199
+ this.intersectionObserver = new IntersectionObserver(([entry]) => {
200
+ if (entry.isIntersecting) {
201
+ this.intersectionObserver?.disconnect();
202
+ this.intersectionObserver = null;
203
+ this.loadData();
204
+ }
205
+ });
206
+ this.intersectionObserver.observe(this);
207
+ return;
208
+ }
209
+
210
+ await this.loadData();
211
+ }
212
+
213
+ disconnectedCallback(): void {
214
+ this.component.destroy?.();
215
+ this.component.element = undefined;
216
+ this.intersectionObserver?.disconnect();
217
+ this.intersectionObserver = null;
218
+ this.abortController?.abort();
219
+ this.abortController = null;
220
+ this.state = 'idle';
221
+ this.data = null;
222
+ this.context = undefined!;
223
+ this.dataPromise = null;
224
+ this.errorMessage = '';
225
+ this.signalReady();
226
+ this.deferred = null;
227
+ }
228
+
229
+ /**
230
+ * Reload component data. Aborts any in-flight request first.
231
+ */
232
+ async reload(): Promise<void> {
233
+ if (this.params === null) return;
234
+
235
+ // Abort previous and create fresh controller
236
+ this.abortController?.abort();
237
+ this.abortController = new AbortController();
238
+
239
+ await this.loadData();
240
+ }
241
+
242
+ /**
243
+ * Fetch a single file by path, with caching.
244
+ * Absolute URLs (http/https) pass through; relative paths get '/' prefix.
245
+ */
246
+ private static loadFile(path: string): Promise<string | undefined> {
247
+ const cached = ComponentElement.fileCache.get(path);
248
+ if (cached) return cached;
249
+
250
+ const url = path.startsWith('http://') || path.startsWith('https://')
251
+ ? path
252
+ : (path.startsWith('/') ? path : '/' + path);
253
+
254
+ const promise = fetch(url).then(
255
+ (res) => res.ok ? res.text() : undefined,
256
+ () => undefined,
257
+ );
258
+
259
+ ComponentElement.fileCache.set(path, promise);
260
+ return promise;
261
+ }
262
+
263
+ /**
264
+ * Load all companion files for this widget instance.
265
+ * Uses effectiveFiles (from registration) falling back to component.files.
266
+ */
267
+ private async loadFiles(): Promise<{ html?: string; md?: string; css?: string }> {
268
+ const filePaths = this.effectiveFiles ?? this.component.files;
269
+ if (!filePaths) return {};
270
+
271
+ const [html, md, css] = await Promise.all([
272
+ filePaths.html ? ComponentElement.loadFile(filePaths.html) : undefined,
273
+ filePaths.md ? ComponentElement.loadFile(filePaths.md) : undefined,
274
+ filePaths.css ? ComponentElement.loadFile(filePaths.css) : undefined,
275
+ ]);
276
+
277
+ return { html, md, css };
278
+ }
279
+
280
+ private async loadData(): Promise<void> {
281
+ if (this.params === null) return;
282
+
283
+ const signal = this.abortController?.signal;
284
+
285
+ this.state = 'loading';
286
+ this.render();
287
+
288
+ try {
289
+ const promise = this.component.getData({
290
+ params: this.params,
291
+ signal,
292
+ context: this.context,
293
+ });
294
+ this.dataPromise = promise;
295
+ this.data = await promise;
296
+
297
+ // Check abort after await — don't touch DOM if disconnected
298
+ if (signal?.aborted) return;
299
+
300
+ this.state = 'ready';
301
+ } catch (e) {
302
+ if (e instanceof DOMException && e.name === 'AbortError') return;
303
+ if (signal?.aborted) return;
304
+
305
+ this.setError(e instanceof Error ? e.message : String(e));
306
+ return;
307
+ }
308
+
309
+ this.render();
310
+ this.signalReady();
311
+ }
312
+
313
+ private setError(message: string): void {
314
+ this.state = 'error';
315
+ this.errorMessage = message;
316
+ this.render();
317
+ this.signalReady(); // Ready even on error (completed loading)
318
+ }
319
+
320
+ private signalReady(): void {
321
+ this.deferred?.resolve();
322
+ this.deferred = null;
323
+ }
324
+
325
+ private render(): void {
326
+ if (this.params === null) {
327
+ this.shadowRoot!.setHTMLUnsafe('');
328
+ return;
329
+ }
330
+
331
+ if (this.state === 'error') {
332
+ this.shadowRoot!.setHTMLUnsafe(this.component.renderError({
333
+ error: new Error(this.errorMessage),
334
+ params: this.params,
335
+ }));
336
+ return;
337
+ }
338
+
339
+ this.shadowRoot!.setHTMLUnsafe(this.component.renderHTML({
340
+ data: this.state === 'ready' ? this.data : null,
341
+ params: this.params,
342
+ context: this.context,
343
+ }));
344
+
345
+ // Call hydrate() after rendering to attach event listeners
346
+ if (this.state === 'ready' && this.component.hydrate) {
347
+ const args = { data: this.data, params: this.params!, context: this.context };
348
+ queueMicrotask(() => {
349
+ this.component.hydrate!(args);
350
+ });
351
+ }
352
+ }
353
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Markdown Element — <mark-down> custom element.
3
+ *
4
+ * Renders markdown content with pluggable renderer.
5
+ * Supports:
6
+ * - Inline content: <mark-down># Title</mark-down>
7
+ * - Source attribute: <mark-down src="/path/to.md"></mark-down>
8
+ */
9
+
10
+ import { escapeHtml, HTMLElementBase } from '../util/html.util.ts';
11
+ import type { MarkdownRenderer } from '../type/markdown.type.ts';
12
+
13
+ export class MarkdownElement extends HTMLElementBase {
14
+ private static renderer: MarkdownRenderer | null = null;
15
+ private static rendererInitPromise: Promise<void> | null = null;
16
+ private abortController: AbortController | null = null;
17
+
18
+ /**
19
+ * Set the markdown renderer.
20
+ * Must be called before any <mark-down> elements are connected.
21
+ *
22
+ * @example
23
+ * ```ts
24
+ * import { createEmkoRenderer } from './emko.renderer.ts';
25
+ * MarkdownElement.setRenderer(await createEmkoRenderer());
26
+ * ```
27
+ */
28
+ static setRenderer(renderer: MarkdownRenderer): void {
29
+ MarkdownElement.renderer = renderer;
30
+ MarkdownElement.rendererInitPromise = renderer.init ? renderer.init() : null;
31
+ }
32
+
33
+ /**
34
+ * Get the current renderer, waiting for init if needed.
35
+ */
36
+ private static async getRenderer(): Promise<MarkdownRenderer> {
37
+ const renderer = MarkdownElement.renderer;
38
+ if (!renderer) {
39
+ throw new Error(
40
+ 'No markdown renderer configured. Call MarkdownElement.setRenderer() before using <mark-down> elements.',
41
+ );
42
+ }
43
+
44
+ if (MarkdownElement.rendererInitPromise) {
45
+ await MarkdownElement.rendererInitPromise;
46
+ }
47
+
48
+ return renderer;
49
+ }
50
+
51
+ async connectedCallback(): Promise<void> {
52
+ this.abortController = new AbortController();
53
+ await this.loadContent();
54
+ }
55
+
56
+ disconnectedCallback(): void {
57
+ this.abortController?.abort();
58
+ this.abortController = null;
59
+ }
60
+
61
+ private async loadContent(): Promise<void> {
62
+ const src = this.getAttribute('src');
63
+ const inlineContent = this.textContent?.trim();
64
+
65
+ if (src) {
66
+ await this.loadFromSrc(src);
67
+ } else if (inlineContent) {
68
+ await this.renderContent(inlineContent);
69
+ } else {
70
+ this.innerHTML = '';
71
+ }
72
+ }
73
+
74
+ private async loadFromSrc(src: string): Promise<void> {
75
+ const signal = this.abortController?.signal;
76
+
77
+ try {
78
+ const response = await fetch(src, { signal });
79
+
80
+ if (!response.ok) {
81
+ throw new Error(`Failed to fetch ${src}: ${response.status}`);
82
+ }
83
+
84
+ const markdown = await response.text();
85
+ await this.renderContent(markdown);
86
+ } catch (error) {
87
+ if (error instanceof Error && error.name === 'AbortError') {
88
+ return;
89
+ }
90
+ this.showError(error);
91
+ }
92
+ }
93
+
94
+ private async renderContent(markdown: string): Promise<void> {
95
+ try {
96
+ const renderer = await MarkdownElement.getRenderer();
97
+ this.innerHTML = renderer.render(markdown);
98
+ } catch (error) {
99
+ this.showError(error);
100
+ }
101
+ }
102
+
103
+ private showError(error: unknown): void {
104
+ const message = error instanceof Error ? error.message : String(error);
105
+ this.innerHTML = `<div>Markdown Error: ${escapeHtml(message)}</div>`;
106
+ }
107
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Router Slot Element
3
+ *
4
+ * <router-slot> is where page content is rendered.
5
+ * Supports nested routes via parent page containing slot.
6
+ *
7
+ * Usage:
8
+ * ```html
9
+ * <router-slot></router-slot>
10
+ * ```
11
+ *
12
+ * For nested routes, parent page includes:
13
+ * ```typescript
14
+ * export default function render() {
15
+ * return `
16
+ * <header>...</header>
17
+ * <main>
18
+ * <router-slot></router-slot>
19
+ * </main>
20
+ * `;
21
+ * }
22
+ * ```
23
+ */
24
+
25
+ import { HTMLElementBase } from '../util/html.util.ts';
26
+
27
+ /**
28
+ * Router slot web component.
29
+ * Serves as the mounting point for page content.
30
+ */
31
+ export class RouterSlot extends HTMLElementBase {}
package/src/index.ts ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * @emkodev/emroute
3
+ *
4
+ * Public API — types, component base classes, and utilities for consumer code.
5
+ *
6
+ * For environment-specific code, use sub-exports:
7
+ * @emkodev/emroute/spa — Browser: SPA router + custom elements
8
+ * @emkodev/emroute/ssr/html — Server: SSR HTML renderer
9
+ * @emkodev/emroute/ssr/md — Server: SSR Markdown renderer
10
+ * @emkodev/emroute/server — Production server
11
+ * @emkodev/emroute/runtime — Abstract runtime + constants
12
+ * @emkodev/emroute/runtime/sitemap — Sitemap generation
13
+ */
14
+
15
+ // Types
16
+ export type {
17
+ ErrorBoundary,
18
+ MatchedRoute,
19
+ NavigateOptions,
20
+ RedirectConfig,
21
+ RouteConfig,
22
+ RouteFiles,
23
+ RouteFileType,
24
+ RouteInfo,
25
+ RouteParams,
26
+ RouterEvent,
27
+ RouterEventListener,
28
+ RouterEventType,
29
+ RouterState,
30
+ RoutesManifest,
31
+ } from './type/route.type.ts';
32
+
33
+ export type {
34
+ ParsedWidgetBlock,
35
+ SpaMode,
36
+ WidgetManifestEntry,
37
+ WidgetsManifest,
38
+ } from './type/widget.type.ts';
39
+
40
+ export type { MarkdownRenderer } from './type/markdown.type.ts';
41
+ export { type Logger, setLogger } from './type/logger.type.ts';
42
+
43
+ // Components
44
+ export {
45
+ Component,
46
+ type ComponentContext,
47
+ type ComponentManifestEntry,
48
+ type ContextProvider,
49
+ type FileContents,
50
+ type RenderContext,
51
+ } from './component/abstract.component.ts';
52
+
53
+ export { PageComponent } from './component/page.component.ts';
54
+ export { WidgetComponent } from './component/widget.component.ts';
55
+ export { WidgetRegistry } from './widget/widget.registry.ts';
56
+
57
+ // Route config
58
+ export { type BasePath, DEFAULT_BASE_PATH, prefixManifest } from './route/route.core.ts';
59
+
60
+ // Utils
61
+ export { escapeHtml, scopeWidgetCss } from './util/html.util.ts';
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Overlay Module
3
+ *
4
+ * Programmatic overlay API for modals, toasts, and popovers.
5
+ * For simple cases, use declarative HTML (commandfor/command + popover/dialog).
6
+ * dismissAll() closes both programmatic and declarative overlays.
7
+ */
8
+
9
+ export type { ModalOptions, OverlayService, PopoverOptions, ToastOptions } from './overlay.type.ts';
10
+ export { createOverlayService } from './overlay.service.ts';