@withl5e/l5e 0.1.0-alpha.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 (84) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +24 -0
  3. package/dist/action.js +10 -0
  4. package/dist/action.js.map +1 -0
  5. package/dist/client-D67hK4Yy.js +9 -0
  6. package/dist/client-D67hK4Yy.js.map +1 -0
  7. package/dist/entry-server-Ckh6zfgm.js +258 -0
  8. package/dist/entry-server-Ckh6zfgm.js.map +1 -0
  9. package/dist/entry-server.js +12 -0
  10. package/dist/entry-server.js.map +1 -0
  11. package/dist/generateMetadata-C5QsMS-H.js +144 -0
  12. package/dist/generateMetadata-C5QsMS-H.js.map +1 -0
  13. package/dist/index-BIt7MJT9.js +163 -0
  14. package/dist/index-BIt7MJT9.js.map +1 -0
  15. package/dist/index.js +49 -0
  16. package/dist/index.js.map +1 -0
  17. package/dist/island/client.js +5 -0
  18. package/dist/island/client.js.map +1 -0
  19. package/dist/island/runtime.js +98 -0
  20. package/dist/island/runtime.js.map +1 -0
  21. package/dist/island.js +39 -0
  22. package/dist/island.js.map +1 -0
  23. package/dist/jsx-runtime-C2Vw67N2.js +256 -0
  24. package/dist/jsx-runtime-C2Vw67N2.js.map +1 -0
  25. package/dist/jsx-runtime.js +26 -0
  26. package/dist/jsx-runtime.js.map +1 -0
  27. package/dist/middleware.js +9 -0
  28. package/dist/middleware.js.map +1 -0
  29. package/dist/seo.js +7 -0
  30. package/dist/seo.js.map +1 -0
  31. package/dist/server.js +489 -0
  32. package/dist/server.js.map +1 -0
  33. package/dist/swap/server.js +15 -0
  34. package/dist/swap/server.js.map +1 -0
  35. package/dist/swap.js +121 -0
  36. package/dist/swap.js.map +1 -0
  37. package/dist/tooltip.js +129 -0
  38. package/dist/tooltip.js.map +1 -0
  39. package/dist/vite-plugin.js +381 -0
  40. package/dist/vite-plugin.js.map +1 -0
  41. package/index.ts +1 -0
  42. package/package.json +129 -0
  43. package/src/action/define-action.ts +8 -0
  44. package/src/action/index.ts +2 -0
  45. package/src/action/types.ts +21 -0
  46. package/src/core/bundler.ts +275 -0
  47. package/src/core/const.ts +2 -0
  48. package/src/core/entry-server.d.ts +1 -0
  49. package/src/core/entry-server.ts +381 -0
  50. package/src/core/exceptions.ts +80 -0
  51. package/src/core/head-priority.ts +15 -0
  52. package/src/core/index.ts +40 -0
  53. package/src/core/jsx-runtime.ts +325 -0
  54. package/src/core/jsx-types.d.ts +548 -0
  55. package/src/core/render.ts +181 -0
  56. package/src/core/request.ts +31 -0
  57. package/src/core/server.ts +740 -0
  58. package/src/core/vite-plugin.ts +779 -0
  59. package/src/island/ClientIsland.ts +71 -0
  60. package/src/island/client.ts +3 -0
  61. package/src/island/index.ts +3 -0
  62. package/src/island/runtime.ts +149 -0
  63. package/src/island/strategy-registry.ts +10 -0
  64. package/src/island/types.ts +28 -0
  65. package/src/middleware/defineMiddleware.ts +5 -0
  66. package/src/middleware/index.ts +133 -0
  67. package/src/middleware/sequence.ts +105 -0
  68. package/src/middleware/types.ts +28 -0
  69. package/src/seo/generateMetadata.tsx +559 -0
  70. package/src/seo/index.ts +10 -0
  71. package/src/seo/mergeMetadata.ts +200 -0
  72. package/src/seo/types.ts +316 -0
  73. package/src/swap/SwapResponse.tsx +16 -0
  74. package/src/swap/create-swap.ts +121 -0
  75. package/src/swap/index.ts +8 -0
  76. package/src/swap/parse.ts +12 -0
  77. package/src/swap/server.ts +1 -0
  78. package/src/swap/swap.ts +57 -0
  79. package/src/swap/types.ts +47 -0
  80. package/src/swap/utils.ts +7 -0
  81. package/src/tooltip/index.ts +2 -0
  82. package/src/tooltip/tooltip-loader.ts +108 -0
  83. package/src/tooltip/tooltip-runtime.ts +173 -0
  84. package/types.d.ts +14 -0
