@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,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* esbuild Virtual Manifest Plugin
|
|
3
|
+
*
|
|
4
|
+
* Intercepts `emroute:routes` and `emroute:widgets` import specifiers.
|
|
5
|
+
* Reads JSON manifests from the runtime and generates TypeScript modules
|
|
6
|
+
* with `moduleLoaders` (dynamic `import()` calls) in-memory — no .g.ts
|
|
7
|
+
* files on disk.
|
|
8
|
+
*
|
|
9
|
+
* This is the single source of truth: JSON manifest → esbuild bundle.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { Runtime } from '../runtime/abstract.runtime.ts';
|
|
13
|
+
import { ROUTES_MANIFEST_PATH, WIDGETS_MANIFEST_PATH } from '../runtime/abstract.runtime.ts';
|
|
14
|
+
|
|
15
|
+
/** Escape a string for use inside a single-quoted JS/TS string literal. */
|
|
16
|
+
function esc(value: string): string {
|
|
17
|
+
return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface ManifestPluginOptions {
|
|
21
|
+
runtime: Runtime;
|
|
22
|
+
/** HTML base path prefix for route patterns (e.g. '/html'). */
|
|
23
|
+
basePath?: string;
|
|
24
|
+
/**
|
|
25
|
+
* Directory prefix to strip from module paths so that import() calls
|
|
26
|
+
* are relative to the entry point (e.g. 'routes/' strips '/routes/').
|
|
27
|
+
*/
|
|
28
|
+
stripPrefix?: string;
|
|
29
|
+
/** Absolute directory for resolving relative import() paths in generated code. */
|
|
30
|
+
resolveDir: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
34
|
+
type EsbuildPlugin = any;
|
|
35
|
+
|
|
36
|
+
export function createManifestPlugin(options: ManifestPluginOptions): EsbuildPlugin {
|
|
37
|
+
const { runtime, basePath = '', stripPrefix = '', resolveDir } = options;
|
|
38
|
+
|
|
39
|
+
const prefixPattern = (pattern: string): string =>
|
|
40
|
+
basePath ? (pattern === '/' ? basePath : basePath + pattern) : pattern;
|
|
41
|
+
|
|
42
|
+
const strip = (p: string): string =>
|
|
43
|
+
stripPrefix && p.startsWith(stripPrefix) ? p.slice(stripPrefix.length) : p;
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
name: 'emroute-manifest',
|
|
47
|
+
|
|
48
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
49
|
+
setup(build: any) {
|
|
50
|
+
// ── Resolve virtual specifiers ──────────────────────────────────
|
|
51
|
+
build.onResolve(
|
|
52
|
+
{ filter: /^emroute:/ },
|
|
53
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
54
|
+
(args: any) => ({ path: args.path, namespace: 'emroute' }),
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
// ── Load virtual modules ────────────────────────────────────────
|
|
58
|
+
build.onLoad(
|
|
59
|
+
{ filter: /.*/, namespace: 'emroute' },
|
|
60
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
61
|
+
async (args: any) => {
|
|
62
|
+
if (args.path === 'emroute:routes') {
|
|
63
|
+
return { contents: await generateRoutesModule(), loader: 'ts' as const, resolveDir };
|
|
64
|
+
}
|
|
65
|
+
if (args.path === 'emroute:widgets') {
|
|
66
|
+
return { contents: await generateWidgetsModule(), loader: 'ts' as const, resolveDir };
|
|
67
|
+
}
|
|
68
|
+
return undefined;
|
|
69
|
+
},
|
|
70
|
+
);
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// ── Routes module generator ───────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
async function generateRoutesModule(): Promise<string> {
|
|
77
|
+
const response = await runtime.query(ROUTES_MANIFEST_PATH);
|
|
78
|
+
if (response.status === 404) {
|
|
79
|
+
return `export const routesManifest = { routes: [], errorBoundaries: [], statusPages: new Map(), moduleLoaders: {} };`;
|
|
80
|
+
}
|
|
81
|
+
const raw = await response.json();
|
|
82
|
+
|
|
83
|
+
// Routes array
|
|
84
|
+
const routesArray = (raw.routes ?? [])
|
|
85
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
86
|
+
.map((r: any) => {
|
|
87
|
+
const filesStr = r.files
|
|
88
|
+
? `\n files: { ${
|
|
89
|
+
Object.entries(r.files)
|
|
90
|
+
.filter(([_, v]) => v)
|
|
91
|
+
.map(([k, v]) => `${k}: '${esc(strip(v as string))}'`)
|
|
92
|
+
.join(', ')
|
|
93
|
+
} },`
|
|
94
|
+
: '';
|
|
95
|
+
|
|
96
|
+
return ` {
|
|
97
|
+
pattern: '${esc(prefixPattern(r.pattern))}',
|
|
98
|
+
type: '${esc(r.type)}',
|
|
99
|
+
modulePath: '${esc(strip(r.modulePath))}',${filesStr}${
|
|
100
|
+
r.parent ? `\n parent: '${esc(prefixPattern(r.parent))}',` : ''
|
|
101
|
+
}${r.statusCode ? `\n statusCode: ${r.statusCode},` : ''}
|
|
102
|
+
}`;
|
|
103
|
+
})
|
|
104
|
+
.join(',\n');
|
|
105
|
+
|
|
106
|
+
// Error boundaries
|
|
107
|
+
const errorBoundariesArray = (raw.errorBoundaries ?? [])
|
|
108
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
109
|
+
.map((e: any) => ` {
|
|
110
|
+
pattern: '${esc(prefixPattern(e.pattern))}',
|
|
111
|
+
modulePath: '${esc(strip(e.modulePath))}',
|
|
112
|
+
}`)
|
|
113
|
+
.join(',\n');
|
|
114
|
+
|
|
115
|
+
// Status pages
|
|
116
|
+
const statusPagesEntries = (raw.statusPages ?? [])
|
|
117
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
118
|
+
.map(([status, route]: [number, any]) => {
|
|
119
|
+
const filesStr = route.files
|
|
120
|
+
? `, files: { ${
|
|
121
|
+
Object.entries(route.files)
|
|
122
|
+
.map(([k, v]) => `${k}: '${esc(strip(v as string))}'`)
|
|
123
|
+
.join(', ')
|
|
124
|
+
} }`
|
|
125
|
+
: '';
|
|
126
|
+
return ` [${status}, { pattern: '${esc(prefixPattern(route.pattern))}', type: '${
|
|
127
|
+
esc(route.type)
|
|
128
|
+
}', modulePath: '${
|
|
129
|
+
esc(strip(route.modulePath))
|
|
130
|
+
}', statusCode: ${status}${filesStr} }]`;
|
|
131
|
+
})
|
|
132
|
+
.join(',\n');
|
|
133
|
+
|
|
134
|
+
// Error handler
|
|
135
|
+
const errorHandlerCode = raw.errorHandler
|
|
136
|
+
? `{
|
|
137
|
+
pattern: '${esc(prefixPattern(raw.errorHandler.pattern))}',
|
|
138
|
+
type: '${esc(raw.errorHandler.type)}',
|
|
139
|
+
modulePath: '${esc(strip(raw.errorHandler.modulePath))}',
|
|
140
|
+
}`
|
|
141
|
+
: 'undefined';
|
|
142
|
+
|
|
143
|
+
// Module loaders — collect all .ts module paths
|
|
144
|
+
const tsModulePaths = new Set<string>();
|
|
145
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
146
|
+
for (const route of (raw.routes ?? []) as any[]) {
|
|
147
|
+
if (route.files?.ts) tsModulePaths.add(route.files.ts);
|
|
148
|
+
if (route.modulePath.endsWith('.ts')) tsModulePaths.add(route.modulePath);
|
|
149
|
+
}
|
|
150
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
151
|
+
for (const boundary of (raw.errorBoundaries ?? []) as any[]) {
|
|
152
|
+
tsModulePaths.add(boundary.modulePath);
|
|
153
|
+
}
|
|
154
|
+
if (raw.errorHandler) {
|
|
155
|
+
tsModulePaths.add(raw.errorHandler.modulePath);
|
|
156
|
+
}
|
|
157
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
158
|
+
for (const [_, statusRoute] of (raw.statusPages ?? []) as [number, any][]) {
|
|
159
|
+
if (statusRoute.modulePath.endsWith('.ts')) {
|
|
160
|
+
tsModulePaths.add(statusRoute.modulePath);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const moduleLoadersCode = [...tsModulePaths]
|
|
165
|
+
.map((p) => {
|
|
166
|
+
const key = strip(p);
|
|
167
|
+
const rel = key.replace(/^\.?\//, '');
|
|
168
|
+
return ` '${esc(key)}': () => import('./${esc(rel)}'),`;
|
|
169
|
+
})
|
|
170
|
+
.join('\n');
|
|
171
|
+
|
|
172
|
+
return `import type { RoutesManifest } from '@emkodev/emroute';
|
|
173
|
+
|
|
174
|
+
export const routesManifest: RoutesManifest = {
|
|
175
|
+
routes: [
|
|
176
|
+
${routesArray}
|
|
177
|
+
],
|
|
178
|
+
|
|
179
|
+
errorBoundaries: [
|
|
180
|
+
${errorBoundariesArray}
|
|
181
|
+
],
|
|
182
|
+
|
|
183
|
+
statusPages: new Map([
|
|
184
|
+
${statusPagesEntries}
|
|
185
|
+
]),
|
|
186
|
+
|
|
187
|
+
errorHandler: ${errorHandlerCode},
|
|
188
|
+
|
|
189
|
+
moduleLoaders: {
|
|
190
|
+
${moduleLoadersCode}
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ── Widgets module generator ──────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
async function generateWidgetsModule(): Promise<string> {
|
|
199
|
+
const response = await runtime.query(WIDGETS_MANIFEST_PATH);
|
|
200
|
+
if (response.status === 404) {
|
|
201
|
+
return `export const widgetsManifest = { widgets: [], moduleLoaders: {} };`;
|
|
202
|
+
}
|
|
203
|
+
const entries = await response.json();
|
|
204
|
+
|
|
205
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
206
|
+
const widgetEntries = (entries as any[]).map((e) => {
|
|
207
|
+
const filesStr = e.files
|
|
208
|
+
? `\n files: { ${
|
|
209
|
+
Object.entries(e.files)
|
|
210
|
+
.filter(([_, v]) => v)
|
|
211
|
+
.map(([k, v]) => `${k}: '${esc(strip(v as string))}'`)
|
|
212
|
+
.join(', ')
|
|
213
|
+
} },`
|
|
214
|
+
: '';
|
|
215
|
+
|
|
216
|
+
return ` {
|
|
217
|
+
name: '${esc(e.name)}',
|
|
218
|
+
modulePath: '${esc(strip(e.modulePath))}',
|
|
219
|
+
tagName: '${esc(e.tagName)}',${filesStr}
|
|
220
|
+
}`;
|
|
221
|
+
}).join(',\n');
|
|
222
|
+
|
|
223
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
224
|
+
const loaderEntries = (entries as any[]).map((e) => {
|
|
225
|
+
const key = strip(e.modulePath);
|
|
226
|
+
const rel = key.replace(/^\.?\//, '');
|
|
227
|
+
return ` '${esc(key)}': () => import('./${esc(rel)}'),`;
|
|
228
|
+
}).join('\n');
|
|
229
|
+
|
|
230
|
+
return `import type { WidgetsManifest } from '@emkodev/emroute';
|
|
231
|
+
|
|
232
|
+
export const widgetsManifest: WidgetsManifest = {
|
|
233
|
+
widgets: [
|
|
234
|
+
${widgetEntries}
|
|
235
|
+
],
|
|
236
|
+
|
|
237
|
+
moduleLoaders: {
|
|
238
|
+
${loaderEntries}
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
`;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standalone Scanning Utilities
|
|
3
|
+
*
|
|
4
|
+
* Runtime-agnostic route and widget scanning. Works with any Runtime
|
|
5
|
+
* that implements query() — used by tests with mock runtimes and by
|
|
6
|
+
* the CLI generate command.
|
|
7
|
+
*
|
|
8
|
+
* Note: BunFsRuntime has these as instance methods with lazy caching.
|
|
9
|
+
* These standalone functions are the same logic without caching.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { Runtime } from '../runtime/abstract.runtime.ts';
|
|
13
|
+
import {
|
|
14
|
+
filePathToPattern,
|
|
15
|
+
getPageFileType,
|
|
16
|
+
getRouteType,
|
|
17
|
+
sortRoutesBySpecificity,
|
|
18
|
+
} from '../src/route/route.matcher.ts';
|
|
19
|
+
import type {
|
|
20
|
+
ErrorBoundary,
|
|
21
|
+
RouteConfig,
|
|
22
|
+
RouteFiles,
|
|
23
|
+
RoutesManifest,
|
|
24
|
+
} from '../src/type/route.type.ts';
|
|
25
|
+
import type { WidgetManifestEntry } from '../src/type/widget.type.ts';
|
|
26
|
+
|
|
27
|
+
export interface GeneratorResult extends RoutesManifest {
|
|
28
|
+
warnings: string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Walk directory recursively and collect files via Runtime. */
|
|
32
|
+
async function* walkDirectory(runtime: Runtime, dir: string): AsyncGenerator<string> {
|
|
33
|
+
const trailingDir = dir.endsWith('/') ? dir : dir + '/';
|
|
34
|
+
const response = await runtime.query(trailingDir);
|
|
35
|
+
const entries: string[] = await response.json();
|
|
36
|
+
|
|
37
|
+
for (const entry of entries) {
|
|
38
|
+
const path = `${trailingDir}${entry}`;
|
|
39
|
+
if (entry.endsWith('/')) {
|
|
40
|
+
yield* walkDirectory(runtime, path);
|
|
41
|
+
} else {
|
|
42
|
+
yield path;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Generate routes manifest by scanning a directory via Runtime. */
|
|
48
|
+
export async function generateRoutesManifest(
|
|
49
|
+
routesDir: string,
|
|
50
|
+
runtime: Runtime,
|
|
51
|
+
): Promise<GeneratorResult> {
|
|
52
|
+
const pageFiles: Array<{
|
|
53
|
+
path: string;
|
|
54
|
+
pattern: string;
|
|
55
|
+
fileType: 'ts' | 'html' | 'md' | 'css';
|
|
56
|
+
}> = [];
|
|
57
|
+
const redirects: RouteConfig[] = [];
|
|
58
|
+
const errorBoundaries: ErrorBoundary[] = [];
|
|
59
|
+
const statusPages = new Map<number, RouteConfig>();
|
|
60
|
+
let errorHandler: RouteConfig | undefined;
|
|
61
|
+
|
|
62
|
+
const allFiles: string[] = [];
|
|
63
|
+
for await (const file of walkDirectory(runtime, routesDir)) {
|
|
64
|
+
allFiles.push(file);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
for (const filePath of allFiles) {
|
|
68
|
+
const relativePath = filePath.replace(`${routesDir}/`, '');
|
|
69
|
+
const filename = relativePath.split('/').pop() ?? '';
|
|
70
|
+
|
|
71
|
+
if (filename === 'index.error.ts' && relativePath === 'index.error.ts') {
|
|
72
|
+
errorHandler = {
|
|
73
|
+
pattern: '/',
|
|
74
|
+
type: 'error',
|
|
75
|
+
modulePath: filePath,
|
|
76
|
+
};
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const cssFileType = getPageFileType(filename);
|
|
81
|
+
if (cssFileType === 'css') {
|
|
82
|
+
const pattern = filePathToPattern(relativePath);
|
|
83
|
+
pageFiles.push({ path: filePath, pattern, fileType: 'css' });
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const routeType = getRouteType(filename);
|
|
88
|
+
if (!routeType) continue;
|
|
89
|
+
|
|
90
|
+
const statusMatch = filename.match(/^(\d{3})\.page\.(ts|html|md)$/);
|
|
91
|
+
if (statusMatch) {
|
|
92
|
+
const statusCode = parseInt(statusMatch[1], 10);
|
|
93
|
+
const fileType = getPageFileType(filename);
|
|
94
|
+
if (fileType) {
|
|
95
|
+
const existing = statusPages.get(statusCode);
|
|
96
|
+
if (existing) {
|
|
97
|
+
existing.files ??= {};
|
|
98
|
+
existing.files[fileType] = filePath;
|
|
99
|
+
existing.modulePath = existing.files.ts ?? existing.files.html ?? existing.files.md ?? '';
|
|
100
|
+
} else {
|
|
101
|
+
const files: RouteFiles = { [fileType]: filePath };
|
|
102
|
+
statusPages.set(statusCode, {
|
|
103
|
+
pattern: `/${statusCode}`,
|
|
104
|
+
type: 'page',
|
|
105
|
+
modulePath: filePath,
|
|
106
|
+
statusCode,
|
|
107
|
+
files,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const pattern = filePathToPattern(relativePath);
|
|
115
|
+
|
|
116
|
+
if (routeType === 'error') {
|
|
117
|
+
const boundaryPattern = pattern.replace(/\/[^/]+$/, '') || '/';
|
|
118
|
+
errorBoundaries.push({ pattern: boundaryPattern, modulePath: filePath });
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (routeType === 'redirect') {
|
|
123
|
+
redirects.push({ pattern, type: 'redirect', modulePath: filePath });
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const fileType = getPageFileType(filename);
|
|
128
|
+
if (fileType) {
|
|
129
|
+
pageFiles.push({ path: filePath, pattern, fileType });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Group files by pattern
|
|
134
|
+
const groups = new Map<string, { pattern: string; files: RouteFiles; parent?: string }>();
|
|
135
|
+
for (const { path, pattern, fileType } of pageFiles) {
|
|
136
|
+
let group = groups.get(pattern);
|
|
137
|
+
if (!group) {
|
|
138
|
+
group = { pattern, files: {} };
|
|
139
|
+
const segments = pattern.split('/').filter(Boolean);
|
|
140
|
+
if (segments.length > 1) {
|
|
141
|
+
group.parent = '/' + segments.slice(0, -1).join('/');
|
|
142
|
+
}
|
|
143
|
+
groups.set(pattern, group);
|
|
144
|
+
}
|
|
145
|
+
const existing = group.files[fileType];
|
|
146
|
+
if (existing?.includes('/index.page.') && !path.includes('/index.page.')) {
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
group.files[fileType] = path;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Detect collisions
|
|
153
|
+
const warnings: string[] = [];
|
|
154
|
+
for (const [pattern, group] of groups) {
|
|
155
|
+
const filePaths = Object.values(group.files).filter(Boolean);
|
|
156
|
+
const hasIndex = filePaths.some((p) => p?.includes('/index.page.'));
|
|
157
|
+
const hasFlat = filePaths.some((p) => p && !p.includes('/index.page.'));
|
|
158
|
+
if (hasIndex && hasFlat) {
|
|
159
|
+
warnings.push(
|
|
160
|
+
`Warning: Mixed file structure for ${pattern}:\n` +
|
|
161
|
+
filePaths.map((p) => ` ${p}`).join('\n') +
|
|
162
|
+
`\n Both folder/index and flat files detected`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Convert groups to RouteConfig array
|
|
168
|
+
const routes: RouteConfig[] = [];
|
|
169
|
+
for (const [_, group] of groups) {
|
|
170
|
+
const modulePath = group.files.ts ?? group.files.html ?? group.files.md ?? '';
|
|
171
|
+
if (!modulePath) continue;
|
|
172
|
+
const route: RouteConfig = {
|
|
173
|
+
pattern: group.pattern,
|
|
174
|
+
type: 'page',
|
|
175
|
+
modulePath,
|
|
176
|
+
files: group.files,
|
|
177
|
+
};
|
|
178
|
+
if (group.parent) route.parent = group.parent;
|
|
179
|
+
routes.push(route);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
routes.push(...redirects);
|
|
183
|
+
const sortedRoutes = sortRoutesBySpecificity(routes);
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
routes: sortedRoutes,
|
|
187
|
+
errorBoundaries,
|
|
188
|
+
statusPages,
|
|
189
|
+
errorHandler,
|
|
190
|
+
warnings,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Discover widget modules and companion files by scanning a directory.
|
|
196
|
+
*/
|
|
197
|
+
export async function discoverWidgets(
|
|
198
|
+
widgetsDir: string,
|
|
199
|
+
runtime: Runtime,
|
|
200
|
+
pathPrefix?: string,
|
|
201
|
+
): Promise<WidgetManifestEntry[]> {
|
|
202
|
+
const COMPANION_EXTENSIONS = ['html', 'md', 'css'] as const;
|
|
203
|
+
const WIDGET_FILE_SUFFIX = '.widget.ts';
|
|
204
|
+
const entries: WidgetManifestEntry[] = [];
|
|
205
|
+
|
|
206
|
+
const trailingDir = widgetsDir.endsWith('/') ? widgetsDir : widgetsDir + '/';
|
|
207
|
+
const response = await runtime.query(trailingDir);
|
|
208
|
+
const listing: string[] = await response.json();
|
|
209
|
+
|
|
210
|
+
for (const item of listing) {
|
|
211
|
+
if (!item.endsWith('/')) continue;
|
|
212
|
+
|
|
213
|
+
const name = item.slice(0, -1);
|
|
214
|
+
const moduleFile = `${name}${WIDGET_FILE_SUFFIX}`;
|
|
215
|
+
const modulePath = `${trailingDir}${name}/${moduleFile}`;
|
|
216
|
+
|
|
217
|
+
if ((await runtime.query(modulePath)).status === 404) continue;
|
|
218
|
+
|
|
219
|
+
const prefix = pathPrefix ? `${pathPrefix}/` : '';
|
|
220
|
+
const entry: WidgetManifestEntry = {
|
|
221
|
+
name,
|
|
222
|
+
modulePath: `${prefix}${name}/${moduleFile}`,
|
|
223
|
+
tagName: `widget-${name}`,
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const files: { html?: string; md?: string; css?: string } = {};
|
|
227
|
+
let hasFiles = false;
|
|
228
|
+
for (const ext of COMPANION_EXTENSIONS) {
|
|
229
|
+
const companionFile = `${name}.widget.${ext}`;
|
|
230
|
+
const companionPath = `${trailingDir}${name}/${companionFile}`;
|
|
231
|
+
if ((await runtime.query(companionPath)).status !== 404) {
|
|
232
|
+
files[ext] = `${prefix}${name}/${companionFile}`;
|
|
233
|
+
hasFiles = true;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (hasFiles) entry.files = files;
|
|
238
|
+
entries.push(entry);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
242
|
+
return entries;
|
|
243
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server API Types
|
|
3
|
+
*
|
|
4
|
+
* Interfaces for the emroute server.
|
|
5
|
+
* Consumers use `createEmrouteServer()` to get a server that handles
|
|
6
|
+
* SSR rendering, static file serving, and route matching.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { RoutesManifest } from '../src/type/route.type.ts';
|
|
10
|
+
import type { MarkdownRenderer } from '../src/type/markdown.type.ts';
|
|
11
|
+
import type { SpaMode, WidgetManifestEntry } from '../src/type/widget.type.ts';
|
|
12
|
+
import type { ContextProvider } from '../src/component/abstract.component.ts';
|
|
13
|
+
import type { BasePath } from '../src/route/route.core.ts';
|
|
14
|
+
import type { WidgetRegistry } from '../src/widget/widget.registry.ts';
|
|
15
|
+
import type { SsrHtmlRouter } from '../src/renderer/ssr/html.renderer.ts';
|
|
16
|
+
import type { SsrMdRouter } from '../src/renderer/ssr/md.renderer.ts';
|
|
17
|
+
|
|
18
|
+
// ── SSR Render Result ──────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
/** Result of rendering a URL through an SSR renderer. */
|
|
21
|
+
export interface SsrRenderResult {
|
|
22
|
+
/** Rendered content (HTML or Markdown) */
|
|
23
|
+
content: string;
|
|
24
|
+
/** HTTP status code */
|
|
25
|
+
status: number;
|
|
26
|
+
/** Page title (from the leaf route's getTitle) */
|
|
27
|
+
title?: string;
|
|
28
|
+
/** Redirect target URL (for 301/302 responses) */
|
|
29
|
+
redirect?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ── Server ─────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Config for `createEmrouteServer()`.
|
|
36
|
+
*
|
|
37
|
+
* The server reads manifests from the Runtime and handles SSR rendering,
|
|
38
|
+
* static file serving, and route matching.
|
|
39
|
+
*/
|
|
40
|
+
export interface EmrouteServerConfig {
|
|
41
|
+
/** Pre-built manifest (alternative to reading from runtime) */
|
|
42
|
+
routesManifest?: RoutesManifest;
|
|
43
|
+
|
|
44
|
+
/** Pre-built widget registry (alternative to reading from runtime) */
|
|
45
|
+
widgets?: WidgetRegistry;
|
|
46
|
+
|
|
47
|
+
/** SPA mode — controls which routers are constructed and what gets served */
|
|
48
|
+
spa?: SpaMode;
|
|
49
|
+
|
|
50
|
+
/** Base paths for SSR endpoints (default: { html: '/html', md: '/md' }) */
|
|
51
|
+
basePath?: BasePath;
|
|
52
|
+
|
|
53
|
+
/** Page title (fallback when no route provides one) */
|
|
54
|
+
title?: string;
|
|
55
|
+
|
|
56
|
+
/** Markdown renderer for server-side <mark-down> expansion */
|
|
57
|
+
markdownRenderer?: MarkdownRenderer;
|
|
58
|
+
|
|
59
|
+
/** Enrich every ComponentContext with app-level services. */
|
|
60
|
+
extendContext?: ContextProvider;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* An emroute server instance.
|
|
65
|
+
*
|
|
66
|
+
* Handles SSR rendering, static file serving, and route matching.
|
|
67
|
+
* Use `handleRequest(req)` to compose with your own request handling.
|
|
68
|
+
*/
|
|
69
|
+
export interface EmrouteServer {
|
|
70
|
+
/**
|
|
71
|
+
* Handle an HTTP request for SSR routes and bare paths.
|
|
72
|
+
* Returns `null` for unmatched file requests — consumer handles 404.
|
|
73
|
+
*/
|
|
74
|
+
handleRequest(req: Request): Promise<Response | null>;
|
|
75
|
+
|
|
76
|
+
/** The SSR HTML router (null in 'only' mode — no server rendering). */
|
|
77
|
+
readonly htmlRouter: SsrHtmlRouter | null;
|
|
78
|
+
|
|
79
|
+
/** The SSR Markdown router (null in 'only' mode). */
|
|
80
|
+
readonly mdRouter: SsrMdRouter | null;
|
|
81
|
+
|
|
82
|
+
/** The resolved routes manifest. */
|
|
83
|
+
readonly manifest: RoutesManifest;
|
|
84
|
+
|
|
85
|
+
/** Discovered widget entries. */
|
|
86
|
+
readonly widgetEntries: WidgetManifestEntry[];
|
|
87
|
+
|
|
88
|
+
/** The resolved HTML shell. */
|
|
89
|
+
readonly shell: string;
|
|
90
|
+
}
|