@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.
- package/LICENSE +28 -0
- package/README.md +147 -12
- package/package.json +48 -7
- package/runtime/abstract.runtime.ts +441 -0
- package/runtime/bun/esbuild-runtime-loader.plugin.ts +94 -0
- package/runtime/bun/fs/bun-fs.runtime.ts +245 -0
- package/runtime/bun/sqlite/bun-sqlite.runtime.ts +279 -0
- package/runtime/sitemap.generator.ts +180 -0
- package/server/codegen.util.ts +66 -0
- package/server/emroute.server.ts +398 -0
- package/server/esbuild-manifest.plugin.ts +243 -0
- package/server/scanner.util.ts +243 -0
- package/server/server-api.type.ts +90 -0
- package/src/component/abstract.component.ts +229 -0
- package/src/component/page.component.ts +134 -0
- package/src/component/widget.component.ts +85 -0
- package/src/element/component.element.ts +353 -0
- package/src/element/markdown.element.ts +107 -0
- package/src/element/slot.element.ts +31 -0
- package/src/index.ts +61 -0
- package/src/overlay/mod.ts +10 -0
- package/src/overlay/overlay.css.ts +170 -0
- package/src/overlay/overlay.service.ts +348 -0
- package/src/overlay/overlay.type.ts +38 -0
- package/src/renderer/spa/base.renderer.ts +186 -0
- package/src/renderer/spa/hash.renderer.ts +215 -0
- package/src/renderer/spa/html.renderer.ts +382 -0
- package/src/renderer/spa/mod.ts +76 -0
- package/src/renderer/ssr/html.renderer.ts +159 -0
- package/src/renderer/ssr/md.renderer.ts +142 -0
- package/src/renderer/ssr/ssr.renderer.ts +286 -0
- package/src/route/route.core.ts +316 -0
- package/src/route/route.matcher.ts +260 -0
- package/src/type/logger.type.ts +24 -0
- package/src/type/markdown.type.ts +21 -0
- package/src/type/navigation-api.d.ts +95 -0
- package/src/type/route.type.ts +149 -0
- package/src/type/widget.type.ts +65 -0
- package/src/util/html.util.ts +186 -0
- package/src/util/logger.util.ts +83 -0
- package/src/util/widget-resolve.util.ts +197 -0
- package/src/web-doc/index.md +15 -0
- package/src/widget/breadcrumb.widget.ts +106 -0
- package/src/widget/page-title.widget.ts +52 -0
- package/src/widget/widget.parser.ts +89 -0
- package/src/widget/widget.registry.ts +51 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base Renderer
|
|
3
|
+
*
|
|
4
|
+
* Shared rendering logic for SPA and Hash routers:
|
|
5
|
+
* - Route hierarchy traversal with nested slot rendering
|
|
6
|
+
* - Component loading, data fetching, and HTML rendering
|
|
7
|
+
* - Markdown render waiting
|
|
8
|
+
* - Document title updates
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { MatchedRoute, RouteConfig, RouteInfo } from '../../type/route.type.ts';
|
|
12
|
+
import defaultPageComponent, { type PageComponent } from '../../component/page.component.ts';
|
|
13
|
+
import { DEFAULT_ROOT_ROUTE, RouteCore } from '../../route/route.core.ts';
|
|
14
|
+
import { logger } from '../../util/logger.util.ts';
|
|
15
|
+
|
|
16
|
+
const MARKDOWN_RENDER_TIMEOUT = 5000;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Abstract base for renderers that share the page rendering pipeline.
|
|
20
|
+
* Subclasses provide navigation mechanics (Navigation API, hashchange, etc.).
|
|
21
|
+
*/
|
|
22
|
+
export abstract class BaseRenderer {
|
|
23
|
+
protected core: RouteCore;
|
|
24
|
+
protected slot: Element | null = null;
|
|
25
|
+
|
|
26
|
+
constructor(core: RouteCore) {
|
|
27
|
+
this.core = core;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Render a matched page route with nested route support.
|
|
32
|
+
*/
|
|
33
|
+
protected async renderPage(
|
|
34
|
+
routeInfo: RouteInfo,
|
|
35
|
+
matched: MatchedRoute,
|
|
36
|
+
signal: AbortSignal,
|
|
37
|
+
): Promise<void> {
|
|
38
|
+
if (!this.slot) return;
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const hierarchy = this.core.buildRouteHierarchy(routeInfo.pattern);
|
|
42
|
+
logger.render('page', routeInfo.pattern, `hierarchy: ${hierarchy.join(' > ')}`);
|
|
43
|
+
|
|
44
|
+
let currentSlot: Element = this.slot;
|
|
45
|
+
let pageTitle: string | undefined;
|
|
46
|
+
|
|
47
|
+
for (let i = 0; i < hierarchy.length; i++) {
|
|
48
|
+
if (signal.aborted) return;
|
|
49
|
+
|
|
50
|
+
const routePattern = hierarchy[i];
|
|
51
|
+
const isLeaf = i === hierarchy.length - 1;
|
|
52
|
+
|
|
53
|
+
let route = this.core.matcher.findRoute(routePattern);
|
|
54
|
+
|
|
55
|
+
if (!route && routePattern === this.core.root) {
|
|
56
|
+
route = { ...DEFAULT_ROOT_ROUTE, pattern: this.core.root };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!route) {
|
|
60
|
+
logger.render('skip', routePattern, 'route not found');
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const routeType = isLeaf ? 'leaf' : 'layout';
|
|
65
|
+
logger.render(routeType, routePattern, `${route.files?.ts ?? 'default'} → slot`);
|
|
66
|
+
|
|
67
|
+
// Skip wildcard route appearing as its own parent (prevents double-render)
|
|
68
|
+
if (route === matched.route && routePattern !== matched.route.pattern) {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const { html, title } = await this.renderRouteContent(routeInfo, route, signal, isLeaf);
|
|
73
|
+
if (signal.aborted) return;
|
|
74
|
+
|
|
75
|
+
currentSlot.setHTMLUnsafe(html);
|
|
76
|
+
|
|
77
|
+
if (title) {
|
|
78
|
+
pageTitle = title;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Wait for <mark-down> to finish rendering its content
|
|
82
|
+
// (must happen before attributing slots — router-slot may be inside markdown)
|
|
83
|
+
const markDown = currentSlot.querySelector<HTMLElement>('mark-down');
|
|
84
|
+
if (markDown) {
|
|
85
|
+
await this.waitForMarkdownRender(markDown);
|
|
86
|
+
if (signal.aborted) return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Attribute bare <router-slot> tags with this route's pattern
|
|
90
|
+
for (const slot of currentSlot.querySelectorAll('router-slot:not([pattern])')) {
|
|
91
|
+
slot.setAttribute('pattern', routePattern);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!isLeaf) {
|
|
95
|
+
const nestedSlot = currentSlot.querySelector(
|
|
96
|
+
`router-slot[pattern="${CSS.escape(routePattern)}"]`,
|
|
97
|
+
);
|
|
98
|
+
if (nestedSlot) {
|
|
99
|
+
currentSlot = nestedSlot;
|
|
100
|
+
} else {
|
|
101
|
+
logger.warn(
|
|
102
|
+
`Route "${routePattern}" has no <router-slot> ` +
|
|
103
|
+
`for child routes to render into. ` +
|
|
104
|
+
`Add <router-slot></router-slot> to the parent template.`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (signal.aborted) return;
|
|
111
|
+
|
|
112
|
+
this.updateTitle(pageTitle);
|
|
113
|
+
|
|
114
|
+
this.core.emit({
|
|
115
|
+
type: 'load',
|
|
116
|
+
pathname: routeInfo.pattern,
|
|
117
|
+
params: routeInfo.params,
|
|
118
|
+
});
|
|
119
|
+
} catch (error) {
|
|
120
|
+
if (signal.aborted) return;
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Render a single route's content.
|
|
127
|
+
*/
|
|
128
|
+
protected async renderRouteContent(
|
|
129
|
+
routeInfo: RouteInfo,
|
|
130
|
+
route: RouteConfig,
|
|
131
|
+
signal: AbortSignal,
|
|
132
|
+
isLeaf?: boolean,
|
|
133
|
+
): Promise<{ html: string; title?: string }> {
|
|
134
|
+
if (route.modulePath === DEFAULT_ROOT_ROUTE.modulePath) {
|
|
135
|
+
return { html: `<router-slot pattern="${route.pattern}"></router-slot>` };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const files = route.files ?? {};
|
|
139
|
+
|
|
140
|
+
const component: PageComponent = files.ts
|
|
141
|
+
? (await this.core.loadModule<{ default: PageComponent }>(files.ts)).default
|
|
142
|
+
: defaultPageComponent;
|
|
143
|
+
|
|
144
|
+
const context = await this.core.buildComponentContext(routeInfo, route, signal, isLeaf);
|
|
145
|
+
const data = await component.getData({ params: routeInfo.params, signal, context });
|
|
146
|
+
const html = component.renderHTML({ data, params: routeInfo.params, context });
|
|
147
|
+
const title = component.getTitle({ data, params: routeInfo.params, context });
|
|
148
|
+
return { html, title };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Wait for a <mark-down> element to finish rendering.
|
|
153
|
+
*/
|
|
154
|
+
protected waitForMarkdownRender(element: HTMLElement): Promise<void> {
|
|
155
|
+
return new Promise((resolve) => {
|
|
156
|
+
if (element.children.length > 0) {
|
|
157
|
+
resolve();
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const timeout = setTimeout(() => {
|
|
162
|
+
observer.disconnect();
|
|
163
|
+
resolve();
|
|
164
|
+
}, MARKDOWN_RENDER_TIMEOUT);
|
|
165
|
+
|
|
166
|
+
const observer = new MutationObserver(() => {
|
|
167
|
+
if (element.children.length > 0) {
|
|
168
|
+
clearTimeout(timeout);
|
|
169
|
+
observer.disconnect();
|
|
170
|
+
resolve();
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
observer.observe(element, { childList: true });
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Update document.title from getTitle() result.
|
|
180
|
+
*/
|
|
181
|
+
protected updateTitle(pageTitle?: string): void {
|
|
182
|
+
if (pageTitle) {
|
|
183
|
+
document.title = pageTitle;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hash Router
|
|
3
|
+
*
|
|
4
|
+
* Lightweight client-side router for leaf mode mini-apps.
|
|
5
|
+
* Uses hashchange events + RouteCore pattern matching.
|
|
6
|
+
*
|
|
7
|
+
* Routes are defined inline by the consumer, not from the main manifest.
|
|
8
|
+
* Coexists with SpaHtmlRouter in root mode (SPA router skips hash changes).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { RoutesManifest } from '../../type/route.type.ts';
|
|
12
|
+
import type { ContextProvider } from '../../component/abstract.component.ts';
|
|
13
|
+
import { RouteCore } from '../../route/route.core.ts';
|
|
14
|
+
import { escapeHtml } from '../../util/html.util.ts';
|
|
15
|
+
import { logger } from '../../util/logger.util.ts';
|
|
16
|
+
import { BaseRenderer } from './base.renderer.ts';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A single hash route definition with a lazy module loader.
|
|
20
|
+
* @experimental
|
|
21
|
+
*/
|
|
22
|
+
export interface HashRouteConfig {
|
|
23
|
+
/** URLPattern pathname pattern (e.g. '/settings', '/users/:id'). */
|
|
24
|
+
pattern: string;
|
|
25
|
+
/** Lazy loader returning a module with a default PageComponent export. */
|
|
26
|
+
loader: () => Promise<unknown>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Options for creating a HashRouter.
|
|
31
|
+
* @experimental
|
|
32
|
+
*/
|
|
33
|
+
export interface HashRouterOptions {
|
|
34
|
+
/** Inline route definitions. */
|
|
35
|
+
routes: HashRouteConfig[];
|
|
36
|
+
/** CSS selector or element to render into. Defaults to 'hash-slot'. */
|
|
37
|
+
slot?: string | Element;
|
|
38
|
+
/** Enriches every ComponentContext with app-level services. */
|
|
39
|
+
extendContext?: ContextProvider;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Build an internal RoutesManifest from inline hash route definitions.
|
|
44
|
+
* Each route's pattern is used as the moduleLoaders key so RouteCore.loadModule works.
|
|
45
|
+
*/
|
|
46
|
+
function buildManifest(routes: HashRouteConfig[]): RoutesManifest {
|
|
47
|
+
return {
|
|
48
|
+
routes: routes.map((r) => ({
|
|
49
|
+
pattern: r.pattern,
|
|
50
|
+
type: 'page' as const,
|
|
51
|
+
modulePath: r.pattern,
|
|
52
|
+
files: { ts: r.pattern },
|
|
53
|
+
})),
|
|
54
|
+
errorBoundaries: [],
|
|
55
|
+
statusPages: new Map(),
|
|
56
|
+
moduleLoaders: Object.fromEntries(
|
|
57
|
+
routes.map((r) => [r.pattern, r.loader]),
|
|
58
|
+
),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Hash-based mini-app router for leaf mode pages.
|
|
64
|
+
*
|
|
65
|
+
* Listens to `hashchange`, maps `#/path` → pattern match via RouteCore,
|
|
66
|
+
* renders matched PageComponent into a slot element.
|
|
67
|
+
*
|
|
68
|
+
* @experimental
|
|
69
|
+
*/
|
|
70
|
+
export class HashRouter extends BaseRenderer {
|
|
71
|
+
private boundHandler: (() => void) | null = null;
|
|
72
|
+
|
|
73
|
+
constructor(manifest: RoutesManifest, options?: { extendContext?: ContextProvider }) {
|
|
74
|
+
const core = new RouteCore(manifest, {
|
|
75
|
+
extendContext: options?.extendContext,
|
|
76
|
+
});
|
|
77
|
+
super(core);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Initialize: find slot, attach hashchange listener, render initial hash.
|
|
82
|
+
*/
|
|
83
|
+
async initialize(slot: string | Element = 'hash-slot'): Promise<void> {
|
|
84
|
+
this.slot = typeof slot === 'string' ? document.querySelector(slot) : slot;
|
|
85
|
+
|
|
86
|
+
if (!this.slot) {
|
|
87
|
+
console.error(`[HashRouter] Slot not found: ${slot}`);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
this.boundHandler = () => {
|
|
92
|
+
this.handleHashChange();
|
|
93
|
+
};
|
|
94
|
+
globalThis.addEventListener('hashchange', this.boundHandler);
|
|
95
|
+
|
|
96
|
+
// Render initial hash if present
|
|
97
|
+
if (location.hash.length > 1) {
|
|
98
|
+
await this.handleHashChange();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Navigate to a hash path. Triggers hashchange → render.
|
|
104
|
+
*/
|
|
105
|
+
navigate(hash: string): void {
|
|
106
|
+
location.hash = hash.startsWith('#') ? hash : '#' + hash;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Add event listener for router events.
|
|
111
|
+
*/
|
|
112
|
+
addEventListener(
|
|
113
|
+
listener: Parameters<RouteCore['addEventListener']>[0],
|
|
114
|
+
): () => void {
|
|
115
|
+
return this.core.addEventListener(listener);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Remove event listeners and release references.
|
|
120
|
+
*/
|
|
121
|
+
dispose(): void {
|
|
122
|
+
if (this.boundHandler) {
|
|
123
|
+
globalThis.removeEventListener('hashchange', this.boundHandler);
|
|
124
|
+
this.boundHandler = null;
|
|
125
|
+
}
|
|
126
|
+
this.slot = null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Handle a hashchange event: parse hash, match, render.
|
|
131
|
+
*/
|
|
132
|
+
private async handleHashChange(): Promise<void> {
|
|
133
|
+
const path = location.hash.slice(1) || '/';
|
|
134
|
+
const matchUrl = new URL(path, location.origin);
|
|
135
|
+
|
|
136
|
+
logger.nav('hash', path, path);
|
|
137
|
+
|
|
138
|
+
const controller = new AbortController();
|
|
139
|
+
const { signal } = controller;
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
const matched = this.core.match(matchUrl);
|
|
143
|
+
|
|
144
|
+
if (!matched) {
|
|
145
|
+
logger.nav('hash-not-found', path, path);
|
|
146
|
+
if (this.slot) {
|
|
147
|
+
this.slot.setHTMLUnsafe(`
|
|
148
|
+
<h1>Not Found</h1>
|
|
149
|
+
<p>Path: ${escapeHtml(path)}</p>
|
|
150
|
+
`);
|
|
151
|
+
}
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
logger.nav('hash-matched', path, matched.route.pattern, { params: matched.params });
|
|
156
|
+
|
|
157
|
+
this.core.currentRoute = matched;
|
|
158
|
+
const routeInfo = this.core.toRouteInfo(matched, path);
|
|
159
|
+
|
|
160
|
+
await this.renderPage(routeInfo, matched, signal);
|
|
161
|
+
|
|
162
|
+
if (signal.aborted) return;
|
|
163
|
+
|
|
164
|
+
this.core.emit({
|
|
165
|
+
type: 'navigate',
|
|
166
|
+
pathname: path,
|
|
167
|
+
params: matched.params,
|
|
168
|
+
});
|
|
169
|
+
} catch (error) {
|
|
170
|
+
if (signal.aborted) return;
|
|
171
|
+
console.error('[HashRouter] Render error:', error);
|
|
172
|
+
|
|
173
|
+
this.core.emit({
|
|
174
|
+
type: 'error',
|
|
175
|
+
pathname: path,
|
|
176
|
+
params: {},
|
|
177
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
if (this.slot) {
|
|
181
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
182
|
+
this.slot.setHTMLUnsafe(`
|
|
183
|
+
<h1>Error</h1>
|
|
184
|
+
<p>${escapeHtml(message)}</p>
|
|
185
|
+
`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Create and initialize a hash router for a leaf-mode mini-app.
|
|
193
|
+
*
|
|
194
|
+
* The router instance is stored on `globalThis.__emroute_hash_router` for
|
|
195
|
+
* programmatic access. Calling twice returns the existing router with a warning.
|
|
196
|
+
*
|
|
197
|
+
* @experimental
|
|
198
|
+
*/
|
|
199
|
+
export async function createHashRouter(
|
|
200
|
+
options: HashRouterOptions,
|
|
201
|
+
): Promise<HashRouter> {
|
|
202
|
+
const g = globalThis as Record<string, unknown>;
|
|
203
|
+
if (g.__emroute_hash_router) {
|
|
204
|
+
console.warn('eMroute: Hash router already initialized.');
|
|
205
|
+
return g.__emroute_hash_router as HashRouter;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const manifest = buildManifest(options.routes);
|
|
209
|
+
const router = new HashRouter(manifest, {
|
|
210
|
+
extendContext: options.extendContext,
|
|
211
|
+
});
|
|
212
|
+
await router.initialize(options.slot ?? 'hash-slot');
|
|
213
|
+
g.__emroute_hash_router = router;
|
|
214
|
+
return router;
|
|
215
|
+
}
|