@@ -0,0 +1,779 @@
1
+ import { transform } from 'esbuild';
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ readdirSync,
6
+ readFileSync,
7
+ rmSync,
8
+ statSync,
9
+ writeFileSync,
10
+ } from 'fs';
11
+ import { dirname, join, relative, resolve } from 'path';
12
+ import type { Plugin, UserConfig } from 'vite';
13
+
14
+ const VIRTUAL_L5E_VIEWS = 'virtual:l5e-views';
15
+ const VIRTUAL_L5E_ROUTE = 'virtual:l5e-route';
16
+ const VIRTUAL_L5E_SSR_ENTRY = 'virtual:l5e-ssr-entry';
17
+ const VIRTUAL_L5E_GLOBAL_LOADER = 'virtual:l5e-global-loader';
18
+ const VIRTUAL_L5E_ISLAND_STRATEGIES = 'virtual:l5e-island-strategies';
19
+ const VIRTUAL_L5E_ACTIONS = 'virtual:l5e-actions';
20
+ const VIRTUAL_L5E_MIDDLEWARE = 'virtual:l5e-middleware';
21
+
22
+ /**
23
+ * Recursively scan directory for .tsx and .ts files
24
+ */
25
+ function scanTsFiles(dir: string, fileList: string[] = []): string[] {
26
+ const files = readdirSync(dir);
27
+
28
+ for (const file of files) {
29
+ const filePath = join(dir, file);
30
+ const stat = statSync(filePath);
31
+
32
+ if (stat.isDirectory()) {
33
+ // Skip node_modules and dist directories
34
+ if (file === 'node_modules' || file === 'dist' || file === '.git') {
35
+ continue;
36
+ }
37
+ scanTsFiles(filePath, fileList);
38
+ } else if (file.endsWith('.tsx') || file.endsWith('.ts')) {
39
+ fileList.push(filePath);
40
+ }
41
+ }
42
+
43
+ return fileList;
44
+ }
45
+
46
+ /**
47
+ * Short hash function for island keys
48
+ */
49
+ function shortHash(str: string): string {
50
+ let hash = 0;
51
+ for (let i = 0; i < str.length; i++) {
52
+ hash = (hash << 5) - hash + str.charCodeAt(i);
53
+ hash = hash & hash;
54
+ }
55
+ return Math.abs(hash).toString(16).slice(0, 4);
56
+ }
57
+
58
+ /**
59
+ * Derive component name from path: "./react/Counter" → "Counter"
60
+ */
61
+ function deriveComponentName(fromPath: string): string {
62
+ const segments = fromPath.split('/');
63
+ let filename = segments[segments.length - 1];
64
+ // Remove extension if present
65
+ filename = filename.replace(/\.(tsx?|jsx?)$/, '');
66
+ return filename;
67
+ }
68
+
69
+ /**
70
+ * Create island registry key: "/src/views/test-island/react/Counter" → "Counter_a3f2"
71
+ */
72
+ function makeIslandKey(resolvedPath: string): string {
73
+ const name = deriveComponentName(resolvedPath);
74
+ return `${name}_${shortHash(resolvedPath)}`;
75
+ }
76
+
77
+ /**
78
+ * Resolve file path with extension (.tsx, .ts, .jsx, .js)
79
+ * Returns the relative path with extension (e.g., "src/views/.../Counter.tsx")
80
+ * or null if file not found
81
+ */
82
+ function resolveWithExtension(resolvedPath: string, rootDir: string): string | null {
83
+ // resolvedPath: "/src/views/test-island/react/Counter" (no extension, leading /)
84
+ const relPath = resolvedPath.replace(/^\//, ''); // "src/views/.../Counter"
85
+ const absBase = resolve(rootDir, relPath);
86
+
87
+ // Check with extensions
88
+ for (const ext of ['.tsx', '.ts', '.jsx', '.js']) {
89
+ if (existsSync(absBase + ext)) {
90
+ return relPath + ext; // "src/views/.../Counter.tsx"
91
+ }
92
+ }
93
+ // Check without extension (file might already have one)
94
+ if (existsSync(absBase)) {
95
+ return relPath;
96
+ }
97
+ return null;
98
+ }
99
+
100
+ /**
101
+ * Extract island entries from file using regex on JSX source
102
+ */
103
+ function extractIslandEntries(
104
+ filePath: string,
105
+ rootDir: string,
106
+ ): Array<{ component: string; resolvedPath: string; src: string; key: string }> {
107
+ const content = readFileSync(filePath, 'utf-8');
108
+ const entries: Array<{ component: string; resolvedPath: string; src: string; key: string }> = [];
109
+
110
+ // Regex scan JSX source (NOT post-transform code).
111
+ // Just find <ClientIsland ... from="Y" ...> — component name derived from path.
112
+ const regex = /<ClientIsland\s[^>]*?from\s*=\s*"([^"]+)"/g;
113
+
114
+ const seen = new Set<string>();
115
+ let match;
116
+
117
+ while ((match = regex.exec(content)) !== null) {
118
+ const fromPath = match[1];
119
+
120
+ // Resolve relative path → absolute
121
+ let resolvedPath: string;
122
+ if (fromPath.startsWith('~/')) {
123
+ // Alias ~/ → /src/ (project convention)
124
+ resolvedPath = '/src/' + fromPath.substring(2);
125
+ } else if (fromPath.startsWith('/src/')) {
126
+ resolvedPath = fromPath;
127
+ } else if (fromPath.startsWith('./') || fromPath.startsWith('../')) {
128
+ const abs = resolve(filePath, '..', fromPath);
129
+ resolvedPath = '/' + relative(rootDir, abs).replace(/\\/g, '/');
130
+ } else {
131
+ resolvedPath = fromPath;
132
+ }
133
+
134
+ // Resolve file extension for manifest compatibility
135
+ const src = resolveWithExtension(resolvedPath, rootDir);
136
+ if (!src) {
137
+ console.warn(
138
+ `[l5e] Island component not found: ${resolvedPath} (from ${fromPath} in ${filePath})`,
139
+ );
140
+ continue;
141
+ }
142
+
143
+ const key = makeIslandKey(resolvedPath);
144
+ if (!seen.has(key)) {
145
+ seen.add(key);
146
+ entries.push({
147
+ component: deriveComponentName(resolvedPath),
148
+ resolvedPath,
149
+ src, // "src/views/.../Counter.tsx" — matches manifest key format
150
+ key,
151
+ });
152
+ }
153
+ }
154
+ return entries;
155
+ }
156
+
157
+ /**
158
+ * Extract paths from useCss and useClientJs calls using regex
159
+ */
160
+ function extractPathsFromFile(filePath: string, rootDir: string): { css: string[]; js: string[] } {
161
+ const content = readFileSync(filePath, 'utf-8');
162
+ const css: string[] = [];
163
+ const js: string[] = [];
164
+
165
+ // Regex patterns to match useCss('path') or useCss("path")
166
+ // Handles single and double quotes, and escaped quotes
167
+ const useCssPattern = /useCss\s*\(\s*['"]([^'"]+)['"]\s*,?\s*\)/g;
168
+ const useClientJsPattern = /useClientJs\s*\(\s*['"]([^'"]+)['"]\s*,?\s*\)/g;
169
+
170
+ let match;
171
+
172
+ // Extract CSS paths
173
+ while ((match = useCssPattern.exec(content)) !== null) {
174
+ const path = match[1];
175
+ // Convert relative path to absolute if needed
176
+ if (path.startsWith('/src/')) {
177
+ css.push(path);
178
+ } else if (path.startsWith('./') || path.startsWith('../')) {
179
+ // Resolve relative path
180
+ const absolutePath = resolve(filePath, '..', path);
181
+ const relativePath = '/' + relative(rootDir, absolutePath).replace(/\\/g, '/');
182
+ css.push(relativePath);
183
+ } else {
184
+ css.push(path);
185
+ }
186
+ }
187
+
188
+ // Extract JS paths
189
+ while ((match = useClientJsPattern.exec(content)) !== null) {
190
+ const path = match[1];
191
+ if (path.startsWith('/src/')) {
192
+ js.push(path);
193
+ } else if (path.startsWith('./') || path.startsWith('../')) {
194
+ const absolutePath = resolve(filePath, '..', path);
195
+ const relativePath = '/' + relative(rootDir, absolutePath).replace(/\\/g, '/');
196
+ js.push(relativePath);
197
+ } else {
198
+ js.push(path);
199
+ }
200
+ }
201
+
202
+ return { css, js };
203
+ }
204
+
205
+ /**
206
+ * Auto-discover rollup input entries from useCss and useClientJs calls
207
+ */
208
+ function discoverRollupInput(rootDir: string): {
209
+ input: Record<string, string>;
210
+ islandRegistry: Map<string, string>;
211
+ pathToKey: Map<string, string>;
212
+ keyToSrc: Map<string, string>;
213
+ actionRegistry: Map<string, { modulePath: string; actionName: string }>;
214
+ } {
215
+ const input: Record<string, string> = {};
216
+ const srcDir = join(rootDir, 'src');
217
+
218
+ // Island registries
219
+ const islandRegistry = new Map<string, string>(); // key → resolvedPath
220
+ const pathToKey = new Map<string, string>(); // resolvedPath → key
221
+ const keyToSrc = new Map<string, string>(); // key → src (manifest-compatible path with extension)
222
+
223
+ // Action registry: actionKey → { modulePath, actionName }
224
+ const actionRegistry = new Map<string, { modulePath: string; actionName: string }>();
225
+
226
+ try {
227
+ // Check if src directory exists
228
+ if (!statSync(srcDir).isDirectory()) {
229
+ return { input, islandRegistry, pathToKey, keyToSrc, actionRegistry };
230
+ }
231
+
232
+ // Check if src/client.global.ts exists and add it as an entry
233
+ const globalTsPath = join(rootDir, 'src', 'client.global.ts');
234
+ if (existsSync(globalTsPath)) {
235
+ input['global'] = globalTsPath;
236
+ console.log('[l5e] Detected src/client.global.ts and added as entry');
237
+ }
238
+
239
+ // Scan all .tsx and .ts files
240
+ const tsFiles = scanTsFiles(srcDir);
241
+
242
+ // Extract all paths
243
+ const allCssPaths = new Set<string>();
244
+ const allJsPaths = new Set<string>();
245
+
246
+ for (const file of tsFiles) {
247
+ const { css, js } = extractPathsFromFile(file, rootDir);
248
+ css.forEach((path) => allCssPaths.add(path));
249
+ js.forEach((path) => allJsPaths.add(path));
250
+
251
+ // Extract island entries
252
+ const islandEntries = extractIslandEntries(file, rootDir);
253
+ for (const entry of islandEntries) {
254
+ islandRegistry.set(entry.key, entry.resolvedPath);
255
+ pathToKey.set(entry.resolvedPath, entry.key);
256
+ keyToSrc.set(entry.key, entry.src);
257
+ }
258
+
259
+ // Extract action entries from actions.ts/tsx files
260
+ const normalizedFile = file.replace(/\\/g, '/');
261
+ if (/\/actions\.(ts|tsx)$/.test(normalizedFile)) {
262
+ const content = readFileSync(file, 'utf-8');
263
+ const actionExportRegex = /export\s+const\s+(\w+)\s*=\s*defineAction\s*\(/g;
264
+ let actionMatch;
265
+
266
+ // Compute modulePath: relative path from src/ to parent dir
267
+ const relFromSrc = relative(srcDir, dirname(file)).replace(/\\/g, '/');
268
+ const modulePath = relFromSrc || '.';
269
+
270
+ while ((actionMatch = actionExportRegex.exec(content)) !== null) {
271
+ const actionName = actionMatch[1];
272
+ const actionKey = `${actionName}_${shortHash(modulePath)}`;
273
+ actionRegistry.set(actionKey, { modulePath, actionName });
274
+ }
275
+ }
276
+ }
277
+
278
+ // Convert paths to rollup input entries
279
+ // CSS files
280
+ for (const cssPath of allCssPaths) {
281
+ // Remove leading /src/ and convert to relative path
282
+ const relativePath = cssPath.startsWith('/src/')
283
+ ? cssPath.substring(1) // Remove leading /
284
+ : cssPath.startsWith('/')
285
+ ? cssPath.substring(1)
286
+ : cssPath;
287
+
288
+ const absolutePath = resolve(rootDir, relativePath);
289
+
290
+ // Only add if file exists
291
+ if (!existsSync(absolutePath)) {
292
+ console.warn(`[l5e] CSS file not found: ${absolutePath} (from ${cssPath})`);
293
+ continue;
294
+ }
295
+
296
+ // Generate entry name from path (e.g., /src/views/home/home.css -> views-home-home)
297
+ const entryName = relativePath
298
+ .replace(/^src\//, '')
299
+ .replace(/\.css$/, '')
300
+ .replace(/\//g, '-')
301
+ .replace(/\\/g, '-');
302
+
303
+ input[entryName] = absolutePath;
304
+ }
305
+
306
+ // JS/TS files
307
+ for (const jsPath of allJsPaths) {
308
+ const relativePath = jsPath.startsWith('/src/')
309
+ ? jsPath.substring(1)
310
+ : jsPath.startsWith('/')
311
+ ? jsPath.substring(1)
312
+ : jsPath;
313
+
314
+ const absolutePath = resolve(rootDir, relativePath);
315
+
316
+ // Only add if file exists
317
+ if (!existsSync(absolutePath)) {
318
+ console.warn(`[l5e] JS/TS file not found: ${absolutePath} (from ${jsPath})`);
319
+ continue;
320
+ }
321
+
322
+ // Generate entry name from path
323
+ const entryName = relativePath
324
+ .replace(/^src\//, '')
325
+ .replace(/\.(ts|tsx|js|jsx)$/, '')
326
+ .replace(/\//g, '-')
327
+ .replace(/\\/g, '-');
328
+
329
+ input[entryName] = absolutePath;
330
+ }
331
+
332
+ // Add island component files to rollup input so they appear in manifest.
333
+ // server.ts looks up manifest at runtime to resolve per-page island URLs.
334
+ for (const [key, src] of keyToSrc) {
335
+ const absolutePath = resolve(rootDir, src);
336
+ input[`island-${key}`] = absolutePath;
337
+ }
338
+
339
+ if (islandRegistry.size > 0) {
340
+ console.log(`[l5e] Detected ${islandRegistry.size} island(s)`);
341
+ }
342
+ if (actionRegistry.size > 0) {
343
+ console.log(`[l5e] Detected ${actionRegistry.size} action(s)`);
344
+ }
345
+ } catch (error) {
346
+ // Silently fail if directory doesn't exist or other errors
347
+ console.warn('[l5e] Failed to discover rollup input:', error);
348
+ }
349
+
350
+ return { input, islandRegistry, pathToKey, keyToSrc, actionRegistry };
351
+ }
352
+
353
+ /**
354
+ * Inject __key prop into ClientIsland calls
355
+ * Use anchor-based approach to avoid fragile regex with nested objects
356
+ */
357
+ function injectIslandKeys(
358
+ code: string,
359
+ fileId: string,
360
+ rootDir: string,
361
+ pathToKey: Map<string, string>,
362
+ keyToSrc: Map<string, string>,
363
+ ): string {
364
+ // After esbuild, code has form:
365
+ // jsxFactory(ClientIsland, { from: "./react/Counter", props: { n: 5 } })
366
+ // ^anchor ^find from here
367
+
368
+ const result: string[] = [];
369
+ let lastIndex = 0;
370
+
371
+ // Find each position "ClientIsland," (anchor)
372
+ const anchorRegex = /ClientIsland\s*,\s*\{/g;
373
+ let anchorMatch;
374
+
375
+ while ((anchorMatch = anchorRegex.exec(code)) !== null) {
376
+ const searchStart = anchorMatch.index + anchorMatch[0].length;
377
+
378
+ // Scan for from: "..." in window ~500 chars after anchor
379
+ const window = code.substring(searchStart, searchStart + 500);
380
+ const fromMatch = /from:\s*"([^"]+)"/.exec(window);
381
+
382
+ if (!fromMatch) continue;
383
+
384
+ const fromPath = fromMatch[1];
385
+
386
+ // Resolve path (handle ~/ alias)
387
+ let resolved: string;
388
+ if (fromPath.startsWith('~/')) {
389
+ resolved = '/src/' + fromPath.substring(2);
390
+ } else if (fromPath.startsWith('/src/')) {
391
+ resolved = fromPath;
392
+ } else if (fromPath.startsWith('./') || fromPath.startsWith('../')) {
393
+ const abs = resolve(fileId, '..', fromPath);
394
+ resolved = '/' + relative(rootDir, abs).replace(/\\/g, '/');
395
+ } else {
396
+ resolved = fromPath;
397
+ }
398
+
399
+ const key = pathToKey.get(resolved);
400
+ if (!key) continue;
401
+
402
+ const src = keyToSrc.get(key);
403
+ if (!src) continue;
404
+
405
+ // Inject __key and __src right after from: "..."
406
+ const insertPos = searchStart + fromMatch.index + fromMatch[0].length;
407
+ result.push(code.substring(lastIndex, insertPos));
408
+ result.push(`, __key: "${key}", __src: "${src}"`);
409
+ lastIndex = insertPos;
410
+ }
411
+
412
+ result.push(code.substring(lastIndex));
413
+ return result.join('');
414
+ }
415
+
416
+ export function coreVite(): Plugin {
417
+ let rootDir: string = process.cwd();
418
+ let islandRegistry = new Map<string, string>();
419
+ let pathToKey = new Map<string, string>();
420
+ let keyToSrc = new Map<string, string>();
421
+ let actionRegistry = new Map<string, { modulePath: string; actionName: string }>();
422
+
423
+ return {
424
+ name: 'l5e-jsx-classic',
425
+ enforce: 'pre',
426
+
427
+ configResolved(resolvedConfig) {
428
+ // Store root directory for later use
429
+ rootDir = resolvedConfig.root || process.cwd();
430
+ },
431
+
432
+ handleHotUpdate({ file, server }) {
433
+ const relPath = relative(rootDir, file);
434
+
435
+ // Re-scan action registry when an actions file changes
436
+ if (/actions\.(ts|tsx)$/.test(file)) {
437
+ const discovered = discoverRollupInput(rootDir);
438
+ actionRegistry = discovered.actionRegistry;
439
+ // Invalidate the virtual:l5e-actions module so server picks up new registry
440
+ const mod = server.moduleGraph.getModuleById('\0' + VIRTUAL_L5E_ACTIONS);
441
+ if (mod) {
442
+ server.moduleGraph.invalidateModule(mod);
443
+ }
444
+ console.log(
445
+ `[l5e] Action file changed: ${relPath} — re-scanned ${actionRegistry.size} action(s)`,
446
+ );
447
+ }
448
+
449
+ // Allow Vite's default HMR for CSS and client-side JS files
450
+ if (file.endsWith('.css')) {
451
+ console.log(`[l5e] CSS changed: ${relPath} - using Vite HMR`);
452
+ return; // Let Vite handle CSS HMR
453
+ }
454
+
455
+ if (file.includes('client.ts')) {
456
+ console.log(`[l5e] Client JS changed: ${relPath} - using Vite HMR`);
457
+ return; // Let Vite handle client JS HMR
458
+ }
459
+
460
+ // Trigger full page reload for SSR-related file changes (components, loaders, routes)
461
+ // This ensures that server-side rendered components update properly
462
+ if (file.includes('/src/') || file.includes('\\src\\')) {
463
+ console.log(`[l5e] SSR file changed: ${relPath} - triggering full reload`);
464
+ server.ws.send({
465
+ type: 'full-reload',
466
+ path: '*',
467
+ });
468
+ return []; // Prevent Vite's default HMR behavior
469
+ }
470
+ },
471
+
472
+ buildEnd() {
473
+ // Cleanup temporary files after build
474
+ const tempDir = join(rootDir, '.l5e-temp');
475
+ if (existsSync(tempDir)) {
476
+ try {
477
+ rmSync(tempDir, { recursive: true, force: true });
478
+ console.log('[l5e] Cleaned up temporary files');
479
+ } catch (error) {
480
+ console.warn('[l5e] Failed to cleanup temporary files:', error);
481
+ }
482
+ }
483
+ },
484
+
485
+ writeBundle(_options, _bundle) {
486
+ // Emit action registry JSON for production server
487
+ if (actionRegistry.size > 0) {
488
+ const outDir = join(rootDir, 'dist', 'server');
489
+ if (!existsSync(outDir)) {
490
+ mkdirSync(outDir, { recursive: true });
491
+ }
492
+ const registryObj = Object.fromEntries(actionRegistry);
493
+ writeFileSync(join(outDir, 'action-registry.json'), JSON.stringify(registryObj), 'utf-8');
494
+ console.log(`[l5e] Wrote action-registry.json (${actionRegistry.size} actions)`);
495
+ }
496
+ },
497
+
498
+ config(userConfig) {
499
+ // Auto-discover rollup input from useCss and useClientJs
500
+ const projectRoot = userConfig.root || process.cwd();
501
+ const discovered = discoverRollupInput(projectRoot);
502
+
503
+ // Store island registries for use in other hooks
504
+ islandRegistry = discovered.islandRegistry;
505
+ pathToKey = discovered.pathToKey;
506
+ keyToSrc = discovered.keyToSrc;
507
+ actionRegistry = discovered.actionRegistry;
508
+
509
+ // Merge with existing rollupOptions.input if any
510
+ const existingInput = userConfig.build?.rollupOptions?.input || {};
511
+ const mergedInput =
512
+ typeof existingInput === 'object' && !Array.isArray(existingInput)
513
+ ? { ...discovered.input, ...existingInput }
514
+ : discovered.input;
515
+
516
+ return {
517
+ build: {
518
+ ...userConfig.build,
519
+ rollupOptions: {
520
+ ...userConfig.build?.rollupOptions,
521
+ input: Object.keys(mergedInput).length > 0 ? mergedInput : undefined,
522
+ // Preserve exports for island component entries — without this,
523
+ // Rollup tree-shakes their exports since nothing in the bundle imports them
524
+ // (they're loaded at runtime via dynamic import from the island runtime).
525
+ preserveEntrySignatures: 'exports-only',
526
+ },
527
+ },
528
+ } satisfies UserConfig;
529
+ },
530
+
531
+ resolveId(id) {
532
+ if (id === VIRTUAL_L5E_VIEWS) {
533
+ return '\0' + VIRTUAL_L5E_VIEWS;
534
+ }
535
+ if (id === VIRTUAL_L5E_ROUTE) {
536
+ return '\0' + VIRTUAL_L5E_ROUTE;
537
+ }
538
+ if (id === VIRTUAL_L5E_SSR_ENTRY) {
539
+ return '\0' + VIRTUAL_L5E_SSR_ENTRY;
540
+ }
541
+ if (id === VIRTUAL_L5E_GLOBAL_LOADER) {
542
+ return '\0' + VIRTUAL_L5E_GLOBAL_LOADER;
543
+ }
544
+ if (id === VIRTUAL_L5E_ISLAND_STRATEGIES) {
545
+ return '\0' + VIRTUAL_L5E_ISLAND_STRATEGIES;
546
+ }
547
+ if (id === VIRTUAL_L5E_ACTIONS) {
548
+ return '\0' + VIRTUAL_L5E_ACTIONS;
549
+ }
550
+ if (id === VIRTUAL_L5E_MIDDLEWARE) {
551
+ return '\0' + VIRTUAL_L5E_MIDDLEWARE;
552
+ }
553
+ return null;
554
+ },
555
+
556
+ async transform(code, id, options) {
557
+ // Auto-inject island runtime into client.global.ts so it's always loaded globally
558
+ if (id.replace(/\\/g, '/').endsWith('src/client.global.ts') && !options?.ssr) {
559
+ return {
560
+ code: `import '@withl5e/l5e/island/runtime';\n${code}`,
561
+ map: null,
562
+ };
563
+ }
564
+
565
+ // Client-side action transform: replace defineAction exports with fetch stubs
566
+ // Supports actions anywhere under src/ (e.g., src/views/*, src/features/*, etc.)
567
+ if (!options?.ssr) {
568
+ const normalizedId = id.replace(/\\/g, '/');
569
+ const actionMatch = normalizedId.match(/\/src\/(.+)\/actions\.(ts|tsx)$/);
570
+ if (actionMatch) {
571
+ const modulePath = actionMatch[1];
572
+
573
+ // Parse exported action names
574
+ const exportRegex = /export\s+const\s+(\w+)\s*=\s*defineAction\s*\(/g;
575
+ const actions: Array<{ name: string; method: string }> = [];
576
+ let m;
577
+ while ((m = exportRegex.exec(code)) !== null) {
578
+ const name = m[1];
579
+ // Find method for this action — scan from the defineAction( position
580
+ const chunk = code.substring(m.index, m.index + 500);
581
+ const methodMatch = chunk.match(/method:\s*['"](\w+)['"]/);
582
+ const method = methodMatch ? methodMatch[1].toUpperCase() : 'GET';
583
+ actions.push({ name, method });
584
+ }
585
+
586
+ if (actions.length > 0) {
587
+ const stubs = actions.map(({ name, method }) => {
588
+ const actionKey = `${name}_${shortHash(modulePath)}`;
589
+ if (method === 'GET') {
590
+ return `export async function ${name}(params) {
591
+ const res = await fetch('/_l5e/action/${actionKey}?' + new URLSearchParams(params));
592
+ if (!res.ok) throw Object.assign(new Error('HTTP ' + res.status), { status: res.status });
593
+ return res;
594
+ }`;
595
+ }
596
+ return `export async function ${name}(body) {
597
+ const res = await fetch('/_l5e/action/${actionKey}', {
598
+ method: '${method}',
599
+ headers: { 'Content-Type': 'application/json' },
600
+ body: JSON.stringify(body),
601
+ });
602
+ if (!res.ok) throw Object.assign(new Error('HTTP ' + res.status), { status: res.status });
603
+ return res;
604
+ }`;
605
+ });
606
+
607
+ return {
608
+ code: stubs.join('\n\n'),
609
+ map: null,
610
+ };
611
+ }
612
+ }
613
+ }
614
+
615
+ // Skip node_modules
616
+ if (id.includes('node_modules')) {
617
+ return null;
618
+ }
619
+
620
+ // Skip files in /react/ directories
621
+ if (id.includes('/react/') || id.includes('\\react\\')) {
622
+ return null;
623
+ }
624
+
625
+ // Check if this is a JSX/TSX file that needs L5E JSX transformation
626
+ const isJsxFile = /\.(tsx|jsx)$/.test(id);
627
+
628
+ if (isJsxFile) {
629
+ try {
630
+ const injected = `import { Fragment as __Fragment, jsxFactory } from "@withl5e/l5e/jsx-runtime"\n${code}`;
631
+
632
+ // Transform JSX using esbuild with L5E's JSX runtime (async)
633
+ const result = await transform(injected, {
634
+ loader: id.endsWith('.tsx') ? 'tsx' : 'jsx',
635
+ jsx: 'transform',
636
+ jsxFactory: 'jsxFactory',
637
+ jsxFragment: '__Fragment',
638
+ sourcemap: true,
639
+ sourcefile: id,
640
+ target: 'es2020',
641
+ });
642
+
643
+ let transformedCode = result.code;
644
+
645
+ // Inject __key and __src for ALL ClientIsland calls
646
+ if (transformedCode.includes('ClientIsland')) {
647
+ transformedCode = injectIslandKeys(transformedCode, id, rootDir, pathToKey, keyToSrc);
648
+ }
649
+
650
+ return {
651
+ code: transformedCode,
652
+ map: result.map || null,
653
+ };
654
+ } catch (error) {
655
+ // Log error but let Vite handle it
656
+ console.error(`[l5e] Failed to transform JSX in ${id}:`, error);
657
+ throw error;
658
+ }
659
+ }
660
+
661
+ // Transform import.meta.env thành process.env ở server side
662
+ // Server side có thể access tất cả env variables
663
+ // Client side chỉ access được VITE_ env variables
664
+ // Transform này chạy ở runtime khi module được load trong SSR context
665
+ if (options?.ssr) {
666
+ let transformedCode = code;
667
+ let hasChanges = false;
668
+
669
+ // Thay thế import.meta.env.VARIABLE_NAME thành process.env.VARIABLE_NAME
670
+ // Match: import.meta.env.VITE_EVENT_CATEGORY_SLUG
671
+ transformedCode = transformedCode.replace(
672
+ /import\.meta\.env\.([a-zA-Z_][a-zA-Z0-9_]*)/g,
673
+ (match, varName) => {
674
+ hasChanges = true;
675
+ return `process.env.${varName}`;
676
+ },
677
+ );
678
+
679
+ // Thay thế import.meta.env['VARIABLE_NAME'] hoặc import.meta.env["VARIABLE_NAME"]
680
+ // thành process.env['VARIABLE_NAME'] hoặc process.env["VARIABLE_NAME"]
681
+ // Match: import.meta.env['VITE_EVENT_CATEGORY_SLUG'] hoặc import.meta.env["VITE_EVENT_CATEGORY_SLUG"]
682
+ transformedCode = transformedCode.replace(
683
+ /import\.meta\.env\[(['"`])([^'"`]+)\1\]/g,
684
+ (match, quote, varName) => {
685
+ hasChanges = true;
686
+ return `process.env[${quote}${varName}${quote}]`;
687
+ },
688
+ );
689
+
690
+ if (hasChanges) {
691
+ return {
692
+ code: transformedCode,
693
+ map: null, // Không cần source map cho env transform
694
+ };
695
+ }
696
+ }
697
+ return null;
698
+ },
699
+
700
+ load(id) {
701
+ // Virtual module: l5e-views
702
+ if (id === '\0' + VIRTUAL_L5E_VIEWS) {
703
+ return `
704
+ export const viewLoaders = import.meta.glob('/src/views/*/loader.{ts,tsx}');
705
+ export const viewComponents = import.meta.glob('/src/views/*/index.tsx');
706
+ `;
707
+ }
708
+
709
+ // Virtual module: l5e-route
710
+ if (id === '\0' + VIRTUAL_L5E_ROUTE) {
711
+ // Always use TypeScript
712
+ return `export { default } from '/src/route.ts';`;
713
+ }
714
+
715
+ // Virtual module: l5e-ssr-entry
716
+ if (id === '\0' + VIRTUAL_L5E_SSR_ENTRY) {
717
+ return `export { render } from '@withl5e/l5e/entry-server';
718
+ export { viewActions } from 'virtual:l5e-actions';`;
719
+ }
720
+
721
+ // Virtual module: l5e-global-loader
722
+ if (id === '\0' + VIRTUAL_L5E_GLOBAL_LOADER) {
723
+ return `export const globalLoader = import.meta.glob('/src/global-loader.{ts,tsx}', { eager: false });`;
724
+ }
725
+
726
+ // Virtual module: l5e-actions
727
+ if (id === '\0' + VIRTUAL_L5E_ACTIONS) {
728
+ const registryObj = Object.fromEntries(actionRegistry);
729
+ return `export const viewActions = import.meta.glob('/src/**/actions.{ts,tsx}');
730
+ export const actionRegistry = ${JSON.stringify(registryObj)};`;
731
+ }
732
+
733
+ // Virtual module: l5e-middleware
734
+ if (id === '\0' + VIRTUAL_L5E_MIDDLEWARE) {
735
+ return `
736
+ const middlewareModules = import.meta.glob([
737
+ '/src/middleware.{ts,tsx,js,jsx}',
738
+ '/src/middleware/index.{ts,tsx,js,jsx}',
739
+ ]);
740
+
741
+ const middlewarePaths = [
742
+ '/src/middleware.ts',
743
+ '/src/middleware.tsx',
744
+ '/src/middleware.js',
745
+ '/src/middleware.jsx',
746
+ '/src/middleware/index.ts',
747
+ '/src/middleware/index.tsx',
748
+ '/src/middleware/index.js',
749
+ '/src/middleware/index.jsx',
750
+ ];
751
+
752
+ export async function loadMiddleware() {
753
+ const middlewarePath = middlewarePaths.find((path) => middlewareModules[path]);
754
+ if (!middlewarePath) return undefined;
755
+
756
+ const mod = await middlewareModules[middlewarePath]();
757
+ return mod.onRequest;
758
+ }
759
+ `;
760
+ }
761
+
762
+ // Virtual module: l5e-island-strategies
763
+ if (id === '\0' + VIRTUAL_L5E_ISLAND_STRATEGIES) {
764
+ // Check if src/island-strategies.ts exists
765
+ const strategiesFile = join(rootDir, 'src', 'island-strategies.ts');
766
+ if (existsSync(strategiesFile)) {
767
+ // Re-export user's file → Vite will build this file
768
+ return `import '/src/island-strategies.ts';`;
769
+ }
770
+ // No file → empty module, no error
771
+ return `/* no custom island strategies */`;
772
+ }
773
+
774
+ return null;
775
+ },
776
+ };
777
+ }
778
+
779
+ export default coreVite;