@emkodev/emroute 1.7.2 → 1.8.0-beta.1

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 (236) hide show
  1. package/README.md +1 -1
  2. package/core/component/abstract.component.ts +74 -0
  3. package/{src → core}/component/page.component.ts +3 -61
  4. package/core/component/widget.component.ts +54 -0
  5. package/core/pipeline/pipeline.ts +224 -0
  6. package/{src/renderer/ssr → core/renderer}/html.renderer.ts +19 -45
  7. package/{src/renderer/ssr → core/renderer}/md.renderer.ts +21 -40
  8. package/{src/renderer/ssr → core/renderer}/ssr.renderer.ts +42 -53
  9. package/{src/route → core/router}/route.resolver.ts +1 -10
  10. package/core/router/route.trie.ts +175 -0
  11. package/core/runtime/abstract.runtime.ts +47 -0
  12. package/core/server/emroute.server.ts +346 -0
  13. package/core/type/component.type.ts +39 -0
  14. package/core/type/element.type.ts +10 -0
  15. package/core/type/logger.type.ts +20 -0
  16. package/core/type/markdown.type.ts +8 -0
  17. package/core/type/route-tree.type.ts +28 -0
  18. package/core/type/route.type.ts +75 -0
  19. package/core/type/widget.type.ts +27 -0
  20. package/core/util/html.util.ts +50 -0
  21. package/{src → core}/util/md.util.ts +0 -2
  22. package/{src/route → core/util}/route-tree.util.ts +0 -2
  23. package/{src → core}/util/widget-resolve.util.ts +15 -45
  24. package/{src → core}/widget/widget.parser.ts +0 -21
  25. package/core/widget/widget.registry.ts +24 -0
  26. package/dist/core/component/abstract.component.d.ts +48 -0
  27. package/dist/core/component/abstract.component.js +42 -0
  28. package/dist/core/component/abstract.component.js.map +1 -0
  29. package/dist/core/component/page.component.d.ts +23 -0
  30. package/dist/core/component/page.component.js +49 -0
  31. package/dist/core/component/page.component.js.map +1 -0
  32. package/dist/core/component/widget.component.d.ts +17 -0
  33. package/dist/core/component/widget.component.js +37 -0
  34. package/dist/core/component/widget.component.js.map +1 -0
  35. package/dist/core/pipeline/pipeline.d.ts +61 -0
  36. package/dist/core/pipeline/pipeline.js +189 -0
  37. package/dist/core/pipeline/pipeline.js.map +1 -0
  38. package/dist/{src/renderer/ssr → core/renderer}/html.renderer.d.ts +8 -24
  39. package/dist/{src/renderer/ssr → core/renderer}/html.renderer.js +12 -33
  40. package/dist/core/renderer/html.renderer.js.map +1 -0
  41. package/dist/{src/renderer/ssr → core/renderer}/md.renderer.d.ts +6 -21
  42. package/dist/{src/renderer/ssr → core/renderer}/md.renderer.js +14 -31
  43. package/dist/core/renderer/md.renderer.js.map +1 -0
  44. package/dist/{src/renderer/ssr → core/renderer}/ssr.renderer.d.ts +11 -17
  45. package/dist/{src/renderer/ssr → core/renderer}/ssr.renderer.js +34 -36
  46. package/dist/core/renderer/ssr.renderer.js.map +1 -0
  47. package/dist/{src/route → core/router}/route.resolver.d.ts +1 -8
  48. package/dist/{src/route → core/router}/route.resolver.js +0 -1
  49. package/dist/core/router/route.resolver.js.map +1 -0
  50. package/dist/core/router/route.trie.d.ts +32 -0
  51. package/dist/core/router/route.trie.js +152 -0
  52. package/dist/core/router/route.trie.js.map +1 -0
  53. package/dist/core/runtime/abstract.runtime.d.ts +32 -0
  54. package/dist/core/runtime/abstract.runtime.js +26 -0
  55. package/dist/core/runtime/abstract.runtime.js.map +1 -0
  56. package/dist/core/server/emroute.server.d.ts +48 -0
  57. package/dist/core/server/emroute.server.js +261 -0
  58. package/dist/core/server/emroute.server.js.map +1 -0
  59. package/dist/core/server/server.type.d.ts +45 -0
  60. package/dist/core/server/server.type.js +11 -0
  61. package/dist/core/server/server.type.js.map +1 -0
  62. package/dist/core/type/component.type.d.ts +37 -0
  63. package/dist/core/type/component.type.js +7 -0
  64. package/dist/core/type/component.type.js.map +1 -0
  65. package/dist/core/type/element.type.d.ts +9 -0
  66. package/dist/core/type/element.type.js +5 -0
  67. package/dist/core/type/element.type.js.map +1 -0
  68. package/dist/core/type/logger.type.d.ts +14 -0
  69. package/dist/core/type/logger.type.js +8 -0
  70. package/dist/core/type/logger.type.js.map +1 -0
  71. package/dist/core/type/markdown.type.d.ts +7 -0
  72. package/dist/core/type/markdown.type.js +5 -0
  73. package/dist/core/type/markdown.type.js.map +1 -0
  74. package/dist/{src → core}/type/route-tree.type.d.ts +0 -12
  75. package/dist/{src → core}/type/route-tree.type.js +0 -1
  76. package/dist/core/type/route-tree.type.js.map +1 -0
  77. package/dist/core/type/route.type.d.ts +62 -0
  78. package/dist/core/type/route.type.js +7 -0
  79. package/dist/core/type/route.type.js.map +1 -0
  80. package/dist/core/type/widget.type.d.ts +27 -0
  81. package/dist/core/type/widget.type.js +5 -0
  82. package/dist/core/type/widget.type.js.map +1 -0
  83. package/dist/core/util/html.util.d.ts +14 -0
  84. package/dist/core/util/html.util.js +43 -0
  85. package/dist/core/util/html.util.js.map +1 -0
  86. package/dist/{src → core}/util/md.util.d.ts +0 -1
  87. package/dist/{src → core}/util/md.util.js +0 -2
  88. package/dist/core/util/md.util.js.map +1 -0
  89. package/dist/{src/route → core/util}/route-tree.util.js +0 -2
  90. package/dist/core/util/route-tree.util.js.map +1 -0
  91. package/dist/core/util/widget-resolve.util.d.ts +32 -0
  92. package/dist/{src → core}/util/widget-resolve.util.js +11 -41
  93. package/dist/core/util/widget-resolve.util.js.map +1 -0
  94. package/dist/{src → core}/widget/widget.parser.d.ts +0 -13
  95. package/dist/{src → core}/widget/widget.parser.js +0 -21
  96. package/dist/core/widget/widget.parser.js.map +1 -0
  97. package/dist/core/widget/widget.registry.d.ts +13 -0
  98. package/dist/core/widget/widget.registry.js +19 -0
  99. package/dist/core/widget/widget.registry.js.map +1 -0
  100. package/dist/emroute.js +1137 -1151
  101. package/dist/emroute.js.map +36 -5
  102. package/dist/runtime/abstract.runtime.d.ts +50 -5
  103. package/dist/runtime/abstract.runtime.js +446 -6
  104. package/dist/runtime/abstract.runtime.js.map +1 -1
  105. package/dist/runtime/bun/fs/bun-fs.runtime.d.ts +1 -0
  106. package/dist/runtime/bun/fs/bun-fs.runtime.js +18 -2
  107. package/dist/runtime/bun/fs/bun-fs.runtime.js.map +1 -1
  108. package/dist/runtime/bun/sqlite/bun-sqlite.runtime.d.ts +2 -0
  109. package/dist/runtime/bun/sqlite/bun-sqlite.runtime.js +11 -1
  110. package/dist/runtime/bun/sqlite/bun-sqlite.runtime.js.map +1 -1
  111. package/dist/runtime/fetch.runtime.d.ts +3 -3
  112. package/dist/runtime/fetch.runtime.js +3 -3
  113. package/dist/runtime/sitemap.generator.d.ts +1 -1
  114. package/dist/runtime/sitemap.generator.js +1 -1
  115. package/dist/runtime/sitemap.generator.js.map +1 -1
  116. package/dist/runtime/universal/fs/universal-fs.runtime.d.ts +1 -0
  117. package/dist/runtime/universal/fs/universal-fs.runtime.js +18 -2
  118. package/dist/runtime/universal/fs/universal-fs.runtime.js.map +1 -1
  119. package/dist/server/build.util.d.ts +9 -10
  120. package/dist/server/build.util.js +45 -36
  121. package/dist/server/build.util.js.map +1 -1
  122. package/dist/server/codegen.util.d.ts +1 -1
  123. package/dist/server/emroute.server.d.ts +8 -35
  124. package/dist/server/emroute.server.js +7 -341
  125. package/dist/server/emroute.server.js.map +1 -1
  126. package/dist/server/esbuild-manifest.plugin.js +1 -1
  127. package/dist/server/esbuild-manifest.plugin.js.map +1 -1
  128. package/dist/server/server-api.type.d.ts +3 -68
  129. package/dist/server/server-api.type.js +1 -8
  130. package/dist/server/server-api.type.js.map +1 -1
  131. package/dist/src/element/component.element.d.ts +4 -7
  132. package/dist/src/element/component.element.js +23 -22
  133. package/dist/src/element/component.element.js.map +1 -1
  134. package/dist/src/element/markdown.element.d.ts +2 -2
  135. package/dist/src/element/markdown.element.js +4 -3
  136. package/dist/src/element/markdown.element.js.map +1 -1
  137. package/dist/src/index.d.ts +15 -13
  138. package/dist/src/index.js +8 -8
  139. package/dist/src/index.js.map +1 -1
  140. package/dist/src/renderer/spa/emroute.app.d.ts +50 -0
  141. package/dist/src/renderer/spa/emroute.app.js +246 -0
  142. package/dist/src/renderer/spa/emroute.app.js.map +1 -0
  143. package/dist/src/renderer/spa/mod.d.ts +17 -16
  144. package/dist/src/renderer/spa/mod.js +9 -9
  145. package/dist/src/renderer/spa/mod.js.map +1 -1
  146. package/dist/src/renderer/spa/thin-client.d.ts +3 -3
  147. package/dist/src/renderer/spa/thin-client.js +34 -12
  148. package/dist/src/renderer/spa/thin-client.js.map +1 -1
  149. package/dist/src/route/route.core.d.ts +3 -3
  150. package/dist/src/route/route.core.js +21 -7
  151. package/dist/src/route/route.core.js.map +1 -1
  152. package/dist/src/util/html.util.d.ts +5 -22
  153. package/dist/src/util/html.util.js +8 -56
  154. package/dist/src/util/html.util.js.map +1 -1
  155. package/dist/src/widget/breadcrumb.widget.d.ts +2 -2
  156. package/dist/src/widget/breadcrumb.widget.js +2 -2
  157. package/dist/src/widget/breadcrumb.widget.js.map +1 -1
  158. package/dist/src/widget/page-title.widget.d.ts +1 -1
  159. package/dist/src/widget/page-title.widget.js +1 -1
  160. package/dist/src/widget/page-title.widget.js.map +1 -1
  161. package/package.json +8 -8
  162. package/runtime/abstract.runtime.ts +483 -8
  163. package/runtime/bun/fs/bun-fs.runtime.ts +17 -1
  164. package/runtime/bun/sqlite/bun-sqlite.runtime.ts +11 -0
  165. package/runtime/fetch.runtime.ts +3 -3
  166. package/runtime/sitemap.generator.ts +2 -2
  167. package/runtime/universal/fs/universal-fs.runtime.ts +17 -1
  168. package/server/build.util.ts +53 -47
  169. package/server/codegen.util.ts +1 -1
  170. package/server/emroute.server.ts +12 -412
  171. package/src/element/component.element.ts +24 -31
  172. package/src/element/markdown.element.ts +5 -4
  173. package/src/index.ts +22 -18
  174. package/src/renderer/spa/{thin-client.ts → emroute.app.ts} +46 -22
  175. package/src/renderer/spa/mod.ts +22 -22
  176. package/src/util/html.util.ts +16 -61
  177. package/src/widget/breadcrumb.widget.ts +3 -3
  178. package/src/widget/page-title.widget.ts +1 -1
  179. package/dist/src/component/abstract.component.d.ts +0 -199
  180. package/dist/src/component/abstract.component.js +0 -84
  181. package/dist/src/component/abstract.component.js.map +0 -1
  182. package/dist/src/component/page.component.d.ts +0 -74
  183. package/dist/src/component/page.component.js +0 -107
  184. package/dist/src/component/page.component.js.map +0 -1
  185. package/dist/src/component/widget.component.d.ts +0 -47
  186. package/dist/src/component/widget.component.js +0 -69
  187. package/dist/src/component/widget.component.js.map +0 -1
  188. package/dist/src/renderer/ssr/html.renderer.js.map +0 -1
  189. package/dist/src/renderer/ssr/md.renderer.js.map +0 -1
  190. package/dist/src/renderer/ssr/ssr.renderer.js.map +0 -1
  191. package/dist/src/route/route-tree.util.js.map +0 -1
  192. package/dist/src/route/route.matcher.d.ts +0 -86
  193. package/dist/src/route/route.matcher.js +0 -214
  194. package/dist/src/route/route.matcher.js.map +0 -1
  195. package/dist/src/route/route.resolver.js.map +0 -1
  196. package/dist/src/route/route.trie.d.ts +0 -38
  197. package/dist/src/route/route.trie.js +0 -206
  198. package/dist/src/route/route.trie.js.map +0 -1
  199. package/dist/src/type/logger.type.d.ts +0 -17
  200. package/dist/src/type/logger.type.js +0 -9
  201. package/dist/src/type/logger.type.js.map +0 -1
  202. package/dist/src/type/markdown.type.d.ts +0 -20
  203. package/dist/src/type/markdown.type.js +0 -2
  204. package/dist/src/type/markdown.type.js.map +0 -1
  205. package/dist/src/type/route-tree.type.js.map +0 -1
  206. package/dist/src/type/route.type.d.ts +0 -94
  207. package/dist/src/type/route.type.js +0 -8
  208. package/dist/src/type/route.type.js.map +0 -1
  209. package/dist/src/type/widget.type.d.ts +0 -55
  210. package/dist/src/type/widget.type.js +0 -10
  211. package/dist/src/type/widget.type.js.map +0 -1
  212. package/dist/src/util/logger.util.d.ts +0 -26
  213. package/dist/src/util/logger.util.js +0 -80
  214. package/dist/src/util/logger.util.js.map +0 -1
  215. package/dist/src/util/md.util.js.map +0 -1
  216. package/dist/src/util/widget-resolve.util.d.ts +0 -52
  217. package/dist/src/util/widget-resolve.util.js.map +0 -1
  218. package/dist/src/widget/widget.parser.js.map +0 -1
  219. package/dist/src/widget/widget.registry.d.ts +0 -23
  220. package/dist/src/widget/widget.registry.js +0 -42
  221. package/dist/src/widget/widget.registry.js.map +0 -1
  222. package/runtime/bun/esbuild-runtime-loader.plugin.ts +0 -112
  223. package/server/esbuild-manifest.plugin.ts +0 -209
  224. package/server/server-api.type.ts +0 -97
  225. package/src/component/abstract.component.ts +0 -231
  226. package/src/component/widget.component.ts +0 -85
  227. package/src/route/route.core.ts +0 -362
  228. package/src/route/route.trie.ts +0 -265
  229. package/src/type/logger.type.ts +0 -24
  230. package/src/type/markdown.type.ts +0 -21
  231. package/src/type/route-tree.type.ts +0 -51
  232. package/src/type/route.type.ts +0 -124
  233. package/src/type/widget.type.ts +0 -65
  234. package/src/util/logger.util.ts +0 -83
  235. package/src/widget/widget.registry.ts +0 -51
  236. /package/dist/{src/route → core/util}/route-tree.util.d.ts +0 -0
