@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,229 @@
1
+ /**
2
+ * Unified Component Architecture
3
+ *
4
+ * Everything is a Component: pages and widgets.
5
+ * Components render differently based on context:
6
+ * - /md/* → Markdown (LLMs, text clients)
7
+ * - /html/* → Pre-rendered HTML (SSR)
8
+ * - SPA → Hydrated custom elements
9
+ *
10
+ * Precedence (like .ts/.html/.md):
11
+ * - renderHTML() if defined → full HTML control
12
+ * - renderMarkdown() → converted to HTML via markdown renderer
13
+ */
14
+
15
+ import type { RouteInfo } from '../type/route.type.ts';
16
+ import { escapeHtml } from '../util/html.util.ts';
17
+
18
+ /**
19
+ * Context passed to components during rendering.
20
+ * Extends RouteInfo (pathname, pattern, params, searchParams)
21
+ * with pre-loaded file content and an abort signal.
22
+ *
23
+ * Consumers can extend this interface via module augmentation
24
+ * to add app-level services (RPC clients, auth, feature flags, etc.).
25
+ */
26
+ /** Shape of companion file contents (html, md, css). Used by generated `.page.files.g.ts` modules. */
27
+ export type FileContents = { html?: string; md?: string; css?: string };
28
+
29
+ export interface ComponentContext extends RouteInfo {
30
+ readonly files?: Readonly<FileContents>;
31
+ readonly signal?: AbortSignal;
32
+ /** True when this component is the leaf (matched) route, false when rendered as a layout parent. */
33
+ readonly isLeaf?: boolean;
34
+ /** Base path for SSR HTML links (e.g. '/html'). */
35
+ readonly basePath?: string;
36
+ }
37
+
38
+ /**
39
+ * Callback that enriches the base ComponentContext with app-level services.
40
+ * Registered once at router creation; called for every context construction.
41
+ *
42
+ * **1. Register** — always spread `base` to preserve routing/file/signal data:
43
+ * ```ts
44
+ * createSpaHtmlRouter(manifest, {
45
+ * extendContext: (base) => ({ ...base, rpc: myRpcClient }),
46
+ * });
47
+ * ```
48
+ *
49
+ * **2. Access** — expose custom properties to components via module augmentation:
50
+ * ```ts
51
+ * declare module '@emkodev/emroute' {
52
+ * interface ComponentContext { rpc: RpcClient; }
53
+ * }
54
+ * ```
55
+ * or per-component via the third generic:
56
+ * ```ts
57
+ * class MyPage extends PageComponent<Params, Data, AppContext> {}
58
+ * ```
59
+ */
60
+ export type ContextProvider = (base: ComponentContext) => ComponentContext;
61
+
62
+ /**
63
+ * Render context determines how components are rendered.
64
+ */
65
+ export type RenderContext = 'markdown' | 'html' | 'spa';
66
+
67
+ /**
68
+ * Abstract base class for all components.
69
+ *
70
+ * Subclasses must implement:
71
+ * - name: unique identifier for custom element tag
72
+ * - getData(): fetch/compute data
73
+ * - renderMarkdown(): render as markdown
74
+ *
75
+ * Optional override:
76
+ * - renderHTML(): custom HTML rendering (defaults to markdown→HTML conversion)
77
+ * - validateParams(): params validation
78
+ *
79
+ * @typeParam TContext — custom context shape; defaults to ComponentContext.
80
+ * Use with `extendContext` on the router to inject app-level services.
81
+ * See {@link ContextProvider} for details.
82
+ */
83
+ export abstract class Component<
84
+ TParams = unknown,
85
+ TData = unknown,
86
+ TContext extends ComponentContext = ComponentContext,
87
+ > {
88
+ /** Type carrier for getData args — use as `this['DataArgs']` in overrides. */
89
+ declare readonly DataArgs: {
90
+ params: TParams;
91
+ signal?: AbortSignal;
92
+ context: TContext;
93
+ };
94
+
95
+ /** Type carrier for render args — use as `this['RenderArgs']` in overrides. */
96
+ declare readonly RenderArgs: {
97
+ data: TData | null;
98
+ params: TParams;
99
+ context: TContext;
100
+ };
101
+
102
+ /** Unique name in kebab-case. Used for custom element: `<widget-{name}>` */
103
+ abstract readonly name: string;
104
+
105
+ /** Host element reference, set by ComponentElement in the browser. */
106
+ element?: HTMLElement;
107
+
108
+ /** Associated file paths for pre-loaded content (html, md, css). */
109
+ readonly files?: { html?: string; md?: string; css?: string };
110
+
111
+ /**
112
+ * When true, SSR serializes the getData() result into the element's
113
+ * light DOM so the client can access it immediately in hydrate()
114
+ * without re-fetching.
115
+ *
116
+ * Default is false — hydrate() receives `data: null`. Most widgets
117
+ * don't need this because the rendered Shadow DOM already contains
118
+ * the visual representation of the data.
119
+ *
120
+ * If you find yourself parsing the shadow DOM in hydrate() trying to
121
+ * reconstruct the original data object, set this to true instead.
122
+ * The server-fetched data will be available as `args.data` in hydrate().
123
+ */
124
+ readonly exposeSsrData?: boolean;
125
+
126
+ /**
127
+ * Fetch or compute data based on params.
128
+ * Called server-side for SSR, client-side for SPA.
129
+ *
130
+ * @example
131
+ * ```ts
132
+ * override async getData({ params, signal }: this['DataArgs']) {
133
+ * const res = await fetch(`/api/${params.id}`, { signal });
134
+ * return res.json();
135
+ * }
136
+ * ```
137
+ */
138
+ abstract getData(args: this['DataArgs']): Promise<TData | null>;
139
+
140
+ /**
141
+ * Render as markdown.
142
+ * This is the canonical content representation.
143
+ *
144
+ * @example
145
+ * ```ts
146
+ * override renderMarkdown({ data }: this['RenderArgs']) {
147
+ * return `# ${data?.title}`;
148
+ * }
149
+ * ```
150
+ */
151
+ abstract renderMarkdown(args: this['RenderArgs']): string;
152
+
153
+ /**
154
+ * Render as HTML for browser context.
155
+ *
156
+ * Default implementation converts renderMarkdown() output to HTML.
157
+ * Override for custom HTML rendering with rich styling/interactivity.
158
+ */
159
+ renderHTML(args: this['RenderArgs']): string {
160
+ if (args.data === null) {
161
+ return `<div data-component="${this.name}">Loading...</div>`;
162
+ }
163
+ // Default: wrap markdown in a container
164
+ // The actual markdown→HTML conversion happens at render time
165
+ const markdown = this.renderMarkdown({
166
+ data: args.data,
167
+ params: args.params,
168
+ context: args.context,
169
+ });
170
+ return `<div data-component="${this.name}" data-markdown>${escapeHtml(markdown)}</div>`;
171
+ }
172
+
173
+ /**
174
+ * Hydration hook called after SSR content is adopted or after SPA rendering.
175
+ * Use to attach event listeners to existing DOM without re-rendering.
176
+ *
177
+ * @example
178
+ * ```ts
179
+ * override hydrate({ data, params, context }: this['RenderArgs']) {
180
+ * const button = this.element?.querySelector('button');
181
+ * button?.addEventListener('click', () => this.deleteItem(data.id));
182
+ * }
183
+ * ```
184
+ */
185
+ hydrate?(args: this['RenderArgs']): void;
186
+
187
+ /**
188
+ * Cleanup hook called when the component is removed from the DOM.
189
+ * Use for clearing timers, removing event listeners, unmounting
190
+ * third-party renderers, closing connections, etc.
191
+ *
192
+ * Intentionally synchronous (called from disconnectedCallback). You can
193
+ * fire async cleanup here, but it will not be awaited.
194
+ */
195
+ destroy?(): void;
196
+
197
+ /**
198
+ * Validate params.
199
+ * @returns Error message if invalid, undefined if valid.
200
+ */
201
+ validateParams?(params: TParams): string | undefined;
202
+
203
+ /**
204
+ * Render error state.
205
+ */
206
+ renderError(args: { error: unknown; params: TParams }): string {
207
+ const msg = args.error instanceof Error ? args.error.message : String(args.error);
208
+ return `<div data-component="${this.name}">Error: ${escapeHtml(msg)}</div>`;
209
+ }
210
+
211
+ /**
212
+ * Render error as markdown.
213
+ */
214
+ renderMarkdownError(error: unknown): string {
215
+ const msg = error instanceof Error ? error.message : String(error);
216
+ return `> **Error** (\`${this.name}\`): ${msg}`;
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Component manifest entry for code generation.
222
+ */
223
+ export interface ComponentManifestEntry {
224
+ name: string;
225
+ modulePath: string;
226
+ tagName: string;
227
+ type: 'page' | 'widget';
228
+ pattern?: string;
229
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Page Component
3
+ *
4
+ * Page component — params come from URL, context carries file content.
5
+ *
6
+ * Default implementations follow the fallback table:
7
+ * - renderHTML: html file → md via <mark-down> → <router-slot /> (non-leaf only)
8
+ * - renderMarkdown: md file → ```router-slot\n``` (non-leaf only)
9
+ * - getData: no-op (returns null)
10
+ */
11
+
12
+ import { Component, type ComponentContext } from './abstract.component.ts';
13
+ import { escapeHtml } from '../util/html.util.ts';
14
+
15
+ export class PageComponent<
16
+ TParams extends Record<string, string> = Record<string, string>,
17
+ TData = unknown,
18
+ TContext extends ComponentContext = ComponentContext,
19
+ > extends Component<TParams, TData, TContext> {
20
+ override readonly name: string = 'page';
21
+
22
+ /** Route pattern this page handles (optional — set by subclasses) */
23
+ readonly pattern?: string;
24
+
25
+ /**
26
+ * Fetch or compute page data. Override in subclasses.
27
+ * Default: returns null (no data needed).
28
+ *
29
+ * @example
30
+ * ```ts
31
+ * override getData({ params, context }: this['DataArgs']) {
32
+ * return fetch(`/api/${params.id}`, { signal: context?.signal });
33
+ * }
34
+ * ```
35
+ */
36
+ override getData(
37
+ _args: this['DataArgs'],
38
+ ): Promise<TData | null> {
39
+ return Promise.resolve(null);
40
+ }
41
+
42
+ /**
43
+ * Render page as HTML.
44
+ *
45
+ * Fallback chain:
46
+ * 1. html file content from context
47
+ * 2. md file content wrapped in `<mark-down>`
48
+ * 3. `<router-slot />` (bare slot for child routes)
49
+ *
50
+ * @example
51
+ * ```ts
52
+ * override renderHTML({ data, params, context }: this['RenderArgs']) {
53
+ * return `<h1>${params.id}</h1><p>${context?.files?.html ?? ''}</p>`;
54
+ * }
55
+ * ```
56
+ */
57
+ override renderHTML(
58
+ args: this['RenderArgs'],
59
+ ): string {
60
+ const files = args.context.files;
61
+ const style = files?.css ? `<style>${files.css}</style>\n` : '';
62
+
63
+ if (files?.html) {
64
+ let html = style + files.html;
65
+ if (files.md && html.includes('<mark-down></mark-down>')) {
66
+ html = html.replace(
67
+ '<mark-down></mark-down>',
68
+ `<mark-down>${escapeHtml(files.md)}</mark-down>`,
69
+ );
70
+ }
71
+ return html;
72
+ }
73
+
74
+ if (files?.md) {
75
+ // HOTFIX: skip external <router-slot> when markdown already defines one
76
+ // via ```router-slot fenced block. Without this, SPA mode produces two
77
+ // slots with the same pattern — one inside <mark-down> (from the fenced
78
+ // block) and one outside (from this suffix). SSR strips empty duplicates
79
+ // via stripSlots, but SPA has no equivalent cleanup.
80
+ // See: issues/pending/spa-duplicate-router-slot.issue.md
81
+ const hasSlot = files.md.includes('```router-slot');
82
+ const slot = args.context.isLeaf || hasSlot ? '' : '\n<router-slot></router-slot>';
83
+ return `${style}<mark-down>${escapeHtml(files.md)}</mark-down>${slot}`;
84
+ }
85
+
86
+ return args.context.isLeaf ? '' : '<router-slot></router-slot>';
87
+ }
88
+
89
+ /**
90
+ * Render page as Markdown.
91
+ *
92
+ * Fallback chain:
93
+ * 1. md file content from context
94
+ * 2. `` ```router-slot\n``` `` (slot placeholder in markdown — newline required)
95
+ *
96
+ * @example
97
+ * ```ts
98
+ * override renderMarkdown({ data, params, context }: this['RenderArgs']) {
99
+ * return `# ${params.id}\n\n${context?.files?.md ?? ''}`;
100
+ * }
101
+ * ```
102
+ */
103
+ override renderMarkdown(
104
+ args: this['RenderArgs'],
105
+ ): string {
106
+ const files = args.context.files;
107
+
108
+ if (files?.md) {
109
+ return files.md;
110
+ }
111
+
112
+ return args.context.isLeaf ? '' : '```router-slot\n```';
113
+ }
114
+
115
+ /**
116
+ * Page title. Override in subclasses.
117
+ * Default: undefined (no title).
118
+ *
119
+ * @example
120
+ * ```ts
121
+ * override getTitle({ data, params }: this['RenderArgs']) {
122
+ * return `Project ${params.id}`;
123
+ * }
124
+ * ```
125
+ */
126
+ getTitle(
127
+ _args: this['RenderArgs'],
128
+ ): string | undefined {
129
+ return undefined;
130
+ }
131
+ }
132
+
133
+ /** Shared default instance used by renderers when no custom .page.ts exists. */
134
+ export default new PageComponent();
@@ -0,0 +1,85 @@
1
+ /**
2
+ * WidgetComponent — embeddable unit within page content.
3
+ *
4
+ * Everything reusable that is not a page is a Widget.
5
+ * Widgets render across all contexts (HTML, Markdown, SPA) and are
6
+ * resolved by name via WidgetRegistry.
7
+ *
8
+ * Pages live in the routes manifest. Widgets live in the registry.
9
+ *
10
+ * Default rendering fallback chains (parallel to PageComponent):
11
+ * - renderHTML: html file → md file in <mark-down> → base Component default
12
+ * - renderMarkdown: md file → ''
13
+ */
14
+
15
+ import { Component, type ComponentContext } from './abstract.component.ts';
16
+ import { escapeHtml, scopeWidgetCss } from '../util/html.util.ts';
17
+
18
+ export abstract class WidgetComponent<
19
+ TParams = unknown,
20
+ TData = unknown,
21
+ TContext extends ComponentContext = ComponentContext,
22
+ > extends Component<TParams, TData, TContext> {
23
+ /**
24
+ * Render widget as HTML.
25
+ *
26
+ * Fallback chain:
27
+ * 1. html file content from context
28
+ * 2. md file content wrapped in `<mark-down>`
29
+ * 3. base Component default (markdown→HTML conversion)
30
+ *
31
+ * @example
32
+ * ```ts
33
+ * override renderHTML({ data, params }: this['RenderArgs']) {
34
+ * return `<span>${params.coin}: $${data?.price}</span>`;
35
+ * }
36
+ * ```
37
+ */
38
+ override renderHTML(
39
+ args: this['RenderArgs'],
40
+ ): string {
41
+ const files = args.context.files;
42
+ // @scope needed for SSR Light DOM output; redundant but harmless in SPA Shadow DOM
43
+ const style = files?.css ? `<style>${scopeWidgetCss(files.css, this.name)}</style>\n` : '';
44
+
45
+ if (files?.html) {
46
+ return style + files.html;
47
+ }
48
+
49
+ if (files?.md) {
50
+ return `${style}<mark-down>${escapeHtml(files.md)}</mark-down>`;
51
+ }
52
+
53
+ if (style) {
54
+ return style + super.renderHTML(args);
55
+ }
56
+
57
+ return super.renderHTML(args);
58
+ }
59
+
60
+ /**
61
+ * Render widget as Markdown.
62
+ *
63
+ * Fallback chain:
64
+ * 1. md file content from context
65
+ * 2. empty string
66
+ *
67
+ * @example
68
+ * ```ts
69
+ * override renderMarkdown({ data, params }: this['RenderArgs']) {
70
+ * return `**${params.coin}**: $${data?.price}`;
71
+ * }
72
+ * ```
73
+ */
74
+ override renderMarkdown(
75
+ args: this['RenderArgs'],
76
+ ): string {
77
+ const files = args.context.files;
78
+
79
+ if (files?.md) {
80
+ return files.md;
81
+ }
82
+
83
+ return '';
84
+ }
85
+ }