@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,142 @@
1
+ /**
2
+ * SSR Markdown Renderer
3
+ *
4
+ * Server-side Markdown rendering.
5
+ * Generates Markdown strings for LLM consumption, text clients, curl.
6
+ */
7
+
8
+ import type { RouteConfig, RouteInfo, RoutesManifest } from '../../type/route.type.ts';
9
+ import type { PageComponent } from '../../component/page.component.ts';
10
+ import { DEFAULT_ROOT_ROUTE } from '../../route/route.core.ts';
11
+ import { STATUS_MESSAGES } from '../../util/html.util.ts';
12
+ import { resolveRecursively } from '../../util/widget-resolve.util.ts';
13
+ import { parseWidgetBlocks, replaceWidgetBlocks } from '../../widget/widget.parser.ts';
14
+ import { SsrRenderer, type SsrRendererOptions } from './ssr.renderer.ts';
15
+
16
+ const BARE_SLOT_BLOCK = '```router-slot\n```';
17
+
18
+ function routerSlotBlock(pattern: string): string {
19
+ return `\`\`\`router-slot\n{"pattern":"${pattern}"}\n\`\`\``;
20
+ }
21
+
22
+ /** Options for SSR Markdown Router */
23
+ export type SsrMdRouterOptions = SsrRendererOptions;
24
+
25
+ /**
26
+ * SSR Markdown Router for server-side markdown rendering.
27
+ */
28
+ export class SsrMdRouter extends SsrRenderer {
29
+ protected override readonly label = 'SSR MD';
30
+
31
+ constructor(manifest: RoutesManifest, options: SsrMdRouterOptions = {}) {
32
+ super(manifest, options);
33
+ }
34
+
35
+ protected override injectSlot(parent: string, child: string, parentPattern: string): string {
36
+ return parent.replace(routerSlotBlock(parentPattern), child);
37
+ }
38
+
39
+ protected override stripSlots(result: string): string {
40
+ return result
41
+ .replace(/```router-slot\n(?:\{[^}]*\}\n)?```/g, '')
42
+ .trim();
43
+ }
44
+
45
+ /**
46
+ * Render a single route's content to Markdown.
47
+ */
48
+ protected override async renderRouteContent(
49
+ routeInfo: RouteInfo,
50
+ route: RouteConfig,
51
+ isLeaf?: boolean,
52
+ ): Promise<{ content: string; title?: string }> {
53
+ if (route.modulePath === DEFAULT_ROOT_ROUTE.modulePath) {
54
+ return { content: routerSlotBlock(route.pattern) };
55
+ }
56
+
57
+ const { content: rawContent, title } = await this.loadRouteContent(routeInfo, route, isLeaf);
58
+ let content = rawContent;
59
+
60
+ // Attribute bare router-slot blocks with this route's pattern
61
+ // (before widget resolution so widget-internal blocks are not affected)
62
+ content = content.replaceAll(BARE_SLOT_BLOCK, routerSlotBlock(route.pattern));
63
+
64
+ // Resolve fenced widget blocks: call getData() + renderMarkdown()
65
+ if (this.widgets) {
66
+ content = await this.resolveWidgets(content, routeInfo);
67
+ }
68
+
69
+ return { content, title };
70
+ }
71
+
72
+ protected override renderContent(
73
+ component: PageComponent,
74
+ args: PageComponent['RenderArgs'],
75
+ ): string {
76
+ return component.renderMarkdown(args);
77
+ }
78
+
79
+ protected override renderRedirect(to: string): string {
80
+ return `Redirect to: ${to}`;
81
+ }
82
+
83
+ protected override renderStatusPage(status: number, pathname: string): string {
84
+ return `# ${STATUS_MESSAGES[status] ?? 'Error'}\n\nPath: \`${pathname}\``;
85
+ }
86
+
87
+ protected override renderErrorPage(_error: unknown, pathname: string): string {
88
+ return `# Internal Server Error\n\nPath: \`${pathname}\``;
89
+ }
90
+
91
+ /**
92
+ * Resolve fenced widget blocks in markdown content.
93
+ * Replaces ```widget:name blocks with rendered markdown output.
94
+ */
95
+ private resolveWidgets(
96
+ content: string,
97
+ routeInfo: RouteInfo,
98
+ ): Promise<string> {
99
+ return resolveRecursively(
100
+ content,
101
+ parseWidgetBlocks,
102
+ async (block) => {
103
+ if (block.parseError || !block.params) {
104
+ return `> **Error** (\`${block.widgetName}\`): ${block.parseError}`;
105
+ }
106
+
107
+ const widget = this.widgets!.get(block.widgetName);
108
+ if (!widget) {
109
+ return `> **Error**: Unknown widget \`${block.widgetName}\``;
110
+ }
111
+
112
+ try {
113
+ let files: { html?: string; md?: string } | undefined;
114
+ const filePaths = this.widgetFiles[block.widgetName] ?? widget.files;
115
+ if (filePaths) {
116
+ files = await this.core.loadWidgetFiles(filePaths);
117
+ }
118
+
119
+ const baseContext = { ...routeInfo, files };
120
+ const context = this.core.contextProvider
121
+ ? this.core.contextProvider(baseContext)
122
+ : baseContext;
123
+ const data = await widget.getData({ params: block.params, context });
124
+ return widget.renderMarkdown({ data, params: block.params, context });
125
+ } catch (e) {
126
+ return widget.renderMarkdownError(e);
127
+ }
128
+ },
129
+ replaceWidgetBlocks,
130
+ );
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Create SSR Markdown router.
136
+ */
137
+ export function createSsrMdRouter(
138
+ manifest: RoutesManifest,
139
+ options?: SsrMdRouterOptions,
140
+ ): SsrMdRouter {
141
+ return new SsrMdRouter(manifest, options);
142
+ }
@@ -0,0 +1,286 @@
1
+ /**
2
+ * SSR Renderer Base
3
+ *
4
+ * Abstract base class for server-side renderers.
5
+ * Provides the shared render() pipeline; subclasses supply format-specific rendering.
6
+ */
7
+
8
+ import type {
9
+ MatchedRoute,
10
+ RouteConfig,
11
+ RouteInfo,
12
+ RoutesManifest,
13
+ } from '../../type/route.type.ts';
14
+ import { logger } from '../../type/logger.type.ts';
15
+ import type { ComponentContext } from '../../component/abstract.component.ts';
16
+ import defaultPageComponent, { type PageComponent } from '../../component/page.component.ts';
17
+ import {
18
+ assertSafeRedirect,
19
+ DEFAULT_ROOT_ROUTE,
20
+ RouteCore,
21
+ type RouteCoreOptions,
22
+ } from '../../route/route.core.ts';
23
+ import { toUrl } from '../../route/route.matcher.ts';
24
+ import type { WidgetRegistry } from '../../widget/widget.registry.ts';
25
+
26
+ /** Base options for SSR renderers */
27
+ export interface SsrRendererOptions extends RouteCoreOptions {
28
+ /** Widget registry for server-side widget rendering */
29
+ widgets?: WidgetRegistry;
30
+ /** Widget companion file paths, keyed by widget name */
31
+ widgetFiles?: Record<string, { html?: string; md?: string; css?: string }>;
32
+ }
33
+
34
+ /**
35
+ * Abstract SSR renderer with shared routing pipeline.
36
+ */
37
+ export abstract class SsrRenderer {
38
+ protected core: RouteCore;
39
+ protected widgets: WidgetRegistry | null;
40
+ protected widgetFiles: Record<string, { html?: string; md?: string; css?: string }>;
41
+ protected abstract readonly label: string;
42
+
43
+ constructor(manifest: RoutesManifest, options: SsrRendererOptions = {}) {
44
+ this.core = new RouteCore(manifest, options);
45
+ this.widgets = options.widgets ?? null;
46
+ this.widgetFiles = options.widgetFiles ?? {};
47
+ }
48
+
49
+ /**
50
+ * Render a URL to a content string.
51
+ */
52
+ async render(
53
+ url: string,
54
+ ): Promise<{ content: string; status: number; title?: string; redirect?: string }> {
55
+ const urlObj = toUrl(url);
56
+ const pathname = urlObj.pathname;
57
+
58
+ // Redirect trailing-slash URLs to canonical form (301)
59
+ const normalized = this.core.normalizeUrl(pathname);
60
+ if (normalized !== pathname) {
61
+ const query = urlObj.search || '';
62
+ return { content: '', status: 301, redirect: normalized + query };
63
+ }
64
+
65
+ const matched = this.core.match(urlObj);
66
+
67
+ const searchParams = urlObj.searchParams ?? new URLSearchParams();
68
+
69
+ if (!matched) {
70
+ const statusPage = this.core.matcher.getStatusPage(404);
71
+ if (statusPage) {
72
+ try {
73
+ const ri: RouteInfo = { pathname, pattern: statusPage.pattern, params: {}, searchParams };
74
+ const result = await this.renderRouteContent(ri, statusPage);
75
+ return { content: this.stripSlots(result.content), status: 404, title: result.title };
76
+ } catch (e) {
77
+ logger.error(
78
+ `[${this.label}] Failed to render 404 status page for ${pathname}`,
79
+ e instanceof Error ? e : undefined,
80
+ );
81
+ }
82
+ }
83
+ return { content: this.renderStatusPage(404, pathname), status: 404 };
84
+ }
85
+
86
+ // Handle redirect
87
+ if (matched.route.type === 'redirect') {
88
+ const module = await this.core.loadModule<{ default: { to: string; status?: number } }>(
89
+ matched.route.modulePath,
90
+ );
91
+ const redirectConfig = module.default;
92
+ assertSafeRedirect(redirectConfig.to);
93
+ return {
94
+ content: this.renderRedirect(redirectConfig.to),
95
+ status: redirectConfig.status ?? 301,
96
+ };
97
+ }
98
+
99
+ const routeInfo = this.core.toRouteInfo(matched, pathname);
100
+
101
+ try {
102
+ const { content, title } = await this.renderPage(routeInfo, matched);
103
+ return { content, status: 200, title };
104
+ } catch (error) {
105
+ if (error instanceof Response) {
106
+ const statusPage = this.core.matcher.getStatusPage(error.status);
107
+ if (statusPage) {
108
+ try {
109
+ const ri: RouteInfo = {
110
+ pathname,
111
+ pattern: statusPage.pattern,
112
+ params: {},
113
+ searchParams,
114
+ };
115
+ const result = await this.renderRouteContent(ri, statusPage);
116
+ return {
117
+ content: this.stripSlots(result.content),
118
+ status: error.status,
119
+ title: result.title,
120
+ };
121
+ } catch (e) {
122
+ logger.error(
123
+ `[${this.label}] Failed to render ${error.status} status page for ${pathname}`,
124
+ e instanceof Error ? e : undefined,
125
+ );
126
+ }
127
+ }
128
+ return { content: this.renderStatusPage(error.status, pathname), status: error.status };
129
+ }
130
+ logger.error(
131
+ `[${this.label}] Error rendering ${pathname}:`,
132
+ error instanceof Error ? error : undefined,
133
+ );
134
+
135
+ const boundary = this.core.matcher.findErrorBoundary(pathname);
136
+ if (boundary) {
137
+ const result = await this.tryRenderErrorModule(boundary.modulePath, pathname, 'boundary');
138
+ if (result) return result;
139
+ }
140
+
141
+ const errorHandler = this.core.matcher.getErrorHandler();
142
+ if (errorHandler) {
143
+ const result = await this.tryRenderErrorModule(errorHandler.modulePath, pathname, 'handler');
144
+ if (result) return result;
145
+ }
146
+
147
+ return { content: this.renderErrorPage(error, pathname), status: 500 };
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Render a matched page by composing the route hierarchy.
153
+ */
154
+ protected async renderPage(
155
+ routeInfo: RouteInfo,
156
+ matched: MatchedRoute,
157
+ ): Promise<{ content: string; title?: string }> {
158
+ const hierarchy = this.core.buildRouteHierarchy(routeInfo.pattern);
159
+
160
+ let result = '';
161
+ let pageTitle: string | undefined;
162
+ let lastRenderedPattern = '';
163
+
164
+ for (let i = 0; i < hierarchy.length; i++) {
165
+ const routePattern = hierarchy[i];
166
+ let route = this.core.matcher.findRoute(routePattern);
167
+
168
+ if (!route && routePattern === this.core.root) {
169
+ route = { ...DEFAULT_ROOT_ROUTE, pattern: this.core.root };
170
+ }
171
+
172
+ if (!route) continue;
173
+
174
+ // Skip wildcard route appearing as its own parent (prevents double-render)
175
+ if (route === matched.route && routePattern !== matched.route.pattern) continue;
176
+
177
+ const isLeaf = i === hierarchy.length - 1;
178
+ const { content, title } = await this.renderRouteContent(routeInfo, route, isLeaf);
179
+
180
+ if (title) {
181
+ pageTitle = title;
182
+ }
183
+
184
+ if (result === '') {
185
+ result = content;
186
+ } else {
187
+ const injected = this.injectSlot(result, content, lastRenderedPattern);
188
+ if (injected === result) {
189
+ logger.warn(
190
+ `[${this.label}] Route "${lastRenderedPattern}" has no <router-slot> ` +
191
+ `for child route "${routePattern}" to render into. ` +
192
+ `Add <router-slot></router-slot> to the parent template.`,
193
+ );
194
+ }
195
+ result = injected;
196
+ }
197
+
198
+ lastRenderedPattern = route.pattern;
199
+ }
200
+
201
+ result = this.stripSlots(result);
202
+
203
+ return { content: result, title: pageTitle };
204
+ }
205
+
206
+ protected abstract renderRouteContent(
207
+ routeInfo: RouteInfo,
208
+ route: RouteConfig,
209
+ isLeaf?: boolean,
210
+ ): Promise<{ content: string; title?: string }>;
211
+
212
+ /** Load component, build context, get data, render content, get title. */
213
+ protected async loadRouteContent(
214
+ routeInfo: RouteInfo,
215
+ route: RouteConfig,
216
+ isLeaf?: boolean,
217
+ ): Promise<{ content: string; title?: string }> {
218
+ const files = route.files ?? {};
219
+
220
+ const tsModule = files.ts;
221
+ const component: PageComponent = tsModule
222
+ ? (await this.core.loadModule<{ default: PageComponent }>(tsModule)).default
223
+ : defaultPageComponent;
224
+
225
+ const context = await this.core.buildComponentContext(routeInfo, route, undefined, isLeaf);
226
+ const data = await component.getData({ params: routeInfo.params, context });
227
+ const content = this.renderContent(component, { data, params: routeInfo.params, context });
228
+ const title = component.getTitle({ data, params: routeInfo.params, context });
229
+
230
+ return { content, title };
231
+ }
232
+
233
+ /** Render a component to the output format (HTML or Markdown). */
234
+ protected abstract renderContent(
235
+ component: PageComponent,
236
+ args: PageComponent['RenderArgs'],
237
+ ): string;
238
+
239
+ /** Render a component for error boundary/handler with minimal context. */
240
+ protected renderComponent(
241
+ component: PageComponent,
242
+ data: unknown,
243
+ context: ComponentContext,
244
+ ): string {
245
+ return this.renderContent(component, { data, params: {}, context });
246
+ }
247
+
248
+ /** Try to load and render an error boundary or handler module. Returns null on failure. */
249
+ private async tryRenderErrorModule(
250
+ modulePath: string,
251
+ pathname: string,
252
+ kind: 'boundary' | 'handler',
253
+ ): Promise<{ content: string; status: number } | null> {
254
+ try {
255
+ const module = await this.core.loadModule<{ default: PageComponent }>(modulePath);
256
+ const component = module.default;
257
+ const minCtx: ComponentContext = {
258
+ pathname: '',
259
+ pattern: '',
260
+ params: {},
261
+ searchParams: new URLSearchParams(),
262
+ };
263
+ const data = await component.getData({ params: {}, context: minCtx });
264
+ const content = this.renderComponent(component, data, minCtx);
265
+ return { content, status: 500 };
266
+ } catch (e) {
267
+ logger.error(
268
+ `[${this.label}] Error ${kind} failed for ${pathname}`,
269
+ e instanceof Error ? e : undefined,
270
+ );
271
+ return null;
272
+ }
273
+ }
274
+
275
+ protected abstract renderRedirect(to: string): string;
276
+
277
+ protected abstract renderStatusPage(status: number, pathname: string): string;
278
+
279
+ protected abstract renderErrorPage(error: unknown, pathname: string): string;
280
+
281
+ /** Inject child content into the slot owned by parentPattern. */
282
+ protected abstract injectSlot(parent: string, child: string, parentPattern: string): string;
283
+
284
+ /** Strip all unconsumed slot placeholders from the final result. */
285
+ protected abstract stripSlots(result: string): string;
286
+ }