@@ -1,6 +1,7 @@
1
- import type { RouteNode, RouteFiles } from '../src/type/route-tree.type.ts';
2
- import { resolveTargetNode } from '../src/route/route-tree.util.ts';
3
- import type { WidgetManifestEntry } from '../src/type/widget.type.ts';
1
+ import type { RouteNode, RouteFiles } from '../core/type/route-tree.type.ts';
2
+ import { resolveTargetNode } from '../core/util/route-tree.util.ts';
3
+ import type { WidgetManifestEntry } from '../core/type/widget.type.ts';
4
+ import type { ElementManifestEntry } from '../core/type/element.type.ts';
4
5
 
5
6
  export const CONTENT_TYPES: Map<string, string> = new Map<string, string>([
6
7
  ['.html', 'text/html; charset=utf-8'],
@@ -31,12 +32,18 @@ export type FetchReturn = ReturnType<typeof fetch>;
31
32
 
32
33
  export const DEFAULT_ROUTES_DIR = '/routes';
33
34
  export const DEFAULT_WIDGETS_DIR = '/widgets';
34
- export const ROUTES_MANIFEST_PATH = '/routes.manifest.json';
35
- export const WIDGETS_MANIFEST_PATH = '/widgets.manifest.json';
35
+ export const DEFAULT_ELEMENTS_DIR = '/elements';
36
+ import {
37
+ ROUTES_MANIFEST_PATH,
38
+ WIDGETS_MANIFEST_PATH,
39
+ ELEMENTS_MANIFEST_PATH,
40
+ } from '../core/runtime/abstract.runtime.ts';
41
+ export { ROUTES_MANIFEST_PATH, WIDGETS_MANIFEST_PATH, ELEMENTS_MANIFEST_PATH };
36
42
 
37
43
  export interface RuntimeConfig {
38
44
  routesDir?: string;
39
45
  widgetsDir?: string;
46
+ elementsDir?: string;
40
47
  }
41
48
 
42
49
  /**
@@ -72,16 +79,47 @@ export abstract class Runtime {
72
79
  options?: FetchParams[1],
73
80
  ): FetchReturn;
74
81
 
75
- /** Write. Defaults to PUT; pass `{ method: "DELETE" }` etc. to override. */
82
+ /** Write or delete. Defaults to PUT; pass `{ method: "DELETE" }` to remove. */
76
83
  command(resource: FetchParams[0], options?: FetchParams[1]): FetchReturn {
77
84
  const path = typeof resource === 'string'
78
85
  ? resource
79
86
  : new URL(resource instanceof Request ? resource.url : resource.toString()).pathname;
80
- const result = this.handle(resource, { method: 'PUT', ...options });
87
+ const method = options?.method ?? 'PUT';
88
+ const isDelete = method === 'DELETE';
89
+ const result = this.handle(resource, { method, ...options });
81
90
  const routesDir = this.config.routesDir ?? DEFAULT_ROUTES_DIR;
91
+ const widgetsDir = this.config.widgetsDir ?? DEFAULT_WIDGETS_DIR;
92
+ const elementsDir = this.config.elementsDir ?? DEFAULT_ELEMENTS_DIR;
82
93
  if (path.startsWith(routesDir + '/')) {
83
94
  return result.then(async (res) => {
84
- await this.mergeRouteIntoManifest(path, routesDir);
95
+ if (isDelete) {
96
+ await this.pruneRouteFromManifest(path, routesDir);
97
+ } else {
98
+ await this.mergeRouteIntoManifest(path, routesDir);
99
+ await this.retranspileIfNeeded(path, routesDir, 'route');
100
+ }
101
+ return res;
102
+ });
103
+ }
104
+ if (path.startsWith(widgetsDir + '/')) {
105
+ return result.then(async (res) => {
106
+ if (isDelete) {
107
+ await this.pruneWidgetFromManifest(path, widgetsDir);
108
+ } else {
109
+ await this.mergeWidgetIntoManifest(path, widgetsDir);
110
+ await this.retranspileIfNeeded(path, widgetsDir, 'widget');
111
+ }
112
+ return res;
113
+ });
114
+ }
115
+ if (path.startsWith(elementsDir + '/')) {
116
+ return result.then(async (res) => {
117
+ if (isDelete) {
118
+ await this.pruneElementFromManifest(path, elementsDir);
119
+ } else {
120
+ await this.mergeElementIntoManifest(path, elementsDir);
121
+ await this.retranspileIfNeeded(path, elementsDir, 'element');
122
+ }
85
123
  return res;
86
124
  });
87
125
  }
@@ -148,6 +186,274 @@ export abstract class Runtime {
148
186
  });
149
187
  }
150
188
 
189
+ /**
190
+ * Remove a route entry from the stored manifest when a file is deleted.
191
+ * Walks the tree to find the node, clears the relevant field, then
192
+ * prunes empty ancestor nodes.
193
+ */
194
+ private async pruneRouteFromManifest(
195
+ filePath: string,
196
+ routesDir: string,
197
+ ): Promise<void> {
198
+ const relativePath = filePath.slice(routesDir.length + 1);
199
+ const parts = relativePath.split('/');
200
+ const filename = parts[parts.length - 1];
201
+ const dirSegments = parts.slice(0, -1);
202
+
203
+ const match = filename.match(/^(.+?)\.(page|error|redirect)\.(ts|js|html|md|css)$/);
204
+ if (!match) return;
205
+
206
+ const [, name, kind, ext] = match;
207
+
208
+ const response = await this.handle(ROUTES_MANIFEST_PATH);
209
+ if (response.status === 404) return;
210
+ const tree: RouteNode = await response.json();
211
+
212
+ // Walk to the parent node, tracking path for pruning
213
+ const ancestors: { node: RouteNode; key: string; via: 'children' | 'dynamic' }[] = [];
214
+ let node = tree;
215
+ for (const dir of dirSegments) {
216
+ if (dir.startsWith('[') && dir.endsWith(']')) {
217
+ if (!node.dynamic) return;
218
+ ancestors.push({ node, key: dir, via: 'dynamic' });
219
+ node = node.dynamic.child;
220
+ } else {
221
+ if (!node.children?.[dir]) return;
222
+ ancestors.push({ node, key: dir, via: 'children' });
223
+ node = node.children[dir];
224
+ }
225
+ }
226
+
227
+ // Clear the field
228
+ if (kind === 'error') {
229
+ if (node.errorBoundary === filePath) delete node.errorBoundary;
230
+ } else {
231
+ const isRoot = dirSegments.length === 0;
232
+ const target = this.findTargetNode(node, name, isRoot);
233
+ if (!target) return;
234
+
235
+ if (kind === 'redirect') {
236
+ if (target.redirect === filePath) delete target.redirect;
237
+ } else {
238
+ if (target.files?.[ext as keyof RouteFiles] === filePath) {
239
+ delete target.files[ext as keyof RouteFiles];
240
+ if (Object.keys(target.files).length === 0) delete target.files;
241
+ }
242
+ }
243
+
244
+ // If target is a child node and now empty, remove it
245
+ if (target !== node && this.isEmptyNode(target)) {
246
+ if (name === 'index' && !isRoot) {
247
+ delete node.wildcard;
248
+ } else if (name.startsWith('[') && name.endsWith(']')) {
249
+ delete node.dynamic;
250
+ } else if (node.children) {
251
+ delete node.children[name];
252
+ if (Object.keys(node.children).length === 0) delete node.children;
253
+ }
254
+ }
255
+ }
256
+
257
+ // Prune empty ancestors bottom-up
258
+ for (let i = ancestors.length - 1; i >= 0; i--) {
259
+ const { node: parent, key, via } = ancestors[i];
260
+ const child = via === 'dynamic' ? parent.dynamic?.child : parent.children?.[key];
261
+ if (child && this.isEmptyNode(child)) {
262
+ if (via === 'dynamic') {
263
+ delete parent.dynamic;
264
+ } else if (parent.children) {
265
+ delete parent.children[key];
266
+ if (Object.keys(parent.children).length === 0) delete parent.children;
267
+ }
268
+ }
269
+ }
270
+
271
+ this.routesManifestCache = null;
272
+ await this.handle(ROUTES_MANIFEST_PATH, {
273
+ method: 'PUT',
274
+ body: JSON.stringify(tree),
275
+ });
276
+ }
277
+
278
+ /** Find a target node without creating it (read-only counterpart to resolveTargetNode). */
279
+ private findTargetNode(node: RouteNode, name: string, isRoot: boolean): RouteNode | null {
280
+ if (name === 'index') {
281
+ return isRoot ? node : (node.wildcard?.child ?? null);
282
+ }
283
+ if (name.startsWith('[') && name.endsWith(']')) {
284
+ return node.dynamic?.child ?? null;
285
+ }
286
+ return node.children?.[name] ?? null;
287
+ }
288
+
289
+ private isEmptyNode(node: RouteNode): boolean {
290
+ return (
291
+ !node.files &&
292
+ !node.errorBoundary &&
293
+ !node.redirect &&
294
+ !node.children &&
295
+ !node.dynamic &&
296
+ !node.wildcard
297
+ );
298
+ }
299
+
300
+ /**
301
+ * Remove a widget entry from the stored manifest when a file is deleted.
302
+ */
303
+ private async pruneWidgetFromManifest(
304
+ filePath: string,
305
+ widgetsDir: string,
306
+ ): Promise<void> {
307
+ const relativePath = filePath.slice(widgetsDir.length + 1);
308
+ const parts = relativePath.split('/');
309
+ if (parts.length !== 2) return;
310
+
311
+ const [dirName, filename] = parts;
312
+ const match = filename.match(/^(.+?)\.widget\.(ts|js|html|md|css)$/);
313
+ if (!match) return;
314
+
315
+ const [, name, ext] = match;
316
+ if (name !== dirName) return;
317
+
318
+ const response = await this.handle(WIDGETS_MANIFEST_PATH);
319
+ if (response.status === 404) return;
320
+ const entries: WidgetManifestEntry[] = await response.json();
321
+
322
+ if (ext === 'ts' || ext === 'js') {
323
+ // Module deleted → remove entire entry
324
+ const idx = entries.findIndex((e) => e.name === name);
325
+ if (idx === -1) return;
326
+ entries.splice(idx, 1);
327
+ } else {
328
+ // Companion deleted → remove from files
329
+ const entry = entries.find((e) => e.name === name);
330
+ if (!entry?.files) return;
331
+ delete (entry.files as Record<string, string>)[ext];
332
+ if (Object.keys(entry.files).length === 0) delete entry.files;
333
+ }
334
+
335
+ this.widgetsManifestCache = null;
336
+ await this.handle(WIDGETS_MANIFEST_PATH, {
337
+ method: 'PUT',
338
+ body: JSON.stringify(entries),
339
+ });
340
+ }
341
+
342
+ /**
343
+ * Remove an element entry from the stored manifest when a file is deleted.
344
+ */
345
+ private async pruneElementFromManifest(
346
+ filePath: string,
347
+ elementsDir: string,
348
+ ): Promise<void> {
349
+ const relativePath = filePath.slice(elementsDir.length + 1);
350
+ const parts = relativePath.split('/');
351
+ if (parts.length !== 2) return;
352
+
353
+ const [dirName, filename] = parts;
354
+ const match = filename.match(/^(.+?)\.element\.(ts|js)$/);
355
+ if (!match) return;
356
+
357
+ const [, name] = match;
358
+ if (name !== dirName) return;
359
+
360
+ const response = await this.handle(ELEMENTS_MANIFEST_PATH);
361
+ if (response.status === 404) return;
362
+ const entries: ElementManifestEntry[] = await response.json();
363
+
364
+ const idx = entries.findIndex((e) => e.name === name);
365
+ if (idx === -1) return;
366
+ entries.splice(idx, 1);
367
+
368
+ this.elementsManifestCache = null;
369
+ await this.handle(ELEMENTS_MANIFEST_PATH, {
370
+ method: 'PUT',
371
+ body: JSON.stringify(entries),
372
+ });
373
+ }
374
+
375
+ /**
376
+ * After a source or companion file is written, check if a built `.js`
377
+ * artifact exists for that module. If so, re-transpile the `.ts` source
378
+ * with companions inlined and write the `.js` back.
379
+ *
380
+ * Best-effort: silently skips if `transpile()` is not implemented.
381
+ */
382
+ private async retranspileIfNeeded(
383
+ filePath: string,
384
+ dir: string,
385
+ kind: 'route' | 'widget' | 'element',
386
+ ): Promise<void> {
387
+ // Only act on source/companion files, not the .js output itself
388
+ if (filePath.endsWith('.js')) return;
389
+
390
+ const relativePath = filePath.slice(dir.length + 1);
391
+ const parts = relativePath.split('/');
392
+ const filename = parts[parts.length - 1];
393
+
394
+ // Determine the module base name and the .js output path
395
+ let jsPath: string;
396
+ if (kind === 'route') {
397
+ const match = filename.match(/^(.+?)\.(page)\.(ts|html|md|css)$/);
398
+ if (!match) return;
399
+ const [, name] = match;
400
+ jsPath = `${dir}/${parts.slice(0, -1).join('/')}${parts.length > 1 ? '/' : ''}${name}.page.js`;
401
+ } else if (kind === 'widget') {
402
+ const match = filename.match(/^(.+?)\.(widget)\.(ts|html|md|css)$/);
403
+ if (!match) return;
404
+ const [, name] = match;
405
+ jsPath = `${dir}/${name}/${name}.widget.js`;
406
+ } else {
407
+ const match = filename.match(/^(.+?)\.(element)\.ts$/);
408
+ if (!match) return;
409
+ const [, name] = match;
410
+ jsPath = `${dir}/${name}/${name}.element.js`;
411
+ }
412
+
413
+ // Check if the .js artifact exists
414
+ const jsResponse = await this.handle(jsPath);
415
+ if (jsResponse.status === 404) return;
416
+
417
+ // Read the .ts source
418
+ const tsPath = jsPath.replace(/\.js$/, '.ts');
419
+ let tsSource: string;
420
+ try {
421
+ tsSource = await this.query(tsPath, { as: 'text' });
422
+ } catch {
423
+ return; // .ts doesn't exist (maybe .js was hand-written)
424
+ }
425
+
426
+ // Collect companion files and inline them
427
+ const companionExts = kind === 'element' ? [] : ['html', 'md', 'css'];
428
+ const files: Record<string, string> = {};
429
+ for (const ext of companionExts) {
430
+ const companionPath = tsPath.replace(/\.ts$/, `.${ext}`);
431
+ try {
432
+ files[ext] = await this.query(companionPath, { as: 'text' });
433
+ } catch {
434
+ // companion doesn't exist — skip
435
+ }
436
+ }
437
+
438
+ // Transpile
439
+ let jsCode: string;
440
+ try {
441
+ jsCode = await this.transpile(tsSource);
442
+ } catch {
443
+ return; // transpile not implemented — skip silently
444
+ }
445
+
446
+ // Append __files export if there are companions
447
+ if (Object.keys(files).length > 0) {
448
+ const entries = Object.entries(files)
449
+ .map(([k, v]) => `${k}: \`${v.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$/g, '\\$')}\``)
450
+ .join(', ');
451
+ jsCode += `\nexport const __files = { ${entries} };\n`;
452
+ }
453
+
454
+ await this.handle(jsPath, { method: 'PUT', body: jsCode });
455
+ }
456
+
151
457
  /**
152
458
  * Dynamically import a module from this runtime's storage.
153
459
  * Used by the server for SSR imports of `.page.ts` and `.widget.ts` files.
@@ -164,15 +470,123 @@ export abstract class Runtime {
164
470
  throw new Error(`transpile not implemented for ${this.constructor.name}`);
165
471
  }
166
472
 
473
+ /**
474
+ * Parse a widget file path and merge it into the stored manifest.
475
+ * Reads the current manifest, upserts the entry, writes it back.
476
+ */
477
+ private async mergeWidgetIntoManifest(
478
+ filePath: string,
479
+ widgetsDir: string,
480
+ ): Promise<void> {
481
+ const relativePath = filePath.slice(widgetsDir.length + 1);
482
+ const parts = relativePath.split('/');
483
+ if (parts.length !== 2) return; // must be widgets/{name}/{file}
484
+
485
+ const [dirName, filename] = parts;
486
+
487
+ // Only act on .widget.{ts,js,html,md,css} files
488
+ const match = filename.match(/^(.+?)\.widget\.(ts|js|html|md|css)$/);
489
+ if (!match) return;
490
+
491
+ const [, name, ext] = match;
492
+ if (name !== dirName) return; // filename must match directory
493
+
494
+ const response = await this.handle(WIDGETS_MANIFEST_PATH);
495
+ const entries: WidgetManifestEntry[] = response.status === 404
496
+ ? []
497
+ : await response.json();
498
+
499
+ const prefix = widgetsDir.replace(/^\//, '');
500
+
501
+ if (ext === 'ts' || ext === 'js') {
502
+ // Module file — upsert the entry
503
+ let entry = entries.find((e) => e.name === name);
504
+ if (!entry) {
505
+ entry = {
506
+ name,
507
+ modulePath: `${prefix}/${name}/${filename}`,
508
+ tagName: `widget-${name}`,
509
+ };
510
+ entries.push(entry);
511
+ entries.sort((a, b) => a.name.localeCompare(b.name));
512
+ } else {
513
+ entry.modulePath = `${prefix}/${name}/${filename}`;
514
+ }
515
+ } else {
516
+ // Companion file — update files on existing entry
517
+ const entry = entries.find((e) => e.name === name);
518
+ if (!entry) return; // no module yet, companion alone is not enough
519
+ entry.files ??= {};
520
+ (entry.files as Record<string, string>)[ext] = `${prefix}/${name}/${filename}`;
521
+ }
522
+
523
+ this.widgetsManifestCache = null;
524
+ await this.handle(WIDGETS_MANIFEST_PATH, {
525
+ method: 'PUT',
526
+ body: JSON.stringify(entries),
527
+ });
528
+ }
529
+
530
+ /**
531
+ * Parse an element file path and merge it into the stored manifest.
532
+ * Reads the current manifest, upserts the entry, writes it back.
533
+ */
534
+ private async mergeElementIntoManifest(
535
+ filePath: string,
536
+ elementsDir: string,
537
+ ): Promise<void> {
538
+ const relativePath = filePath.slice(elementsDir.length + 1);
539
+ const parts = relativePath.split('/');
540
+ if (parts.length !== 2) return;
541
+
542
+ const [dirName, filename] = parts;
543
+
544
+ const match = filename.match(/^(.+?)\.element\.(ts|js)$/);
545
+ if (!match) return;
546
+
547
+ const [, name] = match;
548
+ if (name !== dirName) return;
549
+
550
+ // Custom element names must contain a hyphen
551
+ if (!name.includes('-')) return;
552
+
553
+ const response = await this.handle(ELEMENTS_MANIFEST_PATH);
554
+ const entries: ElementManifestEntry[] = response.status === 404
555
+ ? []
556
+ : await response.json();
557
+
558
+ const prefix = elementsDir.replace(/^\//, '');
559
+ let entry = entries.find((e) => e.name === name);
560
+ if (!entry) {
561
+ entry = {
562
+ name,
563
+ modulePath: `${prefix}/${name}/${filename}`,
564
+ tagName: name,
565
+ };
566
+ entries.push(entry);
567
+ entries.sort((a, b) => a.name.localeCompare(b.name));
568
+ } else {
569
+ entry.modulePath = `${prefix}/${name}/${filename}`;
570
+ }
571
+
572
+ this.elementsManifestCache = null;
573
+ await this.handle(ELEMENTS_MANIFEST_PATH, {
574
+ method: 'PUT',
575
+ body: JSON.stringify(entries),
576
+ });
577
+ }
578
+
167
579
  // ── Manifest resolution ─────────────────────────────────────────────
168
580
 
169
581
  private routesManifestCache: Response | null = null;
170
582
  private widgetsManifestCache: Response | null = null;
583
+ private elementsManifestCache: Response | null = null;
171
584
 
172
585
  /** Clear cached manifests so the next query triggers a fresh scan. */
173
586
  invalidateManifests(): void {
174
587
  this.routesManifestCache = null;
175
588
  this.widgetsManifestCache = null;
589
+ this.elementsManifestCache = null;
176
590
  }
177
591
 
178
592
  /**
@@ -215,6 +629,25 @@ export abstract class Runtime {
215
629
  return this.widgetsManifestCache.clone();
216
630
  }
217
631
 
632
+ /**
633
+ * Resolve the elements manifest. Called when the concrete runtime returns
634
+ * 404 for ELEMENTS_MANIFEST_PATH. Scans `config.elementsDir` (or default).
635
+ */
636
+ async resolveElementsManifest(): Promise<Response> {
637
+ if (this.elementsManifestCache) return this.elementsManifestCache.clone();
638
+
639
+ const elementsDir = this.config.elementsDir ?? DEFAULT_ELEMENTS_DIR;
640
+
641
+ const dirResponse = await this.query(elementsDir + '/');
642
+ if (dirResponse.status === 404) {
643
+ return new Response('Not Found', { status: 404 });
644
+ }
645
+
646
+ const entries = await this.scanElements(elementsDir, elementsDir.replace(/^\//, ''));
647
+ this.elementsManifestCache = Response.json(entries);
648
+ return this.elementsManifestCache.clone();
649
+ }
650
+
218
651
  // ── Scanning ──────────────────────────────────────────────────────────
219
652
 
220
653
  protected async *walkDirectory(dir: string): AsyncGenerator<string> {
@@ -344,4 +777,46 @@ export abstract class Runtime {
344
777
  entries.sort((a, b) => a.name.localeCompare(b.name));
345
778
  return entries;
346
779
  }
780
+
781
+ protected async scanElements(
782
+ elementsDir: string,
783
+ pathPrefix?: string,
784
+ ): Promise<ElementManifestEntry[]> {
785
+ const entries: ElementManifestEntry[] = [];
786
+
787
+ const trailingDir = elementsDir.endsWith('/') ? elementsDir : elementsDir + '/';
788
+ const response = await this.query(trailingDir);
789
+ const listing: string[] = await response.json();
790
+
791
+ for (const item of listing) {
792
+ if (!item.endsWith('/')) continue;
793
+
794
+ const name = item.slice(0, -1);
795
+
796
+ // Custom element names must contain a hyphen (web spec requirement)
797
+ if (!name.includes('-')) {
798
+ console.warn(`[emroute] Skipping element "${name}": custom element names must contain a hyphen (e.g. "my-element")`);
799
+ continue;
800
+ }
801
+
802
+ // Try .element.ts first, then .element.js
803
+ let moduleFile = `${name}.element.ts`;
804
+ let modulePath = `${trailingDir}${name}/${moduleFile}`;
805
+ if ((await this.query(modulePath)).status === 404) {
806
+ moduleFile = `${name}.element.js`;
807
+ modulePath = `${trailingDir}${name}/${moduleFile}`;
808
+ if ((await this.query(modulePath)).status === 404) continue;
809
+ }
810
+
811
+ const prefix = pathPrefix ? `${pathPrefix}/` : '';
812
+ entries.push({
813
+ name,
814
+ modulePath: `${prefix}${name}/${moduleFile}`,
815
+ tagName: name,
816
+ });
817
+ }
818
+
819
+ entries.sort((a, b) => a.name.localeCompare(b.name));
820
+ return entries;
821
+ }
347
822
  }
@@ -1,4 +1,4 @@
1
- import { stat, readdir, mkdir } from 'node:fs/promises';
1
+ import { stat, readdir, mkdir, unlink } from 'node:fs/promises';
2
2
  import { resolve } from 'node:path';
3
3
  import {
4
4
  CONTENT_TYPES,
@@ -8,6 +8,7 @@ import {
8
8
  Runtime,
9
9
  type RuntimeConfig,
10
10
  WIDGETS_MANIFEST_PATH,
11
+ ELEMENTS_MANIFEST_PATH,
11
12
  } from '../../abstract.runtime.ts';
12
13
 
13
14
  export class BunFsRuntime extends Runtime {
@@ -29,6 +30,8 @@ export class BunFsRuntime extends Runtime {
29
30
  switch (method) {
30
31
  case 'PUT':
31
32
  return this.write(path, body);
33
+ case 'DELETE':
34
+ return this.delete(path);
32
35
  default:
33
36
  return this.read(path);
34
37
  }
@@ -99,6 +102,7 @@ export class BunFsRuntime extends Runtime {
99
102
  const pathname = path.slice(this.root.length);
100
103
  if (pathname === ROUTES_MANIFEST_PATH) return this.resolveRoutesManifest();
101
104
  if (pathname === WIDGETS_MANIFEST_PATH) return this.resolveWidgetsManifest();
105
+ if (pathname === ELEMENTS_MANIFEST_PATH) return this.resolveElementsManifest();
102
106
  return new Response('Not Found', { status: 404 });
103
107
  }
104
108
  return new Response(`Internal Error: ${error}`, { status: 500 });
@@ -128,6 +132,18 @@ export class BunFsRuntime extends Runtime {
128
132
  }
129
133
  }
130
134
 
135
+ private async delete(path: string): Promise<Response> {
136
+ try {
137
+ await unlink(path);
138
+ return new Response(null, { status: 204 });
139
+ } catch (error) {
140
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
141
+ return new Response('Not Found', { status: 404 });
142
+ }
143
+ return new Response(`Delete failed: ${error}`, { status: 500 });
144
+ }
145
+ }
146
+
131
147
  override loadModule(path: string): Promise<unknown> {
132
148
  return import(this.root + path);
133
149
  }
@@ -7,12 +7,14 @@ import {
7
7
  Runtime,
8
8
  type RuntimeConfig,
9
9
  WIDGETS_MANIFEST_PATH,
10
+ ELEMENTS_MANIFEST_PATH,
10
11
  } from '../../abstract.runtime.ts';
11
12
 
12
13
  export class BunSqliteRuntime extends Runtime {
13
14
  private readonly db: Database;
14
15
  private readonly stmtGet: ReturnType<Database['prepare']>;
15
16
  private readonly stmtSet: ReturnType<Database['prepare']>;
17
+ private readonly stmtDel: ReturnType<Database['prepare']>;
16
18
  private readonly stmtList: ReturnType<Database['prepare']>;
17
19
  private readonly stmtHas: ReturnType<Database['prepare']>;
18
20
 
@@ -28,6 +30,7 @@ export class BunSqliteRuntime extends Runtime {
28
30
  `);
29
31
  this.stmtGet = this.db.prepare('SELECT data, mtime FROM files WHERE path = ?');
30
32
  this.stmtSet = this.db.prepare('INSERT OR REPLACE INTO files (path, data, mtime) VALUES (?, ?, ?)');
33
+ this.stmtDel = this.db.prepare('DELETE FROM files WHERE path = ?');
31
34
  this.stmtList = this.db.prepare("SELECT DISTINCT path FROM files WHERE path LIKE ? || '%'");
32
35
  this.stmtHas = this.db.prepare("SELECT 1 FROM files WHERE path LIKE ? || '%' LIMIT 1");
33
36
  }
@@ -41,6 +44,8 @@ export class BunSqliteRuntime extends Runtime {
41
44
  switch (method) {
42
45
  case 'PUT':
43
46
  return this.write(pathname, body);
47
+ case 'DELETE':
48
+ return this.delete(pathname);
44
49
  default:
45
50
  return this.read(pathname);
46
51
  }
@@ -108,6 +113,7 @@ export class BunSqliteRuntime extends Runtime {
108
113
  if (!row) {
109
114
  if (path === ROUTES_MANIFEST_PATH) return this.resolveRoutesManifest();
110
115
  if (path === WIDGETS_MANIFEST_PATH) return this.resolveWidgetsManifest();
116
+ if (path === ELEMENTS_MANIFEST_PATH) return this.resolveElementsManifest();
111
117
  return new Response('Not Found', { status: 404 });
112
118
  }
113
119
 
@@ -130,6 +136,11 @@ export class BunSqliteRuntime extends Runtime {
130
136
  return new Response(null, { status: 204 });
131
137
  }
132
138
 
139
+ private async delete(path: string): Promise<Response> {
140
+ this.stmtDel.run(path);
141
+ return new Response(null, { status: 204 });
142
+ }
143
+
133
144
  private listChildren(prefix: string): string[] {
134
145
  const rows = this.stmtList.all(prefix) as { path: string }[];
135
146
  const entries = new Set<string>();
@@ -2,9 +2,9 @@
2
2
  * Fetch Runtime
3
3
  *
4
4
  * Browser-compatible Runtime that delegates all reads to a remote server
5
- * via `fetch()`. Used by the thin client in `root` mode — same
6
- * `createEmrouteServer` runs in the browser, but the Runtime fetches
7
- * files from the real server instead of reading from disk.
5
+ * via `fetch()`. Used by EmrouteApp in `root` mode — same Emroute
6
+ * instance runs in the browser, but the Runtime fetches files from the
7
+ * real server instead of reading from disk.
8
8
  *
9
9
  * No bundling, no transpiling, no filesystem access.
10
10
  * No directory scanning — the remote server already has manifests.
@@ -17,8 +17,8 @@
17
17
  * @see https://www.sitemaps.org/protocol.html
18
18
  */
19
19
 
20
- import { escapeHtml } from '../src/util/html.util.ts';
21
- import type { RouteNode } from '../src/type/route-tree.type.ts';
20
+ import { escapeHtml } from '../core/util/html.util.ts';
21
+ import type { RouteNode } from '../core/type/route-tree.type.ts';
22
22
 
23
23
  /** Valid changefreq values per sitemaps.org protocol. */
24
24
  export type Changefreq =