@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,245 @@
1
+ import { stat, readdir, mkdir } from 'node:fs/promises';
2
+ import { createRequire } from 'node:module';
3
+ import { resolve } from 'node:path';
4
+ import {
5
+ CONTENT_TYPES,
6
+ DEFAULT_ROUTES_DIR,
7
+ DEFAULT_WIDGETS_DIR,
8
+ EMROUTE_EXTERNALS,
9
+ type FetchParams,
10
+ type FetchReturn,
11
+ ROUTES_MANIFEST_PATH,
12
+ Runtime,
13
+ type RuntimeConfig,
14
+ WIDGETS_MANIFEST_PATH,
15
+ } from '../../abstract.runtime.ts';
16
+ import { createManifestPlugin } from '../../../server/esbuild-manifest.plugin.ts';
17
+ import { createRuntimeLoaderPlugin } from '../esbuild-runtime-loader.plugin.ts';
18
+ import { generateMainTs } from '../../../server/codegen.util.ts';
19
+
20
+ export class BunFsRuntime extends Runtime {
21
+ private readonly root: string;
22
+
23
+ constructor(root: string, config: RuntimeConfig = {}) {
24
+ if (config.entryPoint && !config.bundlePaths) {
25
+ config.bundlePaths = { emroute: '/emroute.js', app: '/app.js' };
26
+ }
27
+ super(config);
28
+ const abs = resolve(root);
29
+ this.root = abs.endsWith('/') ? abs.slice(0, -1) : abs;
30
+ }
31
+
32
+ handle(
33
+ resource: FetchParams[0],
34
+ init?: FetchParams[1],
35
+ ): FetchReturn {
36
+ const [pathname, method, body] = this.parse(resource, init);
37
+ const path = `${this.root}${pathname}`;
38
+
39
+ switch (method) {
40
+ case 'PUT':
41
+ return this.write(path, body);
42
+ default:
43
+ return this.read(path);
44
+ }
45
+ }
46
+
47
+ query(
48
+ resource: FetchParams[0],
49
+ options: FetchParams[1] & { as: 'text' },
50
+ ): Promise<string>;
51
+ query(
52
+ resource: FetchParams[0],
53
+ options?: FetchParams[1],
54
+ ): FetchReturn;
55
+ query(
56
+ resource: FetchParams[0],
57
+ options?: FetchParams[1] & { as?: 'text' },
58
+ ): Promise<Response | string> {
59
+ if (options?.as === 'text') {
60
+ const pathname = this.parsePath(resource);
61
+ return Bun.file(`${this.root}${pathname}`).text();
62
+ }
63
+ return this.handle(resource, options);
64
+ }
65
+
66
+ private parsePath(resource: FetchParams[0]): string {
67
+ if (typeof resource === 'string') return decodeURIComponent(resource);
68
+ if (resource instanceof URL) return decodeURIComponent(resource.pathname);
69
+ return decodeURIComponent(new URL(resource.url).pathname);
70
+ }
71
+
72
+ private parse(
73
+ resource: FetchParams[0],
74
+ init?: RequestInit,
75
+ ): [string, string, BodyInit | null] {
76
+ const pathname = this.parsePath(resource);
77
+ if (typeof resource === 'string' || resource instanceof URL) {
78
+ return [pathname, init?.method ?? 'GET', init?.body ?? null];
79
+ }
80
+ return [
81
+ pathname,
82
+ init?.method ?? resource.method,
83
+ init?.body ?? resource.body,
84
+ ];
85
+ }
86
+
87
+ private async read(path: string): Promise<Response> {
88
+ try {
89
+ const info = await stat(path);
90
+
91
+ if (info.isDirectory()) {
92
+ return this.list(path);
93
+ }
94
+
95
+ const content = new Uint8Array(await Bun.file(path).arrayBuffer());
96
+ const ext = path.slice(path.lastIndexOf('.')).toLowerCase();
97
+ const headers: HeadersInit = {
98
+ 'Content-Type': CONTENT_TYPES.get(ext) ?? 'application/octet-stream',
99
+ 'Content-Length': content.byteLength.toString(),
100
+ };
101
+
102
+ if (info.mtime) {
103
+ headers['Last-Modified'] = info.mtime.toUTCString();
104
+ }
105
+
106
+ return new Response(content, { status: 200, headers });
107
+ } catch (error) {
108
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
109
+ const pathname = path.slice(this.root.length);
110
+ if (pathname === ROUTES_MANIFEST_PATH) return this.resolveRoutesManifest();
111
+ if (pathname === WIDGETS_MANIFEST_PATH) return this.resolveWidgetsManifest();
112
+ return new Response('Not Found', { status: 404 });
113
+ }
114
+ return new Response(`Internal Error: ${error}`, { status: 500 });
115
+ }
116
+ }
117
+
118
+ private async list(path: string): Promise<Response> {
119
+ const entries: string[] = [];
120
+ const dirents = await readdir(path, { withFileTypes: true });
121
+ for (const entry of dirents) {
122
+ entries.push(entry.name + (entry.isDirectory() ? '/' : ''));
123
+ }
124
+ return Response.json(entries);
125
+ }
126
+
127
+ private async write(path: string, body: BodyInit | null): Promise<Response> {
128
+ try {
129
+ const content = body
130
+ ? new Uint8Array(await new Response(body).arrayBuffer())
131
+ : new Uint8Array();
132
+ const dir = path.slice(0, path.lastIndexOf('/'));
133
+ if (dir) await mkdir(dir, { recursive: true });
134
+ await Bun.write(path, content);
135
+ return new Response(null, { status: 204 });
136
+ } catch (error) {
137
+ return new Response(`Write failed: ${error}`, { status: 500 });
138
+ }
139
+ }
140
+
141
+ override loadModule(path: string): Promise<unknown> {
142
+ return import(this.root + path);
143
+ }
144
+
145
+ // ── Bundling ─────────────────────────────────────────────────────────
146
+
147
+ override async bundle(): Promise<void> {
148
+ if (this.config.spa === 'none') return;
149
+ const paths = this.config.bundlePaths;
150
+ if (!paths) return;
151
+
152
+ const esbuild = await BunFsRuntime.esbuild();
153
+ const builds: Promise<{ outputFiles: { path: string; contents: Uint8Array }[] }>[] = [];
154
+ const shared = { bundle: true, write: false, format: 'esm' as const, platform: 'browser' as const };
155
+ const runtimeLoader = createRuntimeLoaderPlugin({ runtime: this, root: this.root });
156
+
157
+ // Emroute SPA bundle — resolve from consumer's node_modules (no runtime loader needed)
158
+ const consumerRequire = createRequire(this.root + '/');
159
+ const spaEntry = consumerRequire.resolve('@emkodev/emroute/spa');
160
+ builds.push(esbuild.build({
161
+ ...shared,
162
+ entryPoints: [spaEntry],
163
+ outfile: `${this.root}${paths.emroute}`,
164
+ }));
165
+
166
+ // App bundle — generate main.ts if absent, virtual plugin resolves manifests
167
+ if (this.config.entryPoint) {
168
+ if ((await this.query(this.config.entryPoint)).status === 404) {
169
+ const hasRoutes = (await this.query((this.config.routesDir ?? DEFAULT_ROUTES_DIR) + '/')).status !== 404;
170
+ const hasWidgets = (await this.query((this.config.widgetsDir ?? DEFAULT_WIDGETS_DIR) + '/')).status !== 404;
171
+ const code = generateMainTs('root', hasRoutes, hasWidgets, '@emkodev/emroute');
172
+ await this.command(this.config.entryPoint, { body: code });
173
+ }
174
+ const manifestPlugin = createManifestPlugin({
175
+ runtime: this,
176
+ basePath: '/html',
177
+ resolveDir: this.root,
178
+ });
179
+ builds.push(esbuild.build({
180
+ ...shared,
181
+ entryPoints: [`${this.root}${this.config.entryPoint}`],
182
+ outfile: `${this.root}${paths.app}`,
183
+ external: [...EMROUTE_EXTERNALS],
184
+ plugins: [manifestPlugin, runtimeLoader],
185
+ }));
186
+ }
187
+
188
+ // Widgets bundle
189
+ if (paths.widgets) {
190
+ const widgetsTsPath = paths.widgets.replace('.js', '.ts');
191
+ if ((await this.query(widgetsTsPath)).status !== 404) {
192
+ builds.push(esbuild.build({
193
+ ...shared,
194
+ entryPoints: [`${this.root}${widgetsTsPath}`],
195
+ outfile: `${this.root}${paths.widgets}`,
196
+ external: [...EMROUTE_EXTERNALS],
197
+ plugins: [runtimeLoader],
198
+ }));
199
+ }
200
+ }
201
+
202
+ const results = await Promise.all(builds);
203
+
204
+ // Write all output files through the runtime
205
+ for (const result of results) {
206
+ for (const file of result.outputFiles) {
207
+ const runtimePath = file.path.startsWith(this.root)
208
+ ? file.path.slice(this.root.length)
209
+ : '/' + file.path;
210
+ await this.command(runtimePath, { body: file.contents as unknown as BodyInit });
211
+ }
212
+ }
213
+
214
+ await this.writeShell(paths);
215
+
216
+ await esbuild.stop();
217
+ BunFsRuntime._esbuild = null;
218
+ }
219
+
220
+ // ── Transpile / esbuild ───────────────────────────────────────────────
221
+
222
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
223
+ private static _esbuild: any = null;
224
+
225
+ private static async esbuild() {
226
+ if (!BunFsRuntime._esbuild) {
227
+ // Resolve esbuild from the consumer's node_modules, not the package's
228
+ const consumerRequire = createRequire(process.cwd() + '/');
229
+ BunFsRuntime._esbuild = consumerRequire('esbuild');
230
+ }
231
+ return BunFsRuntime._esbuild;
232
+ }
233
+
234
+ static override transpile(source: string): Promise<string> {
235
+ const transpiler = new Bun.Transpiler({ loader: 'ts' });
236
+ return Promise.resolve(transpiler.transformSync(source));
237
+ }
238
+
239
+ static override async stopBundler(): Promise<void> {
240
+ if (BunFsRuntime._esbuild) {
241
+ await BunFsRuntime._esbuild.stop();
242
+ BunFsRuntime._esbuild = null;
243
+ }
244
+ }
245
+ }
@@ -0,0 +1,279 @@
1
+ import { createRequire } from 'node:module';
2
+ import { Database } from 'bun:sqlite';
3
+ import {
4
+ CONTENT_TYPES,
5
+ DEFAULT_ROUTES_DIR,
6
+ DEFAULT_WIDGETS_DIR,
7
+ EMROUTE_EXTERNALS,
8
+ type FetchParams,
9
+ type FetchReturn,
10
+ ROUTES_MANIFEST_PATH,
11
+ Runtime,
12
+ type RuntimeConfig,
13
+ WIDGETS_MANIFEST_PATH,
14
+ } from '../../abstract.runtime.ts';
15
+ import { createManifestPlugin } from '../../../server/esbuild-manifest.plugin.ts';
16
+ import { createRuntimeLoaderPlugin } from '../esbuild-runtime-loader.plugin.ts';
17
+ import { generateMainTs } from '../../../server/codegen.util.ts';
18
+
19
+ export class BunSqliteRuntime extends Runtime {
20
+ private readonly db: Database;
21
+ private readonly stmtGet: ReturnType<Database['prepare']>;
22
+ private readonly stmtSet: ReturnType<Database['prepare']>;
23
+ private readonly stmtList: ReturnType<Database['prepare']>;
24
+ private readonly stmtHas: ReturnType<Database['prepare']>;
25
+
26
+ constructor(path: string = ':memory:', config: RuntimeConfig = {}) {
27
+ if (config.entryPoint && !config.bundlePaths) {
28
+ config.bundlePaths = { emroute: '/emroute.js', app: '/app.js' };
29
+ }
30
+ super(config);
31
+ this.db = new Database(path);
32
+ this.db.run(`
33
+ CREATE TABLE IF NOT EXISTS files (
34
+ path TEXT PRIMARY KEY,
35
+ data BLOB NOT NULL,
36
+ mtime TEXT NOT NULL
37
+ )
38
+ `);
39
+ this.stmtGet = this.db.prepare('SELECT data, mtime FROM files WHERE path = ?');
40
+ this.stmtSet = this.db.prepare('INSERT OR REPLACE INTO files (path, data, mtime) VALUES (?, ?, ?)');
41
+ this.stmtList = this.db.prepare("SELECT DISTINCT path FROM files WHERE path LIKE ? || '%'");
42
+ this.stmtHas = this.db.prepare("SELECT 1 FROM files WHERE path LIKE ? || '%' LIMIT 1");
43
+ }
44
+
45
+ handle(
46
+ resource: FetchParams[0],
47
+ init?: FetchParams[1],
48
+ ): FetchReturn {
49
+ const [pathname, method, body] = this.parse(resource, init);
50
+
51
+ switch (method) {
52
+ case 'PUT':
53
+ return this.write(pathname, body);
54
+ default:
55
+ return this.read(pathname);
56
+ }
57
+ }
58
+
59
+ query(
60
+ resource: FetchParams[0],
61
+ options: FetchParams[1] & { as: 'text' },
62
+ ): Promise<string>;
63
+ query(
64
+ resource: FetchParams[0],
65
+ options?: FetchParams[1],
66
+ ): FetchReturn;
67
+ query(
68
+ resource: FetchParams[0],
69
+ options?: FetchParams[1] & { as?: 'text' },
70
+ ): Promise<Response | string> {
71
+ if (options?.as === 'text') {
72
+ const pathname = this.parsePath(resource);
73
+ const row = this.stmtGet.get(pathname) as { data: Uint8Array } | null;
74
+ if (!row) {
75
+ return Promise.reject(new Error(`Not found: ${pathname}`));
76
+ }
77
+ return Promise.resolve(new TextDecoder().decode(row.data));
78
+ }
79
+ return this.handle(resource, options);
80
+ }
81
+
82
+ override async loadModule(path: string): Promise<unknown> {
83
+ const source = await this.query(path, { as: 'text' });
84
+ const code = path.endsWith('.ts')
85
+ ? await BunSqliteRuntime.transpile(source)
86
+ : source;
87
+
88
+ const blob = new Blob([code], { type: 'text/javascript' });
89
+ const url = URL.createObjectURL(blob);
90
+ try {
91
+ return await import(url);
92
+ } finally {
93
+ URL.revokeObjectURL(url);
94
+ }
95
+ }
96
+
97
+ close(): void {
98
+ this.db.close();
99
+ }
100
+
101
+ // ── Bundling ─────────────────────────────────────────────────────────
102
+
103
+ override async bundle(): Promise<void> {
104
+ if (this.config.spa === 'none') return;
105
+ const paths = this.config.bundlePaths;
106
+ if (!paths) return;
107
+
108
+ const esbuild = await BunSqliteRuntime.esbuild();
109
+ const builds: Promise<{ outputFiles: { path: string; contents: Uint8Array }[] }>[] = [];
110
+ const shared = { bundle: true, write: false, format: 'esm' as const, platform: 'browser' as const };
111
+ const runtimeLoader = createRuntimeLoaderPlugin({ runtime: this, root: '' });
112
+
113
+ // Emroute SPA bundle — resolve from consumer's node_modules (no runtime loader needed)
114
+ const consumerRequire = createRequire(process.cwd() + '/');
115
+ const spaEntry = consumerRequire.resolve('@emkodev/emroute/spa');
116
+ builds.push(esbuild.build({
117
+ ...shared,
118
+ entryPoints: [spaEntry],
119
+ outfile: paths.emroute,
120
+ }));
121
+
122
+ // App bundle — generate main.ts if absent, virtual plugin resolves manifests
123
+ if (this.config.entryPoint) {
124
+ if ((await this.query(this.config.entryPoint)).status === 404) {
125
+ const hasRoutes = (await this.query((this.config.routesDir ?? DEFAULT_ROUTES_DIR) + '/')).status !== 404;
126
+ const hasWidgets = (await this.query((this.config.widgetsDir ?? DEFAULT_WIDGETS_DIR) + '/')).status !== 404;
127
+ const code = generateMainTs('root', hasRoutes, hasWidgets, '@emkodev/emroute');
128
+ await this.command(this.config.entryPoint, { body: code });
129
+ }
130
+ const manifestPlugin = createManifestPlugin({
131
+ runtime: this,
132
+ basePath: '/html',
133
+ resolveDir: process.cwd(),
134
+ });
135
+ builds.push(esbuild.build({
136
+ ...shared,
137
+ entryPoints: [this.config.entryPoint],
138
+ outfile: paths.app,
139
+ external: [...EMROUTE_EXTERNALS],
140
+ plugins: [manifestPlugin, runtimeLoader],
141
+ }));
142
+ }
143
+
144
+ // Widgets bundle
145
+ if (paths.widgets) {
146
+ const widgetsTsPath = paths.widgets.replace('.js', '.ts');
147
+ if ((await this.query(widgetsTsPath)).status !== 404) {
148
+ builds.push(esbuild.build({
149
+ ...shared,
150
+ entryPoints: [widgetsTsPath],
151
+ outfile: paths.widgets,
152
+ external: [...EMROUTE_EXTERNALS],
153
+ plugins: [runtimeLoader],
154
+ }));
155
+ }
156
+ }
157
+
158
+ const results = await Promise.all(builds);
159
+
160
+ // Write all output files through the runtime
161
+ for (const result of results) {
162
+ for (const file of result.outputFiles) {
163
+ // outfile paths are relative — ensure leading /
164
+ const runtimePath = file.path.startsWith('/') ? file.path : '/' + file.path;
165
+ await this.command(runtimePath, { body: file.contents as unknown as BodyInit });
166
+ }
167
+ }
168
+
169
+ await this.writeShell(paths);
170
+
171
+ await esbuild.stop();
172
+ BunSqliteRuntime._esbuild = null;
173
+ }
174
+
175
+ // ── Private ─────────────────────────────────────────────────────────
176
+
177
+ private async read(path: string): Promise<Response> {
178
+ if (path.endsWith('/')) {
179
+ const children = this.listChildren(path);
180
+ if (children.length === 0) {
181
+ return new Response('Not Found', { status: 404 });
182
+ }
183
+ return Response.json(children);
184
+ }
185
+
186
+ if (!this.stmtGet.get(path) && this.hasChildren(path + '/')) {
187
+ return Response.json(this.listChildren(path + '/'));
188
+ }
189
+
190
+ const row = this.stmtGet.get(path) as { data: Uint8Array; mtime: string } | null;
191
+ if (!row) {
192
+ if (path === ROUTES_MANIFEST_PATH) return this.resolveRoutesManifest();
193
+ if (path === WIDGETS_MANIFEST_PATH) return this.resolveWidgetsManifest();
194
+ return new Response('Not Found', { status: 404 });
195
+ }
196
+
197
+ const data = new Uint8Array(row.data);
198
+ const ext = path.slice(path.lastIndexOf('.')).toLowerCase();
199
+ const headers: HeadersInit = {
200
+ 'Content-Type': CONTENT_TYPES.get(ext) ?? 'application/octet-stream',
201
+ 'Content-Length': data.byteLength.toString(),
202
+ 'Last-Modified': new Date(row.mtime).toUTCString(),
203
+ };
204
+
205
+ return new Response(data, { status: 200, headers });
206
+ }
207
+
208
+ private async write(path: string, body: BodyInit | null): Promise<Response> {
209
+ const data = body
210
+ ? new Uint8Array(await new Response(body).arrayBuffer())
211
+ : new Uint8Array();
212
+ this.stmtSet.run(path, data, new Date().toISOString());
213
+ return new Response(null, { status: 204 });
214
+ }
215
+
216
+ private listChildren(prefix: string): string[] {
217
+ const rows = this.stmtList.all(prefix) as { path: string }[];
218
+ const entries = new Set<string>();
219
+ for (const row of rows) {
220
+ const rest = row.path.slice(prefix.length);
221
+ const slashIdx = rest.indexOf('/');
222
+ if (slashIdx === -1) {
223
+ entries.add(rest);
224
+ } else {
225
+ entries.add(rest.slice(0, slashIdx + 1));
226
+ }
227
+ }
228
+ return [...entries];
229
+ }
230
+
231
+ private hasChildren(prefix: string): boolean {
232
+ return this.stmtHas.get(prefix) !== null;
233
+ }
234
+
235
+ // ── Transpile / esbuild ───────────────────────────────────────────────
236
+
237
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
238
+ private static _esbuild: any = null;
239
+
240
+ private static async esbuild() {
241
+ if (!BunSqliteRuntime._esbuild) {
242
+ BunSqliteRuntime._esbuild = await import('esbuild');
243
+ }
244
+ return BunSqliteRuntime._esbuild;
245
+ }
246
+
247
+ static override transpile(source: string): Promise<string> {
248
+ const transpiler = new Bun.Transpiler({ loader: 'ts' });
249
+ return Promise.resolve(transpiler.transformSync(source));
250
+ }
251
+
252
+ static override async stopBundler(): Promise<void> {
253
+ if (BunSqliteRuntime._esbuild) {
254
+ await BunSqliteRuntime._esbuild.stop();
255
+ BunSqliteRuntime._esbuild = null;
256
+ }
257
+ }
258
+
259
+ private parsePath(resource: FetchParams[0]): string {
260
+ if (typeof resource === 'string') return decodeURIComponent(resource);
261
+ if (resource instanceof URL) return decodeURIComponent(resource.pathname);
262
+ return decodeURIComponent(new URL(resource.url).pathname);
263
+ }
264
+
265
+ private parse(
266
+ resource: FetchParams[0],
267
+ init?: RequestInit,
268
+ ): [string, string, BodyInit | null] {
269
+ const pathname = this.parsePath(resource);
270
+ if (typeof resource === 'string' || resource instanceof URL) {
271
+ return [pathname, init?.method ?? 'GET', init?.body ?? null];
272
+ }
273
+ return [
274
+ pathname,
275
+ init?.method ?? resource.method,
276
+ init?.body ?? resource.body,
277
+ ];
278
+ }
279
+ }
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Sitemap Generator — Opt-in Submodule
3
+ *
4
+ * Generates sitemap.xml from a RoutesManifest. Pure function over manifest data,
5
+ * no filesystem access needed.
6
+ *
7
+ * Usage:
8
+ * import { generateSitemap } from '@emkodev/emroute/runtime/sitemap';
9
+ * const xml = await generateSitemap(manifest, { baseUrl: 'https://example.com' });
10
+ *
11
+ * Per sitemaps.org protocol:
12
+ * - <loc> is required (full absolute URL)
13
+ * - <lastmod>, <changefreq>, <priority> are optional
14
+ * - URLs use route patterns as-is (patterns include basePath when present)
15
+ * - Max 50,000 URLs per sitemap file
16
+ *
17
+ * @see https://www.sitemaps.org/protocol.html
18
+ */
19
+
20
+ import { escapeHtml } from '../src/util/html.util.ts';
21
+ import type { RoutesManifest } from '../src/type/route.type.ts';
22
+
23
+ /** Valid changefreq values per sitemaps.org protocol. */
24
+ export type Changefreq =
25
+ | 'always'
26
+ | 'hourly'
27
+ | 'daily'
28
+ | 'weekly'
29
+ | 'monthly'
30
+ | 'yearly'
31
+ | 'never';
32
+
33
+ /** Per-route sitemap overrides. */
34
+ export interface SitemapRouteOptions {
35
+ /** W3C Datetime, e.g. '2025-06-15' or '2025-06-15T10:30:00+00:00' */
36
+ lastmod?: string;
37
+ changefreq?: Changefreq;
38
+ /** 0.0–1.0, default 0.5 per protocol */
39
+ priority?: number;
40
+ }
41
+
42
+ /** Options for sitemap generation. */
43
+ export interface SitemapOptions {
44
+ /** Site origin with protocol, e.g. 'https://example.com'. No trailing slash. */
45
+ baseUrl: string;
46
+
47
+ /** Per-route overrides keyed by route pattern (including basePath if present). */
48
+ routes?: Record<string, SitemapRouteOptions>;
49
+
50
+ /** Defaults applied when a route has no specific override. */
51
+ defaults?: SitemapRouteOptions;
52
+
53
+ /**
54
+ * Enumerators for dynamic routes. Keyed by route pattern (e.g. '/html/projects/:id').
55
+ * Each function returns concrete path segments to substitute for the parameter.
56
+ * Dynamic routes without an enumerator are excluded from the sitemap.
57
+ */
58
+ enumerators?: Record<string, () => Promise<string[]>>;
59
+
60
+ /**
61
+ * Base path to prepend to patterns when manifest contains bare patterns.
62
+ * When the manifest already has prefixed patterns (e.g. from generateManifestCode),
63
+ * leave this unset.
64
+ */
65
+ basePath?: string;
66
+ }
67
+
68
+ /** A resolved URL entry before XML serialization. */
69
+ interface SitemapEntry {
70
+ loc: string;
71
+ lastmod?: string;
72
+ changefreq?: Changefreq;
73
+ priority?: number;
74
+ }
75
+
76
+ const XML_HEADER = '<?xml version="1.0" encoding="UTF-8"?>';
77
+ const URLSET_OPEN = '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">';
78
+ const URLSET_CLOSE = '</urlset>';
79
+ const MAX_URLS = 50_000;
80
+
81
+ /** Check if a route pattern contains dynamic segments. */
82
+ function isDynamic(pattern: string): boolean {
83
+ return pattern.includes(':');
84
+ }
85
+
86
+ /** Build the absolute URL for a route path. */
87
+ function buildLoc(baseUrl: string, path: string, basePath = ''): string {
88
+ const base = baseUrl.replace(/\/+$/, '');
89
+ if (path === '/' && basePath) return `${base}${basePath}/`;
90
+ return `${base}${basePath}${path}`;
91
+ }
92
+
93
+ /**
94
+ * Expand a dynamic pattern using an enumerator's param values.
95
+ * Supports single-param patterns like '/projects/:id'.
96
+ * Multi-param patterns are expanded per value (first param replaced).
97
+ */
98
+ function expandDynamic(pattern: string, values: string[]): string[] {
99
+ // Find the first :param segment
100
+ const paramMatch = pattern.match(/:([^/]+)/);
101
+ if (!paramMatch) return [];
102
+
103
+ return values.map((value) => pattern.replace(paramMatch[0], encodeURIComponent(value)));
104
+ }
105
+
106
+ /** Resolve route options: per-route override > defaults > empty. */
107
+ function resolveOptions(
108
+ pattern: string,
109
+ options: SitemapOptions,
110
+ ): SitemapRouteOptions {
111
+ return { ...options.defaults, ...options.routes?.[pattern] };
112
+ }
113
+
114
+ /** Serialize a single <url> entry. */
115
+ function serializeEntry(entry: SitemapEntry): string {
116
+ const lines = [` <url>`, ` <loc>${escapeHtml(entry.loc)}</loc>`];
117
+
118
+ if (entry.lastmod !== undefined) {
119
+ lines.push(` <lastmod>${escapeHtml(entry.lastmod)}</lastmod>`);
120
+ }
121
+ if (entry.changefreq !== undefined) {
122
+ lines.push(` <changefreq>${entry.changefreq}</changefreq>`);
123
+ }
124
+ if (entry.priority !== undefined) {
125
+ lines.push(` <priority>${entry.priority.toFixed(1)}</priority>`);
126
+ }
127
+
128
+ lines.push(` </url>`);
129
+ return lines.join('\n');
130
+ }
131
+
132
+ /**
133
+ * Generate sitemap.xml content from a routes manifest.
134
+ *
135
+ * Static routes (no :param) are included directly.
136
+ * Dynamic routes are included only if an enumerator is provided.
137
+ * All URLs point to /html/ prefixed paths for SSR HTML rendering.
138
+ */
139
+ export async function generateSitemap(
140
+ manifest: RoutesManifest,
141
+ options: SitemapOptions,
142
+ ): Promise<string> {
143
+ const entries: SitemapEntry[] = [];
144
+ const bp = options.basePath ?? '';
145
+
146
+ // Filter to page routes only (exclude error, redirect)
147
+ const pages = manifest.routes.filter((r) => r.type === 'page');
148
+
149
+ for (const route of pages) {
150
+ const routeOpts = resolveOptions(route.pattern, options);
151
+
152
+ if (isDynamic(route.pattern)) {
153
+ // Dynamic route — use enumerator if provided, skip otherwise
154
+ const enumerator = options.enumerators?.[route.pattern];
155
+ if (!enumerator) continue;
156
+
157
+ const values = await enumerator();
158
+ const paths = expandDynamic(route.pattern, values);
159
+
160
+ for (const path of paths) {
161
+ entries.push({
162
+ loc: buildLoc(options.baseUrl, path, bp),
163
+ ...routeOpts,
164
+ });
165
+ }
166
+ } else {
167
+ // Static route — include directly
168
+ entries.push({
169
+ loc: buildLoc(options.baseUrl, route.pattern, bp),
170
+ ...routeOpts,
171
+ });
172
+ }
173
+
174
+ if (entries.length >= MAX_URLS) break;
175
+ }
176
+
177
+ const urlEntries = entries.slice(0, MAX_URLS).map(serializeEntry).join('\n');
178
+
179
+ return `${XML_HEADER}\n${URLSET_OPEN}\n${urlEntries}\n${URLSET_CLOSE}\n`;
180
+ }