@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,170 @@
1
+ /**
2
+ * Overlay Default CSS
3
+ *
4
+ * Styles for modals, toasts, and popovers. Works for both declarative
5
+ * (commandfor/command + HTML attributes) and programmatic (OverlayService)
6
+ * overlays. Uses CSS custom properties for theming.
7
+ */
8
+
9
+ export const overlayCSS: string = /* css */ `
10
+ :root {
11
+ --overlay-backdrop: oklch(0% 0 0 / 0.5);
12
+ --overlay-surface: oklch(100% 0 0);
13
+ --overlay-radius: 8px;
14
+ --overlay-shadow: 0 8px 32px oklch(0% 0 0 / 0.2);
15
+ --overlay-toast-gap: 8px;
16
+ --overlay-toast-duration: 5s;
17
+ --overlay-z: 1000;
18
+ }
19
+
20
+ /* --- Modal (dialog) --- */
21
+
22
+ dialog[data-overlay-modal] {
23
+ border: none;
24
+ padding: 0;
25
+ background: var(--overlay-surface);
26
+ border-radius: var(--overlay-radius);
27
+ box-shadow: var(--overlay-shadow);
28
+ max-width: min(90vw, 560px);
29
+ max-height: 85vh;
30
+ overflow: auto;
31
+ opacity: 1;
32
+ translate: 0 0;
33
+ transition:
34
+ opacity 200ms,
35
+ translate 200ms;
36
+ }
37
+
38
+ dialog[data-overlay-modal][open] {
39
+ transition:
40
+ opacity 200ms,
41
+ translate 200ms,
42
+ display 200ms allow-discrete,
43
+ overlay 200ms allow-discrete;
44
+
45
+ @starting-style {
46
+ opacity: 0;
47
+ translate: 0 20px;
48
+ }
49
+ }
50
+
51
+ dialog[data-overlay-modal]::backdrop {
52
+ background: var(--overlay-backdrop);
53
+ opacity: 1;
54
+ transition: opacity 200ms;
55
+ }
56
+
57
+ dialog[data-overlay-modal][open]::backdrop {
58
+ transition:
59
+ opacity 200ms,
60
+ display 200ms allow-discrete,
61
+ overlay 200ms allow-discrete;
62
+
63
+ @starting-style {
64
+ opacity: 0;
65
+ }
66
+ }
67
+
68
+ dialog[data-overlay-modal][data-dismissing] {
69
+ opacity: 0;
70
+ translate: 0 20px;
71
+ }
72
+
73
+ dialog[data-overlay-modal][data-dismissing]::backdrop {
74
+ opacity: 0;
75
+ }
76
+
77
+ /* --- Toast container --- */
78
+
79
+ [data-overlay-toast-container] {
80
+ position: fixed;
81
+ bottom: 16px;
82
+ right: 16px;
83
+ z-index: var(--overlay-z);
84
+ display: flex;
85
+ flex-direction: column;
86
+ gap: var(--overlay-toast-gap);
87
+ pointer-events: none;
88
+ }
89
+
90
+ /* --- Toast item --- */
91
+
92
+ [data-overlay-toast] {
93
+ pointer-events: auto;
94
+ background: var(--overlay-surface);
95
+ border-radius: var(--overlay-radius);
96
+ box-shadow: var(--overlay-shadow);
97
+ padding: 12px 16px;
98
+ animation: overlay-toast-auto var(--overlay-toast-duration, 5s) ease-in-out forwards;
99
+ }
100
+
101
+ /* Manual toast (timeout: 0): no auto-dismiss, entry transition only */
102
+ [data-overlay-toast][data-toast-manual] {
103
+ animation: none;
104
+ opacity: 1;
105
+ translate: 0 0;
106
+ transition:
107
+ opacity 200ms,
108
+ translate 200ms;
109
+
110
+ @starting-style {
111
+ opacity: 0;
112
+ translate: 20px 0;
113
+ }
114
+ }
115
+
116
+ /* Dismissed toast: CSS exit animation */
117
+ [data-overlay-toast][data-dismissing] {
118
+ animation: overlay-toast-exit 200ms ease-in forwards;
119
+ }
120
+
121
+ @keyframes overlay-toast-auto {
122
+ 0% { opacity: 0; translate: 20px 0; }
123
+ 10% { opacity: 1; translate: 0 0; }
124
+ 80% { opacity: 1; translate: 0 0; }
125
+ 100% { opacity: 0; translate: 0 0; display: none; }
126
+ }
127
+
128
+ @keyframes overlay-toast-exit {
129
+ to { opacity: 0; translate: 20px 0; display: none; }
130
+ }
131
+
132
+ /* --- Popover --- */
133
+
134
+ [data-overlay-popover] {
135
+ border: none;
136
+ padding: 0;
137
+ margin: 0;
138
+ background: var(--overlay-surface);
139
+ border-radius: var(--overlay-radius);
140
+ box-shadow: var(--overlay-shadow);
141
+ opacity: 1;
142
+ scale: 1;
143
+ transition:
144
+ opacity 200ms,
145
+ scale 200ms;
146
+ }
147
+
148
+ [data-overlay-popover]:popover-open {
149
+ position-anchor: auto;
150
+ inset: unset;
151
+ top: anchor(bottom);
152
+ left: anchor(start);
153
+ margin-top: 4px;
154
+ transition:
155
+ opacity 200ms,
156
+ scale 200ms,
157
+ display 200ms allow-discrete,
158
+ overlay 200ms allow-discrete;
159
+
160
+ @starting-style {
161
+ opacity: 0;
162
+ scale: 0.95;
163
+ }
164
+ }
165
+
166
+ [data-overlay-popover][data-dismissing] {
167
+ opacity: 0;
168
+ scale: 0.95;
169
+ }
170
+ `;
@@ -0,0 +1,348 @@
1
+ /**
2
+ * Overlay Service
3
+ *
4
+ * Programmatic API for modals, toasts, and popovers. For simple trigger
5
+ * patterns, use declarative HTML (commandfor/command + popover/dialog)
6
+ * with zero JS. This service covers dynamic content, programmatic
7
+ * triggers, and complex workflows.
8
+ *
9
+ * dismissAll() is DOM-aware: it closes both programmatic overlays
10
+ * managed by this service AND declarative popovers/dialogs found
11
+ * via DOM queries.
12
+ */
13
+
14
+ import type { ModalOptions, OverlayService, PopoverOptions, ToastOptions } from './overlay.type.ts';
15
+ import { overlayCSS } from './overlay.css.ts';
16
+
17
+ const ANIMATION_SAFETY_TIMEOUT = 300;
18
+
19
+ /**
20
+ * Animate an element out by setting `data-dismissing`, waiting for
21
+ * `transitionend`, then calling the provided callback. Includes a
22
+ * safety timeout in case the transition event never fires.
23
+ */
24
+ function animateDismiss(el: HTMLElement, onDone: () => void): void {
25
+ el.setAttribute('data-dismissing', '');
26
+
27
+ let done = false;
28
+ const finish = () => {
29
+ if (done) return;
30
+ done = true;
31
+ onDone();
32
+ };
33
+
34
+ el.addEventListener('transitionend', finish, { once: true });
35
+ setTimeout(finish, ANIMATION_SAFETY_TIMEOUT);
36
+ }
37
+
38
+ export function createOverlayService(): OverlayService {
39
+ let styleInjected = false;
40
+
41
+ // Modal state
42
+ let dialog: HTMLDialogElement | null = null;
43
+ // Uses `any` because modalResolve is reassigned across multiple modal() calls
44
+ // with different type parameters T. Each call creates a Promise.withResolvers<T>(),
45
+ // so the resolver function signature changes (accepts T | PromiseLike<T | undefined>).
46
+ // Type safety is maintained by closeModal<T>(value?: T) which ensures only valid
47
+ // types are passed to the resolver.
48
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
49
+ let modalResolve: ((value: any) => void) | null = null;
50
+ let modalOnClose: (() => void) | undefined;
51
+
52
+ // Toast state
53
+ let toastContainer: HTMLDivElement | null = null;
54
+
55
+ // Popover state
56
+ let popoverEl: HTMLDivElement | null = null;
57
+ let popoverAnchorObserver: MutationObserver | null = null;
58
+ const supportsAnchor = typeof CSS !== 'undefined' &&
59
+ CSS.supports('anchor-name', '--a');
60
+
61
+ function injectCSS(): void {
62
+ if (styleInjected) return;
63
+ styleInjected = true;
64
+ const style = document.createElement('style');
65
+ style.textContent = overlayCSS;
66
+ document.head.appendChild(style);
67
+ }
68
+
69
+ function ensureDialog(): HTMLDialogElement {
70
+ if (dialog) return dialog;
71
+ injectCSS();
72
+ dialog = document.createElement('dialog');
73
+ dialog.setAttribute('data-overlay-modal', '');
74
+ document.body.appendChild(dialog);
75
+
76
+ dialog.addEventListener('click', (e) => {
77
+ if (e.target === dialog) {
78
+ closeModal(undefined);
79
+ }
80
+ });
81
+
82
+ return dialog;
83
+ }
84
+
85
+ function ensureToastContainer(): HTMLDivElement {
86
+ if (toastContainer) return toastContainer;
87
+ injectCSS();
88
+ toastContainer = document.createElement('div');
89
+ toastContainer.setAttribute('data-overlay-toast-container', '');
90
+ document.body.appendChild(toastContainer);
91
+ return toastContainer;
92
+ }
93
+
94
+ function ensurePopover(): HTMLDivElement {
95
+ if (popoverEl) return popoverEl;
96
+ injectCSS();
97
+ popoverEl = document.createElement('div');
98
+ popoverEl.setAttribute('data-overlay-popover', '');
99
+ popoverEl.setAttribute('popover', '');
100
+ document.body.appendChild(popoverEl);
101
+ return popoverEl;
102
+ }
103
+
104
+ // --- Modal ---
105
+
106
+ function modal<T = undefined>(options: ModalOptions<T>): Promise<T | undefined> {
107
+ const d = ensureDialog();
108
+
109
+ // Clean up any lingering dismiss state from a previous close
110
+ d.removeAttribute('data-dismissing');
111
+
112
+ // Immediately dismiss popover — modal takes over the top layer
113
+ hidePopoverImmediate();
114
+
115
+ // Last wins: close current modal if open
116
+ if (d.open) {
117
+ d.close();
118
+ if (modalResolve) {
119
+ modalResolve(undefined);
120
+ modalResolve = null;
121
+ }
122
+ if (modalOnClose) {
123
+ modalOnClose();
124
+ modalOnClose = undefined;
125
+ }
126
+ }
127
+
128
+ d.innerHTML = '';
129
+ options.render(d);
130
+ modalOnClose = options.onClose;
131
+
132
+ const { promise, resolve } = Promise.withResolvers<T | undefined>();
133
+ modalResolve = resolve;
134
+
135
+ d.showModal();
136
+
137
+ return promise;
138
+ }
139
+
140
+ function closeModal<T>(value?: T): void {
141
+ if (!dialog || !dialog.open) return;
142
+
143
+ const resolve = modalResolve;
144
+ const onClose = modalOnClose;
145
+ const dialogRef = dialog;
146
+ modalResolve = null;
147
+ modalOnClose = undefined;
148
+
149
+ animateDismiss(dialogRef, () => {
150
+ if (dialogRef && dialogRef.open) {
151
+ dialogRef.close();
152
+ if (resolve) resolve(value);
153
+ if (onClose) onClose();
154
+ }
155
+ });
156
+ }
157
+
158
+ // --- Toast ---
159
+
160
+ /** Remove dead toasts (dismissed or animation-finished) from container. */
161
+ function clearDeadToasts(container: HTMLDivElement): void {
162
+ for (const child of [...container.children]) {
163
+ const el = child as HTMLElement;
164
+ if (el.hasAttribute('data-dismissing')) {
165
+ el.remove();
166
+ }
167
+ }
168
+ }
169
+
170
+ function toast(options: ToastOptions): { dismiss(): void } {
171
+ const container = ensureToastContainer();
172
+
173
+ // Clean up dead toasts before adding a new one
174
+ clearDeadToasts(container);
175
+
176
+ const el = document.createElement('div');
177
+ el.setAttribute('data-overlay-toast', '');
178
+
179
+ const timeout = options.timeout ?? 0;
180
+ if (timeout === 0) {
181
+ el.setAttribute('data-toast-manual', '');
182
+ } else {
183
+ el.style.setProperty('--overlay-toast-duration', `${timeout}ms`);
184
+ }
185
+
186
+ options.render(el);
187
+ container.appendChild(el);
188
+
189
+ let dismissed = false;
190
+ const dismiss = () => {
191
+ if (dismissed) return;
192
+ dismissed = true;
193
+ el.setAttribute('data-dismissing', '');
194
+ };
195
+
196
+ return { dismiss };
197
+ }
198
+
199
+ // --- Popover ---
200
+
201
+ function popover(options: PopoverOptions): void {
202
+ const el = ensurePopover();
203
+
204
+ // Last wins: hide current popover if showing
205
+ cleanupPopoverAnchorObserver();
206
+ try {
207
+ el.hidePopover();
208
+ } catch {
209
+ // Not shown — ignore
210
+ }
211
+ el.removeAttribute('data-dismissing');
212
+
213
+ el.innerHTML = '';
214
+ options.render(el);
215
+
216
+ // Anchor positioning
217
+ if (supportsAnchor) {
218
+ const anchorName = '--overlay-anchor';
219
+ options.anchor.style.setProperty('anchor-name', anchorName);
220
+ el.style.setProperty('position-anchor', anchorName);
221
+ el.style.removeProperty('top');
222
+ el.style.removeProperty('left');
223
+ } else {
224
+ const rect = options.anchor.getBoundingClientRect();
225
+ el.style.top = `${rect.bottom + globalThis.scrollY}px`;
226
+ el.style.left = `${rect.left + globalThis.scrollX}px`;
227
+ el.style.position = 'absolute';
228
+ }
229
+
230
+ el.showPopover();
231
+
232
+ // Watch for anchor disconnect
233
+ watchAnchorDisconnect(options.anchor);
234
+ }
235
+
236
+ function watchAnchorDisconnect(anchor: HTMLElement): void {
237
+ cleanupPopoverAnchorObserver();
238
+
239
+ const parent = anchor.parentNode;
240
+ if (!parent) {
241
+ closePopover();
242
+ return;
243
+ }
244
+
245
+ popoverAnchorObserver = new MutationObserver(() => {
246
+ if (!document.contains(anchor)) {
247
+ closePopover();
248
+ }
249
+ });
250
+
251
+ popoverAnchorObserver.observe(parent, { childList: true });
252
+ }
253
+
254
+ /** Hide popover instantly without dismiss animation. */
255
+ function hidePopoverImmediate(): void {
256
+ cleanupPopoverAnchorObserver();
257
+ if (!popoverEl) return;
258
+ try {
259
+ popoverEl.hidePopover();
260
+ } catch {
261
+ // Not shown — ignore
262
+ }
263
+ popoverEl.removeAttribute('data-dismissing');
264
+ }
265
+
266
+ function cleanupPopoverAnchorObserver(): void {
267
+ if (popoverAnchorObserver) {
268
+ popoverAnchorObserver.disconnect();
269
+ popoverAnchorObserver = null;
270
+ }
271
+ }
272
+
273
+ function closePopover(): void {
274
+ cleanupPopoverAnchorObserver();
275
+
276
+ if (!popoverEl) return;
277
+
278
+ // Check if popover is showing via matches(':popover-open')
279
+ let isOpen: boolean;
280
+ try {
281
+ isOpen = popoverEl.matches(':popover-open');
282
+ } catch {
283
+ // :popover-open may not be supported — fall back
284
+ isOpen = popoverEl.hasAttribute('popover') && popoverEl.style.display !== 'none';
285
+ }
286
+
287
+ if (!isOpen) return;
288
+
289
+ animateDismiss(popoverEl, () => {
290
+ try {
291
+ popoverEl!.hidePopover();
292
+ } catch {
293
+ // Already hidden
294
+ }
295
+ });
296
+ }
297
+
298
+ // --- Dismiss all ---
299
+
300
+ function dismissAll(): void {
301
+ // Close programmatic modal
302
+ if (dialog && dialog.open) {
303
+ const resolve = modalResolve;
304
+ const onClose = modalOnClose;
305
+ modalResolve = null;
306
+ modalOnClose = undefined;
307
+
308
+ dialog.removeAttribute('data-dismissing');
309
+ dialog.close();
310
+
311
+ if (resolve) resolve(undefined);
312
+ if (onClose) onClose();
313
+ }
314
+
315
+ // Hide programmatic popover
316
+ hidePopoverImmediate();
317
+
318
+ // Dismiss all toasts via CSS
319
+ if (toastContainer) {
320
+ for (const child of toastContainer.children) {
321
+ (child as HTMLElement).setAttribute('data-dismissing', '');
322
+ }
323
+ }
324
+
325
+ // Close declarative popovers found in the DOM
326
+ try {
327
+ for (const el of document.querySelectorAll(':popover-open')) {
328
+ (el as HTMLElement).hidePopover();
329
+ }
330
+ } catch {
331
+ // :popover-open not supported
332
+ }
333
+
334
+ // Close declarative dialogs found in the DOM (skip our own)
335
+ for (const el of document.querySelectorAll<HTMLDialogElement>('dialog[open]')) {
336
+ if (el !== dialog) el.close();
337
+ }
338
+ }
339
+
340
+ return {
341
+ modal,
342
+ closeModal,
343
+ toast,
344
+ popover,
345
+ closePopover,
346
+ dismissAll,
347
+ };
348
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Overlay Service Types
3
+ *
4
+ * Programmatic API for overlays. For simple cases, use declarative HTML
5
+ * attributes (commandfor/command + popover/dialog) — zero JS required.
6
+ * This service provides the imperative path for dynamic content,
7
+ * programmatic triggers, and complex workflows. dismissAll() is
8
+ * DOM-aware and closes both programmatic and declarative overlays.
9
+ */
10
+
11
+ export interface OverlayService {
12
+ modal<T = undefined>(options: ModalOptions<T>): Promise<T | undefined>;
13
+ closeModal<T>(value?: T): void;
14
+
15
+ popover(options: PopoverOptions): void;
16
+ closePopover(): void;
17
+
18
+ toast(options: ToastOptions): { dismiss(): void };
19
+
20
+ /** Close all open overlays — programmatic and declarative — and toasts. */
21
+ dismissAll(): void;
22
+ }
23
+
24
+ export interface ModalOptions<T = undefined> { // eslint-disable-line @typescript-eslint/no-unused-vars
25
+ render(dialog: HTMLDialogElement): void;
26
+ onClose?(): void;
27
+ }
28
+
29
+ export interface PopoverOptions {
30
+ anchor: HTMLElement;
31
+ render(el: HTMLDivElement): void;
32
+ }
33
+
34
+ export interface ToastOptions {
35
+ render(el: HTMLDivElement): void;
36
+ /** Auto-dismiss timeout in ms. Default 0 (manual dismiss only). Set to a positive ms value for auto-dismiss via CSS animation. */
37
+ timeout?: number;
38
+ }