@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,441 @@
1
+ import {
2
+ filePathToPattern,
3
+ getPageFileType,
4
+ getRouteType,
5
+ sortRoutesBySpecificity,
6
+ } from '../src/route/route.matcher.ts';
7
+ import type {
8
+ ErrorBoundary,
9
+ RouteConfig,
10
+ RouteFiles,
11
+ RoutesManifest,
12
+ } from '../src/type/route.type.ts';
13
+ import type { WidgetManifestEntry } from '../src/type/widget.type.ts';
14
+
15
+ export const CONTENT_TYPES: Map<string, string> = new Map<string, string>([
16
+ ['.html', 'text/html; charset=utf-8'],
17
+ ['.css', 'text/css; charset=utf-8'],
18
+ ['.js', 'application/javascript; charset=utf-8'],
19
+ ['.mjs', 'application/javascript; charset=utf-8'],
20
+ ['.ts', 'text/typescript; charset=utf-8'],
21
+ ['.json', 'application/json; charset=utf-8'],
22
+ ['.md', 'text/plain; charset=utf-8'],
23
+ ['.txt', 'text/plain; charset=utf-8'],
24
+ ['.wasm', 'application/wasm'],
25
+ ['.map', 'application/json; charset=utf-8'],
26
+ ['.png', 'image/png'],
27
+ ['.jpg', 'image/jpeg'],
28
+ ['.jpeg', 'image/jpeg'],
29
+ ['.gif', 'image/gif'],
30
+ ['.svg', 'image/svg+xml'],
31
+ ['.ico', 'image/x-icon'],
32
+ ['.webp', 'image/webp'],
33
+ ['.avif', 'image/avif'],
34
+ ['.woff', 'font/woff'],
35
+ ['.woff2', 'font/woff2'],
36
+ ['.ttf', 'font/ttf'],
37
+ ]);
38
+
39
+ export type FetchParams = Parameters<typeof fetch>;
40
+ export type FetchReturn = ReturnType<typeof fetch>;
41
+
42
+ export const DEFAULT_ROUTES_DIR = '/routes';
43
+ export const DEFAULT_WIDGETS_DIR = '/widgets';
44
+ export const ROUTES_MANIFEST_PATH = '/routes.manifest.json';
45
+ export const WIDGETS_MANIFEST_PATH = '/widgets.manifest.json';
46
+
47
+ export const EMROUTE_EXTERNALS = [
48
+ '@emkodev/emroute/spa',
49
+ '@emkodev/emroute/overlay',
50
+ '@emkodev/emroute',
51
+ ] as const;
52
+
53
+ export interface RuntimeConfig {
54
+ routesDir?: string;
55
+ widgetsDir?: string;
56
+ /** SPA mode. When 'none', bundling is skipped entirely. */
57
+ spa?: 'none' | 'leaf' | 'root' | 'only';
58
+ /** Consumer's SPA entry point (e.g. '/main.ts'). Skips app bundle when absent. */
59
+ entryPoint?: string;
60
+ bundlePaths?: {
61
+ emroute: string;
62
+ app: string;
63
+ widgets?: string;
64
+ };
65
+ }
66
+
67
+ /**
68
+ * Abstract resource provider. Speaks Request/Response (ADR-1).
69
+ *
70
+ * Three access patterns:
71
+ * - `handle()` — raw passthrough, server forwards browser requests as-is.
72
+ * - `query()` — read. Returns Response, or string when `{ as: "text" }`.
73
+ * - `command()` — write (PUT by default, override with `{ method }` in options).
74
+ *
75
+ * Includes manifest resolution: when `query(ROUTES_MANIFEST_PATH)` or
76
+ * `query(WIDGETS_MANIFEST_PATH)` returns 404, the runtime scans the
77
+ * configured directories and caches the result.
78
+ */
79
+ export abstract class Runtime {
80
+ constructor(readonly config: RuntimeConfig = {}) {
81
+ this.config = config;
82
+ }
83
+ /** Concrete runtimes implement this. Accepts the same args as `fetch()`. */
84
+ abstract handle(resource: FetchParams[0], init?: FetchParams[1]): FetchReturn;
85
+
86
+ /**
87
+ * Read with `{ as: "text" }` — skip metadata, return contents only.
88
+ * Semantically equivalent to `Accept: text/plain`; `as` exists for type safety.
89
+ */
90
+ abstract query(
91
+ resource: FetchParams[0],
92
+ options: FetchParams[1] & { as: 'text' },
93
+ ): Promise<string>;
94
+ /** Read — returns full Response with headers, status, body. */
95
+ abstract query(
96
+ resource: FetchParams[0],
97
+ options?: FetchParams[1],
98
+ ): FetchReturn;
99
+
100
+ /** Write. Defaults to PUT; pass `{ method: "DELETE" }` etc. to override. */
101
+ command(resource: FetchParams[0], options?: FetchParams[1]): FetchReturn {
102
+ return this.handle(resource, { method: 'PUT', ...options });
103
+ }
104
+
105
+ /**
106
+ * Dynamically import a module from this runtime's storage.
107
+ * Used by the server for SSR imports of `.page.ts` and `.widget.ts` files.
108
+ */
109
+ loadModule(_path: string): Promise<unknown> {
110
+ throw new Error(`loadModule not implemented for ${this.constructor.name}`);
111
+ }
112
+
113
+ static transpile(_ts: string): Promise<string> {
114
+ throw new Error('Not implemented');
115
+ }
116
+
117
+ /**
118
+ * Build client bundles. Called by the server after manifests are written.
119
+ * No-op by default — override in runtimes that support bundling.
120
+ */
121
+ bundle(): Promise<void> {
122
+ return Promise.resolve();
123
+ }
124
+
125
+ /**
126
+ * Generate an HTML shell (`index.html`) if one doesn't already exist.
127
+ * Writes through `this.command()` so it works for any runtime.
128
+ */
129
+ protected async writeShell(
130
+ paths: { emroute: string; app: string; widgets?: string },
131
+ ): Promise<void> {
132
+ if ((await this.query('/index.html')).status !== 404) return;
133
+
134
+ const imports: Record<(typeof EMROUTE_EXTERNALS)[number], string> = Object.fromEntries(
135
+ EMROUTE_EXTERNALS.map((pkg) => [pkg, paths.emroute]),
136
+ ) as Record<(typeof EMROUTE_EXTERNALS)[number], string>;
137
+ const importMap = JSON.stringify({ imports }, null, 2);
138
+
139
+ const scripts = [
140
+ `<script type="importmap">\n${importMap}\n </script>`,
141
+ ];
142
+ if (this.config.entryPoint) {
143
+ scripts.push(`<script type="module" src="${paths.app}"></script>`);
144
+ }
145
+
146
+ const html = `<!DOCTYPE html>
147
+ <html>
148
+ <head>
149
+ <meta charset="utf-8">
150
+ <meta name="viewport" content="width=device-width, initial-scale=1">
151
+ <title>emroute</title>
152
+ <style>@view-transition { navigation: auto; } router-slot { display: contents; }</style>
153
+ </head>
154
+ <body>
155
+ <router-slot></router-slot>
156
+ ${scripts.join('\n ')}
157
+ </body>
158
+ </html>`;
159
+
160
+ await this.command('/index.html', { body: html });
161
+ }
162
+
163
+ static compress(
164
+ _data: Uint8Array,
165
+ _encoding: 'br' | 'gzip',
166
+ ): Promise<Uint8Array> {
167
+ throw new Error('Not implemented');
168
+ }
169
+
170
+ /** Stop the bundler subprocess if running. No-op by default. */
171
+ static stopBundler(): Promise<void> {
172
+ return Promise.resolve();
173
+ }
174
+
175
+ // ── Manifest resolution ─────────────────────────────────────────────
176
+
177
+ private routesManifestCache: Response | null = null;
178
+ private widgetsManifestCache: Response | null = null;
179
+
180
+ /** Clear cached manifests so the next query triggers a fresh scan. */
181
+ invalidateManifests(): void {
182
+ this.routesManifestCache = null;
183
+ this.widgetsManifestCache = null;
184
+ }
185
+
186
+ /**
187
+ * Resolve the routes manifest. Called when the concrete runtime returns
188
+ * 404 for ROUTES_MANIFEST_PATH. Scans `config.routesDir` (or default).
189
+ */
190
+ async resolveRoutesManifest(): Promise<Response> {
191
+ if (this.routesManifestCache) return this.routesManifestCache.clone();
192
+
193
+ const routesDir = this.config.routesDir ?? DEFAULT_ROUTES_DIR;
194
+
195
+ // Check if directory exists by querying it
196
+ const dirResponse = await this.query(routesDir + '/');
197
+ if (dirResponse.status === 404) {
198
+ return new Response('Not Found', { status: 404 });
199
+ }
200
+
201
+ const { warnings, ...manifest } = await this.scanRoutes(routesDir);
202
+ for (const w of warnings) console.warn(w);
203
+
204
+ const json = {
205
+ routes: manifest.routes,
206
+ errorBoundaries: manifest.errorBoundaries,
207
+ statusPages: [...manifest.statusPages.entries()],
208
+ errorHandler: manifest.errorHandler,
209
+ };
210
+
211
+ this.routesManifestCache = Response.json(json);
212
+ return this.routesManifestCache.clone();
213
+ }
214
+
215
+ /**
216
+ * Resolve the widgets manifest. Called when the concrete runtime returns
217
+ * 404 for WIDGETS_MANIFEST_PATH. Scans `config.widgetsDir` (or default).
218
+ */
219
+ async resolveWidgetsManifest(): Promise<Response> {
220
+ if (this.widgetsManifestCache) return this.widgetsManifestCache.clone();
221
+
222
+ const widgetsDir = this.config.widgetsDir ?? DEFAULT_WIDGETS_DIR;
223
+
224
+ const dirResponse = await this.query(widgetsDir + '/');
225
+ if (dirResponse.status === 404) {
226
+ return new Response('Not Found', { status: 404 });
227
+ }
228
+
229
+ const entries = await this.scanWidgets(widgetsDir, widgetsDir.replace(/^\//, ''));
230
+ this.widgetsManifestCache = Response.json(entries);
231
+ return this.widgetsManifestCache.clone();
232
+ }
233
+
234
+ // ── Scanning ──────────────────────────────────────────────────────────
235
+
236
+ protected async *walkDirectory(dir: string): AsyncGenerator<string> {
237
+ const trailingDir = dir.endsWith('/') ? dir : dir + '/';
238
+ const response = await this.query(trailingDir);
239
+ const entries: string[] = await response.json();
240
+
241
+ for (const entry of entries) {
242
+ const path = `${trailingDir}${entry}`;
243
+ if (entry.endsWith('/')) {
244
+ yield* this.walkDirectory(path);
245
+ } else {
246
+ yield path;
247
+ }
248
+ }
249
+ }
250
+
251
+ protected async scanRoutes(routesDir: string): Promise<RoutesManifest & { warnings: string[] }> {
252
+ const pageFiles: Array<{
253
+ path: string;
254
+ pattern: string;
255
+ fileType: 'ts' | 'html' | 'md' | 'css';
256
+ }> = [];
257
+ const redirects: RouteConfig[] = [];
258
+ const errorBoundaries: ErrorBoundary[] = [];
259
+ const statusPages = new Map<number, RouteConfig>();
260
+ let errorHandler: RouteConfig | undefined;
261
+
262
+ const allFiles: string[] = [];
263
+ for await (const file of this.walkDirectory(routesDir)) {
264
+ allFiles.push(file);
265
+ }
266
+
267
+ for (const filePath of allFiles) {
268
+ const relativePath = filePath.replace(`${routesDir}/`, '');
269
+ const filename = relativePath.split('/').pop() ?? '';
270
+
271
+ if (filename === 'index.error.ts' && relativePath === 'index.error.ts') {
272
+ errorHandler = {
273
+ pattern: '/',
274
+ type: 'error',
275
+ modulePath: filePath,
276
+ };
277
+ continue;
278
+ }
279
+
280
+ const cssFileType = getPageFileType(filename);
281
+ if (cssFileType === 'css') {
282
+ const pattern = filePathToPattern(relativePath);
283
+ pageFiles.push({ path: filePath, pattern, fileType: 'css' });
284
+ continue;
285
+ }
286
+
287
+ const routeType = getRouteType(filename);
288
+ if (!routeType) continue;
289
+
290
+ const statusMatch = filename.match(/^(\d{3})\.page\.(ts|html|md)$/);
291
+ if (statusMatch) {
292
+ const statusCode = parseInt(statusMatch[1], 10);
293
+ const fileType = getPageFileType(filename);
294
+ if (fileType) {
295
+ const existing = statusPages.get(statusCode);
296
+ if (existing) {
297
+ existing.files ??= {};
298
+ existing.files[fileType] = filePath;
299
+ existing.modulePath = existing.files.ts ?? existing.files.html ?? existing.files.md ??
300
+ '';
301
+ } else {
302
+ const files: RouteFiles = { [fileType]: filePath };
303
+ statusPages.set(statusCode, {
304
+ pattern: `/${statusCode}`,
305
+ type: 'page',
306
+ modulePath: filePath,
307
+ statusCode,
308
+ files,
309
+ });
310
+ }
311
+ }
312
+ continue;
313
+ }
314
+
315
+ const pattern = filePathToPattern(relativePath);
316
+
317
+ if (routeType === 'error') {
318
+ const boundaryPattern = pattern.replace(/\/[^/]+$/, '') || '/';
319
+ errorBoundaries.push({ pattern: boundaryPattern, modulePath: filePath });
320
+ continue;
321
+ }
322
+
323
+ if (routeType === 'redirect') {
324
+ redirects.push({ pattern, type: 'redirect', modulePath: filePath });
325
+ continue;
326
+ }
327
+
328
+ const fileType = getPageFileType(filename);
329
+ if (fileType) {
330
+ pageFiles.push({ path: filePath, pattern, fileType });
331
+ }
332
+ }
333
+
334
+ // Group files by pattern
335
+ const groups = new Map<string, { pattern: string; files: RouteFiles; parent?: string }>();
336
+ for (const { path, pattern, fileType } of pageFiles) {
337
+ let group = groups.get(pattern);
338
+ if (!group) {
339
+ group = { pattern, files: {} };
340
+ const segments = pattern.split('/').filter(Boolean);
341
+ if (segments.length > 1) {
342
+ group.parent = '/' + segments.slice(0, -1).join('/');
343
+ }
344
+ groups.set(pattern, group);
345
+ }
346
+ const existing = group.files[fileType];
347
+ if (existing?.includes('/index.page.') && !path.includes('/index.page.')) {
348
+ continue;
349
+ }
350
+ group.files[fileType] = path;
351
+ }
352
+
353
+ // Detect collisions
354
+ const warnings: string[] = [];
355
+ for (const [pattern, group] of groups) {
356
+ const filePaths = Object.values(group.files).filter(Boolean);
357
+ const hasIndex = filePaths.some((p) => p?.includes('/index.page.'));
358
+ const hasFlat = filePaths.some((p) => p && !p.includes('/index.page.'));
359
+ if (hasIndex && hasFlat) {
360
+ warnings.push(
361
+ `Warning: Mixed file structure for ${pattern}:\n` +
362
+ filePaths.map((p) => ` ${p}`).join('\n') +
363
+ `\n Both folder/index and flat files detected`,
364
+ );
365
+ }
366
+ }
367
+
368
+ // Convert groups to RouteConfig array
369
+ const routes: RouteConfig[] = [];
370
+ for (const [_, group] of groups) {
371
+ const modulePath = group.files.ts ?? group.files.html ?? group.files.md ?? '';
372
+ if (!modulePath) continue;
373
+ const route: RouteConfig = {
374
+ pattern: group.pattern,
375
+ type: 'page',
376
+ modulePath,
377
+ files: group.files,
378
+ };
379
+ if (group.parent) route.parent = group.parent;
380
+ routes.push(route);
381
+ }
382
+
383
+ routes.push(...redirects);
384
+ const sortedRoutes = sortRoutesBySpecificity(routes);
385
+
386
+ return {
387
+ routes: sortedRoutes,
388
+ errorBoundaries,
389
+ statusPages,
390
+ errorHandler,
391
+ warnings,
392
+ };
393
+ }
394
+
395
+ protected async scanWidgets(
396
+ widgetsDir: string,
397
+ pathPrefix?: string,
398
+ ): Promise<WidgetManifestEntry[]> {
399
+ const COMPANION_EXTENSIONS = ['html', 'md', 'css'] as const;
400
+ const WIDGET_FILE_SUFFIX = '.widget.ts';
401
+ const entries: WidgetManifestEntry[] = [];
402
+
403
+ const trailingDir = widgetsDir.endsWith('/') ? widgetsDir : widgetsDir + '/';
404
+ const response = await this.query(trailingDir);
405
+ const listing: string[] = await response.json();
406
+
407
+ for (const item of listing) {
408
+ if (!item.endsWith('/')) continue;
409
+
410
+ const name = item.slice(0, -1);
411
+ const moduleFile = `${name}${WIDGET_FILE_SUFFIX}`;
412
+ const modulePath = `${trailingDir}${name}/${moduleFile}`;
413
+
414
+ if ((await this.query(modulePath)).status === 404) continue;
415
+
416
+ const prefix = pathPrefix ? `${pathPrefix}/` : '';
417
+ const entry: WidgetManifestEntry = {
418
+ name,
419
+ modulePath: `${prefix}${name}/${moduleFile}`,
420
+ tagName: `widget-${name}`,
421
+ };
422
+
423
+ const files: { html?: string; md?: string; css?: string } = {};
424
+ let hasFiles = false;
425
+ for (const ext of COMPANION_EXTENSIONS) {
426
+ const companionFile = `${name}.widget.${ext}`;
427
+ const companionPath = `${trailingDir}${name}/${companionFile}`;
428
+ if ((await this.query(companionPath)).status !== 404) {
429
+ files[ext] = `${prefix}${name}/${companionFile}`;
430
+ hasFiles = true;
431
+ }
432
+ }
433
+
434
+ if (hasFiles) entry.files = files;
435
+ entries.push(entry);
436
+ }
437
+
438
+ entries.sort((a, b) => a.name.localeCompare(b.name));
439
+ return entries;
440
+ }
441
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * esbuild Runtime Loader Plugin
3
+ *
4
+ * Intercepts file resolution so esbuild reads source files through the
5
+ * runtime's `query()` method instead of the filesystem. This allows
6
+ * bundling to work with any Runtime implementation (filesystem, SQLite,
7
+ * in-memory, etc.).
8
+ *
9
+ * The plugin intercepts `.ts` and `.js` imports that resolve under the
10
+ * virtual root and loads their contents from the runtime.
11
+ */
12
+
13
+ import type { Runtime } from '../../runtime/abstract.runtime.ts';
14
+
15
+ interface RuntimeLoaderOptions {
16
+ runtime: Runtime;
17
+ /**
18
+ * The filesystem root that esbuild would normally resolve paths against.
19
+ * Paths starting with this prefix are stripped to produce runtime paths
20
+ * (e.g. `/app/root/routes/index.page.ts` → `/routes/index.page.ts`).
21
+ * For runtimes without a filesystem root, pass an empty string.
22
+ */
23
+ root: string;
24
+ }
25
+
26
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
27
+ type EsbuildPlugin = any;
28
+
29
+ export function createRuntimeLoaderPlugin(options: RuntimeLoaderOptions): EsbuildPlugin {
30
+ const { runtime, root } = options;
31
+
32
+ return {
33
+ name: 'emroute-runtime-loader',
34
+
35
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
36
+ setup(build: any) {
37
+ // Intercept .ts and .js file resolution — redirect to 'runtime' namespace
38
+ // Only intercepts files that resolve under the runtime root.
39
+ build.onResolve(
40
+ { filter: /\.[tj]s$/ },
41
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
42
+ (args: any) => {
43
+ // Skip bare specifiers (node_modules, packages)
44
+ if (!args.path.startsWith('.') && !args.path.startsWith('/')) return undefined;
45
+ // Skip if already in a custom namespace (except 'runtime' for nested imports)
46
+ // Entry points have namespace '' (empty string)
47
+ if (args.namespace !== 'file' && args.namespace !== 'runtime' && args.namespace !== '') return undefined;
48
+
49
+ let absPath: string;
50
+ if (args.path.startsWith('/')) {
51
+ absPath = args.path;
52
+ } else if (args.resolveDir) {
53
+ absPath = args.resolveDir + '/' + args.path;
54
+ } else {
55
+ return undefined;
56
+ }
57
+
58
+ // Normalize ../ and ./ segments
59
+ const parts = absPath.split('/');
60
+ const normalized: string[] = [];
61
+ for (const part of parts) {
62
+ if (part === '..') normalized.pop();
63
+ else if (part !== '.' && part !== '') normalized.push(part);
64
+ }
65
+ absPath = '/' + normalized.join('/');
66
+
67
+ // Only intercept files under the runtime root
68
+ if (root && !absPath.startsWith(root + '/')) return undefined;
69
+
70
+ return { path: absPath, namespace: 'runtime' };
71
+ },
72
+ );
73
+
74
+ // Load file contents from the runtime
75
+ build.onLoad(
76
+ { filter: /.*/, namespace: 'runtime' },
77
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
78
+ async (args: any) => {
79
+ // Strip root prefix to get runtime path (e.g. /app/root/routes/x.ts → /routes/x.ts)
80
+ const runtimePath = root && args.path.startsWith(root)
81
+ ? args.path.slice(root.length)
82
+ : args.path;
83
+
84
+ const contents = await runtime.query(runtimePath, { as: 'text' });
85
+ const ext = args.path.slice(args.path.lastIndexOf('.') + 1);
86
+ const loader = ext === 'ts' ? 'ts' : 'js';
87
+ const resolveDir = args.path.slice(0, args.path.lastIndexOf('/'));
88
+
89
+ return { contents, loader, resolveDir };
90
+ },
91
+ );
92
+ },
93
+ };
94
+ }