@useavalon/avalon 0.1.11 → 0.1.13

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 (141) hide show
  1. package/README.md +54 -54
  2. package/mod.ts +302 -302
  3. package/package.json +49 -26
  4. package/src/build/integration-bundler-plugin.ts +116 -116
  5. package/src/build/integration-config.ts +168 -168
  6. package/src/build/integration-detection-plugin.ts +117 -117
  7. package/src/build/integration-resolver-plugin.ts +90 -90
  8. package/src/build/island-manifest.ts +269 -269
  9. package/src/build/island-types-generator.ts +476 -476
  10. package/src/build/mdx-island-transform.ts +464 -464
  11. package/src/build/mdx-plugin.ts +98 -98
  12. package/src/build/page-island-transform.ts +598 -598
  13. package/src/build/prop-extractors/index.ts +21 -21
  14. package/src/build/prop-extractors/lit.ts +140 -140
  15. package/src/build/prop-extractors/qwik.ts +16 -16
  16. package/src/build/prop-extractors/solid.ts +125 -125
  17. package/src/build/prop-extractors/svelte.ts +194 -194
  18. package/src/build/prop-extractors/vue.ts +111 -111
  19. package/src/build/sidecar-file-manager.ts +104 -104
  20. package/src/build/sidecar-renderer.ts +30 -30
  21. package/src/client/adapters/index.ts +21 -13
  22. package/src/client/components.ts +35 -35
  23. package/src/client/css-hmr-handler.ts +344 -344
  24. package/src/client/framework-adapter.ts +462 -462
  25. package/src/client/hmr-coordinator.ts +396 -396
  26. package/src/client/hmr-error-overlay.js +533 -533
  27. package/src/client/main.js +824 -816
  28. package/src/client/types/framework-runtime.d.ts +68 -68
  29. package/src/client/types/vite-hmr.d.ts +46 -46
  30. package/src/client/types/vite-virtual-modules.d.ts +70 -60
  31. package/src/components/Image.tsx +123 -123
  32. package/src/components/IslandErrorBoundary.tsx +145 -145
  33. package/src/components/LayoutDataErrorBoundary.tsx +141 -141
  34. package/src/components/LayoutErrorBoundary.tsx +127 -127
  35. package/src/components/PersistentIsland.tsx +52 -52
  36. package/src/components/StreamingErrorBoundary.tsx +233 -233
  37. package/src/components/StreamingLayout.tsx +538 -538
  38. package/src/core/components/component-analyzer.ts +192 -192
  39. package/src/core/components/component-detection.ts +508 -508
  40. package/src/core/components/enhanced-framework-detector.ts +500 -500
  41. package/src/core/components/framework-registry.ts +563 -563
  42. package/src/core/content/mdx-processor.ts +46 -46
  43. package/src/core/integrations/index.ts +19 -19
  44. package/src/core/integrations/loader.ts +125 -125
  45. package/src/core/integrations/registry.ts +175 -175
  46. package/src/core/islands/island-persistence.ts +325 -325
  47. package/src/core/islands/island-state-serializer.ts +258 -258
  48. package/src/core/islands/persistent-island-context.tsx +80 -80
  49. package/src/core/islands/use-persistent-state.ts +68 -68
  50. package/src/core/layout/enhanced-layout-resolver.ts +322 -322
  51. package/src/core/layout/layout-cache-manager.ts +485 -485
  52. package/src/core/layout/layout-composer.ts +357 -357
  53. package/src/core/layout/layout-data-loader.ts +516 -516
  54. package/src/core/layout/layout-discovery.ts +243 -243
  55. package/src/core/layout/layout-matcher.ts +299 -299
  56. package/src/core/layout/layout-types.ts +110 -110
  57. package/src/core/modules/framework-module-resolver.ts +273 -273
  58. package/src/islands/component-analysis.ts +213 -213
  59. package/src/islands/css-utils.ts +565 -565
  60. package/src/islands/discovery/index.ts +80 -80
  61. package/src/islands/discovery/registry.ts +340 -340
  62. package/src/islands/discovery/resolver.ts +477 -477
  63. package/src/islands/discovery/scanner.ts +386 -386
  64. package/src/islands/discovery/types.ts +117 -117
  65. package/src/islands/discovery/validator.ts +544 -544
  66. package/src/islands/discovery/watcher.ts +368 -368
  67. package/src/islands/framework-detection.ts +428 -428
  68. package/src/islands/integration-loader.ts +490 -490
  69. package/src/islands/island.tsx +565 -565
  70. package/src/islands/render-cache.ts +550 -550
  71. package/src/islands/types.ts +80 -80
  72. package/src/islands/universal-css-collector.ts +157 -157
  73. package/src/islands/universal-head-collector.ts +137 -137
  74. package/src/layout-system.d.ts +592 -592
  75. package/src/layout-system.ts +218 -218
  76. package/src/middleware/discovery.ts +268 -268
  77. package/src/middleware/executor.ts +315 -315
  78. package/src/middleware/index.ts +76 -76
  79. package/src/middleware/types.ts +99 -99
  80. package/src/nitro/build-config.ts +575 -575
  81. package/src/nitro/config.ts +483 -483
  82. package/src/nitro/error-handler.ts +636 -636
  83. package/src/nitro/index.ts +173 -173
  84. package/src/nitro/island-manifest.ts +584 -584
  85. package/src/nitro/middleware-adapter.ts +260 -260
  86. package/src/nitro/renderer.ts +1471 -1471
  87. package/src/nitro/route-discovery.ts +439 -439
  88. package/src/nitro/types.ts +321 -321
  89. package/src/render/collect-css.ts +198 -198
  90. package/src/render/error-pages.ts +79 -79
  91. package/src/render/isolated-ssr-renderer.ts +654 -654
  92. package/src/render/ssr.ts +1030 -1030
  93. package/src/schemas/api.ts +30 -30
  94. package/src/schemas/core.ts +64 -64
  95. package/src/schemas/index.ts +212 -212
  96. package/src/schemas/layout.ts +279 -279
  97. package/src/schemas/routing/index.ts +38 -38
  98. package/src/schemas/routing.ts +376 -376
  99. package/src/types/as-island.ts +20 -20
  100. package/src/types/image.d.ts +106 -106
  101. package/src/types/index.d.ts +22 -22
  102. package/src/types/island-jsx.d.ts +33 -33
  103. package/src/types/island-prop.d.ts +20 -20
  104. package/src/types/layout.ts +285 -285
  105. package/src/types/mdx.d.ts +6 -6
  106. package/src/types/routing.ts +555 -555
  107. package/src/types/types.ts +5 -5
  108. package/src/types/urlpattern.d.ts +49 -49
  109. package/src/types/vite-env.d.ts +11 -11
  110. package/src/utils/dev-logger.ts +299 -299
  111. package/src/utils/fs.ts +151 -151
  112. package/src/vite-plugin/auto-discover.ts +551 -551
  113. package/src/vite-plugin/config.ts +266 -266
  114. package/src/vite-plugin/errors.ts +127 -127
  115. package/src/vite-plugin/image-optimization.ts +156 -156
  116. package/src/vite-plugin/integration-activator.ts +126 -126
  117. package/src/vite-plugin/island-sidecar-plugin.ts +176 -176
  118. package/src/vite-plugin/module-discovery.ts +189 -189
  119. package/src/vite-plugin/nitro-integration.ts +1354 -1354
  120. package/src/vite-plugin/plugin.ts +403 -409
  121. package/src/vite-plugin/types.ts +327 -327
  122. package/src/vite-plugin/validation.ts +228 -228
  123. package/src/client/adapters/index.js +0 -12
  124. package/src/client/adapters/lit-adapter.js +0 -467
  125. package/src/client/adapters/lit-adapter.ts +0 -654
  126. package/src/client/adapters/preact-adapter.js +0 -223
  127. package/src/client/adapters/preact-adapter.ts +0 -331
  128. package/src/client/adapters/qwik-adapter.js +0 -259
  129. package/src/client/adapters/qwik-adapter.ts +0 -345
  130. package/src/client/adapters/react-adapter.js +0 -220
  131. package/src/client/adapters/react-adapter.ts +0 -353
  132. package/src/client/adapters/solid-adapter.js +0 -295
  133. package/src/client/adapters/solid-adapter.ts +0 -451
  134. package/src/client/adapters/svelte-adapter.js +0 -368
  135. package/src/client/adapters/svelte-adapter.ts +0 -524
  136. package/src/client/adapters/vue-adapter.js +0 -278
  137. package/src/client/adapters/vue-adapter.ts +0 -467
  138. package/src/client/components.js +0 -23
  139. package/src/client/css-hmr-handler.js +0 -263
  140. package/src/client/framework-adapter.js +0 -283
  141. package/src/client/hmr-coordinator.js +0 -274
@@ -1,584 +1,584 @@
1
- /**
2
- * Island Manifest Generation Module for Nitro
3
- *
4
- * This module provides island manifest generation during production builds.
5
- * The manifest contains information about all discovered islands, their
6
- * compiled asset paths, and framework metadata for client-side hydration.
7
- *
8
- * ## Build Output
9
- *
10
- * During production build, this module generates:
11
- * - `island-manifest.json` in the build output directory
12
- * - Contains metadata for all islands (src, framework, css, preload)
13
- * - Includes build timestamp and content hashes for cache busting
14
- * - Generates preload hints for critical assets
15
- *
16
- * ## Nitro Integration
17
- *
18
- * The manifest is used by Nitro's renderer to:
19
- * - Resolve compiled island paths for hydration scripts
20
- * - Inject CSS assets for islands used on a page
21
- * - Generate preload tags for critical resources
22
- *
23
- * ## Asset Metadata
24
- *
25
- * Each island entry includes:
26
- * - `src`: Compiled JavaScript path (e.g., `/islands/Counter.abc123.js`)
27
- * - `framework`: Detected framework (react, preact, vue, svelte, solid, lit)
28
- * - `css`: Associated CSS files
29
- * - `contentHash`: Hash for cache busting
30
- * - `preloadDeps`: Dependencies to preload
31
- *
32
- * @module nitro/island-manifest
33
- */
34
-
35
- import { readFile } from "node:fs/promises";
36
- import type { Plugin } from "vite";
37
- import type { ResolvedAvalonConfig } from "../vite-plugin/types.ts";
38
- import type { IslandManifest, IslandEntry } from "./types.ts";
39
-
40
- /**
41
- * Extended island entry with build-time metadata
42
- */
43
- export interface BuildIslandEntry extends IslandEntry {
44
- /** Original source file path */
45
- sourcePath: string;
46
- /** Compiled JavaScript chunk name */
47
- chunkName: string;
48
- /** Content hash for cache busting */
49
- contentHash: string;
50
- /** Dependencies that need to be preloaded */
51
- preloadDeps: string[];
52
- /** Whether the island uses streaming */
53
- usesStreaming: boolean;
54
- }
55
-
56
- /**
57
- * Asset metadata for Nitro's asset manifest format
58
- * This matches Nitro's expected asset metadata structure
59
- */
60
- export interface AssetMetadata {
61
- /** MIME type of the asset */
62
- type: string;
63
- /** ETag for cache validation */
64
- etag: string;
65
- /** Last modification time (ISO string) */
66
- mtime: string;
67
- /** File size in bytes */
68
- size: number;
69
- }
70
-
71
- /**
72
- * Build-time island manifest with additional metadata
73
- */
74
- export interface BuildIslandManifest extends IslandManifest {
75
- /** Build timestamp */
76
- buildTime: number;
77
- /** Build version/hash */
78
- buildHash: string;
79
- /** Avalon version */
80
- avalonVersion: string;
81
- /** All CSS assets to inject */
82
- cssAssets: string[];
83
- /** Preload hints for critical assets */
84
- preloadHints: PreloadHint[];
85
- /** Framework-specific bundles */
86
- frameworkBundles: Record<string, string>;
87
- /** Asset metadata for cache headers (Nitro format) */
88
- assetMetadata?: Record<string, AssetMetadata>;
89
- }
90
-
91
- /**
92
- * Preload hint for resource optimization
93
- */
94
- export interface PreloadHint {
95
- /** Resource URL */
96
- href: string;
97
- /** Resource type (script, style, font, etc.) */
98
- as: "script" | "style" | "font" | "image";
99
- /** MIME type */
100
- type?: string;
101
- /** Cross-origin setting */
102
- crossorigin?: "anonymous" | "use-credentials";
103
- }
104
-
105
- /**
106
- * Options for island manifest generation
107
- */
108
- export interface IslandManifestOptions {
109
- /** Output path for the manifest file */
110
- outputPath?: string;
111
- /** Include source maps in manifest */
112
- includeSourceMaps?: boolean;
113
- /** Generate preload hints */
114
- generatePreloadHints?: boolean;
115
- /** Verbose logging */
116
- verbose?: boolean;
117
- }
118
-
119
- /**
120
- * Default manifest generation options
121
- */
122
- export const DEFAULT_MANIFEST_OPTIONS: Required<IslandManifestOptions> = {
123
- outputPath: "dist/island-manifest.json",
124
- includeSourceMaps: false,
125
- generatePreloadHints: true,
126
- verbose: false,
127
- };
128
-
129
- /**
130
- * Framework detection patterns for island files
131
- */
132
- const FRAMEWORK_PATTERNS: Record<string, RegExp[]> = {
133
- react: [
134
- /from\s+['"]react['"]/,
135
- /from\s+['"]react-dom['"]/,
136
- /@jsxImportSource\s+react/,
137
- ],
138
- preact: [
139
- /from\s+['"]preact['"]/,
140
- /from\s+['"]preact\/hooks['"]/,
141
- /@jsxImportSource\s+preact/,
142
- ],
143
- vue: [
144
- /from\s+['"]vue['"]/,
145
- /\.vue$/,
146
- ],
147
- svelte: [
148
- /from\s+['"]svelte['"]/,
149
- /\.svelte$/,
150
- ],
151
- solid: [
152
- /from\s+['"]solid-js['"]/,
153
- /\.solid\.(tsx|jsx)$/,
154
- ],
155
- lit: [
156
- /from\s+['"]lit['"]/,
157
- /from\s+['"]@lit['"]/,
158
- /\.lit\.(ts|js)$/,
159
- ],
160
- };
161
-
162
- /**
163
- * Detects the framework used by an island based on file content and extension
164
- *
165
- * @param filePath - Path to the island file
166
- * @param content - File content
167
- * @returns Detected framework name
168
- */
169
- export function detectIslandFramework(
170
- filePath: string,
171
- content: string
172
- ): string {
173
- // Check file extension first
174
- if (filePath.endsWith(".vue")) return "vue";
175
- if (filePath.endsWith(".svelte")) return "svelte";
176
- if (filePath.includes(".solid.")) return "solid";
177
- if (filePath.includes(".lit.")) return "lit";
178
-
179
- // Check content patterns
180
- for (const [framework, patterns] of Object.entries(FRAMEWORK_PATTERNS)) {
181
- for (const pattern of patterns) {
182
- if (pattern.test(content) || pattern.test(filePath)) {
183
- return framework;
184
- }
185
- }
186
- }
187
-
188
- // Default to preact for JSX/TSX files
189
- if (filePath.endsWith(".tsx") || filePath.endsWith(".jsx")) {
190
- return "preact";
191
- }
192
-
193
- return "unknown";
194
- }
195
-
196
- /**
197
- * Generates a content hash for cache busting
198
- *
199
- * @param content - Content to hash
200
- * @returns Short hash string
201
- */
202
- export async function generateContentHash(content: string): Promise<string> {
203
- const encoder = new TextEncoder();
204
- const data = encoder.encode(content);
205
- const hashBuffer = await crypto.subtle.digest("SHA-256", data);
206
- const hashArray = Array.from(new Uint8Array(hashBuffer));
207
- const hashHex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
208
- return hashHex.slice(0, 8);
209
- }
210
-
211
- /**
212
- * Extracts dependencies from island file content
213
- *
214
- * @param content - File content
215
- * @returns Array of dependency names
216
- */
217
- export function extractIslandDependencies(content: string): string[] {
218
- const deps: string[] = [];
219
- const importRegex = /import\s+.*?\s+from\s+['"]([^'"]+)['"]/g;
220
- const dynamicImportRegex = /import\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
221
-
222
- let match;
223
-
224
- // Static imports
225
- while ((match = importRegex.exec(content)) !== null) {
226
- const importPath = match[1];
227
- if (!importPath.startsWith(".") && !importPath.startsWith("/")) {
228
- deps.push(importPath);
229
- }
230
- }
231
-
232
- // Dynamic imports
233
- while ((match = dynamicImportRegex.exec(content)) !== null) {
234
- const importPath = match[1];
235
- if (!importPath.startsWith(".") && !importPath.startsWith("/")) {
236
- deps.push(importPath);
237
- }
238
- }
239
-
240
- return [...new Set(deps)];
241
- }
242
-
243
- /**
244
- * Creates an island entry from discovered island information
245
- *
246
- * @param name - Island name
247
- * @param sourcePath - Source file path
248
- * @param content - File content
249
- * @param compiledPath - Compiled asset path
250
- * @returns Island entry
251
- */
252
- export async function createIslandEntry(
253
- name: string,
254
- sourcePath: string,
255
- content: string,
256
- compiledPath: string
257
- ): Promise<BuildIslandEntry> {
258
- const framework = detectIslandFramework(sourcePath, content);
259
- const contentHash = await generateContentHash(content);
260
- const deps = extractIslandDependencies(content);
261
-
262
- return {
263
- src: compiledPath,
264
- framework,
265
- css: [], // Will be populated during build
266
- preload: deps.filter((d) => !d.includes("/")), // Only top-level packages
267
- sourcePath,
268
- chunkName: name,
269
- contentHash,
270
- preloadDeps: [],
271
- usesStreaming: false,
272
- };
273
- }
274
-
275
- /**
276
- * Generates preload hints for critical assets
277
- *
278
- * @param manifest - Island manifest
279
- * @returns Array of preload hints
280
- */
281
- export function generatePreloadHints(
282
- manifest: Partial<BuildIslandManifest>
283
- ): PreloadHint[] {
284
- const hints: PreloadHint[] = [];
285
-
286
- // Add client entry script
287
- if (manifest.clientEntry) {
288
- hints.push({
289
- href: manifest.clientEntry,
290
- as: "script",
291
- type: "text/javascript",
292
- });
293
- }
294
-
295
- // Add CSS assets
296
- for (const css of manifest.css ?? []) {
297
- hints.push({
298
- href: css,
299
- as: "style",
300
- type: "text/css",
301
- });
302
- }
303
-
304
- // Add framework bundles
305
- for (const bundle of Object.values(manifest.frameworkBundles ?? {})) {
306
- hints.push({
307
- href: bundle,
308
- as: "script",
309
- type: "text/javascript",
310
- });
311
- }
312
-
313
- return hints;
314
- }
315
-
316
- /**
317
- * Creates a Vite plugin for island manifest generation
318
- *
319
- * @param avalonConfig - Resolved Avalon configuration
320
- * @param options - Manifest generation options
321
- * @returns Vite plugin
322
- */
323
- export function createIslandManifestPlugin(
324
- avalonConfig: ResolvedAvalonConfig,
325
- options: IslandManifestOptions = {}
326
- ): Plugin {
327
- const opts = { ...DEFAULT_MANIFEST_OPTIONS, ...options };
328
- const islands: Map<string, BuildIslandEntry> = new Map();
329
- const cssAssets: Set<string> = new Set();
330
- let clientEntry = "";
331
-
332
- return {
333
- name: "avalon:island-manifest",
334
- enforce: "post",
335
-
336
- // Track island chunks during build
337
- generateBundle(_outputOptions, bundle) {
338
- for (const [fileName, chunk] of Object.entries(bundle)) {
339
- // Track CSS assets
340
- if (fileName.endsWith(".css")) {
341
- cssAssets.add(`/${fileName}`);
342
- }
343
-
344
- // Track client entry
345
- if (
346
- chunk.type === "chunk" &&
347
- (chunk.name === "client" || chunk.name === "main")
348
- ) {
349
- clientEntry = `/${fileName}`;
350
- }
351
-
352
- // Track island chunks
353
- if (
354
- chunk.type === "chunk" &&
355
- (chunk.facadeModuleId?.includes("/islands/") ||
356
- chunk.name?.startsWith("islands/"))
357
- ) {
358
- const name = chunk.name?.replace("islands/", "") ?? fileName;
359
- const existingEntry = islands.get(name);
360
-
361
- if (existingEntry) {
362
- // Update with compiled path
363
- existingEntry.src = `/${fileName}`;
364
- } else {
365
- // Create new entry
366
- islands.set(name, {
367
- src: `/${fileName}`,
368
- framework: detectFrameworkFromChunk(chunk),
369
- css: [],
370
- preload: [],
371
- sourcePath: chunk.facadeModuleId ?? "",
372
- chunkName: name,
373
- contentHash: /\.([a-f0-9]+)\.js$/.exec(fileName)?.[1] ?? "",
374
- preloadDeps: chunk.imports ?? [],
375
- usesStreaming: false,
376
- });
377
- }
378
- }
379
- }
380
- },
381
-
382
- // Write manifest after build
383
- async writeBundle(_options: unknown, bundle: Record<string, { type: string; source?: string | Uint8Array; code?: string }>) {
384
- // Generate asset metadata for Nitro's format (type, etag, mtime, size)
385
- const assetMetadata: Record<string, AssetMetadata> = {};
386
- const buildTime = new Date().toISOString();
387
-
388
- for (const [fileName, chunk] of Object.entries(bundle)) {
389
- const entry = await buildAssetMetadataEntry(fileName, chunk, buildTime);
390
- if (entry) assetMetadata[`/${fileName}`] = entry;
391
- }
392
-
393
- const manifest: BuildIslandManifest = {
394
- islands: Object.fromEntries(islands),
395
- clientEntry,
396
- css: Array.from(cssAssets),
397
- buildTime: Date.now(),
398
- buildHash: await generateContentHash(JSON.stringify(Object.fromEntries(islands))),
399
- avalonVersion: "1.0.0",
400
- cssAssets: Array.from(cssAssets),
401
- preloadHints: opts.generatePreloadHints
402
- ? generatePreloadHints({
403
- clientEntry,
404
- css: Array.from(cssAssets),
405
- islands: Object.fromEntries(islands),
406
- })
407
- : [],
408
- frameworkBundles: {},
409
- assetMetadata,
410
- };
411
-
412
- // Write manifest file
413
- const manifestJson = JSON.stringify(manifest, null, 2);
414
-
415
- if (opts.verbose) {
416
- console.log("📋 Island manifest generated:");
417
- console.log(` Islands: ${islands.size}`);
418
- console.log(` CSS assets: ${cssAssets.size}`);
419
- console.log(` Client entry: ${clientEntry}`);
420
- console.log(` Asset metadata entries: ${Object.keys(assetMetadata).length}`);
421
- }
422
-
423
- // Emit the manifest as an asset
424
- this.emitFile({
425
- type: "asset",
426
- fileName: "island-manifest.json",
427
- source: manifestJson,
428
- });
429
- },
430
- };
431
- }
432
-
433
- /**
434
- * Generates asset metadata entry for a single bundle chunk
435
- */
436
- async function buildAssetMetadataEntry(
437
- fileName: string,
438
- chunk: { type: string; source?: string | Uint8Array; code?: string },
439
- buildTime: string
440
- ): Promise<AssetMetadata | null> {
441
- let content: string | Uint8Array | undefined;
442
- if (chunk.type === "asset" && chunk.source) {
443
- content = chunk.source;
444
- } else if (chunk.type === "chunk" && chunk.code) {
445
- content = chunk.code;
446
- }
447
- if (!content) return null;
448
-
449
- const size = typeof content === "string"
450
- ? new TextEncoder().encode(content).length
451
- : content.length;
452
- const contentStr = typeof content === "string"
453
- ? content
454
- : new TextDecoder().decode(content);
455
- const etag = await generateContentHash(contentStr);
456
-
457
- const ext = fileName.substring(fileName.lastIndexOf("."));
458
- const mimeTypes: Record<string, string> = {
459
- ".js": "application/javascript",
460
- ".mjs": "application/javascript",
461
- ".css": "text/css",
462
- ".json": "application/json",
463
- ".html": "text/html",
464
- ".map": "application/json",
465
- };
466
-
467
- return {
468
- type: mimeTypes[ext] ?? "application/octet-stream",
469
- etag: `"${etag}"`,
470
- mtime: buildTime,
471
- size,
472
- };
473
- }
474
-
475
- /**
476
- * @param chunk - Rollup output chunk
477
- * @returns Detected framework name
478
- */
479
- function detectFrameworkFromChunk(chunk: {
480
- facadeModuleId?: string | null;
481
- code?: string;
482
- }): string {
483
- const filePath = chunk.facadeModuleId ?? "";
484
- const content = chunk.code ?? "";
485
-
486
- return detectIslandFramework(filePath, content);
487
- }
488
-
489
- /**
490
- * Loads an existing island manifest from disk
491
- *
492
- * @param manifestPath - Path to the manifest file
493
- * @returns Loaded manifest or null if not found
494
- */
495
- export async function loadIslandManifest(
496
- manifestPath: string = "dist/island-manifest.json"
497
- ): Promise<BuildIslandManifest | null> {
498
- try {
499
- const content = await readFile(manifestPath, "utf-8");
500
- return JSON.parse(content) as BuildIslandManifest;
501
- } catch {
502
- return null;
503
- }
504
- }
505
-
506
- /**
507
- * Gets the compiled asset path for an island
508
- *
509
- * @param islandName - Name of the island
510
- * @param manifest - Island manifest
511
- * @returns Compiled asset path or null if not found
512
- */
513
- export function getIslandAssetPath(
514
- islandName: string,
515
- manifest: BuildIslandManifest | null
516
- ): string | null {
517
- if (!manifest) return null;
518
-
519
- const entry = manifest.islands[islandName];
520
- return entry?.src ?? null;
521
- }
522
-
523
- /**
524
- * Gets all CSS assets that should be injected for a page
525
- *
526
- * @param islandNames - Names of islands used on the page
527
- * @param manifest - Island manifest
528
- * @returns Array of CSS asset paths
529
- */
530
- export function getPageCssAssets(
531
- islandNames: string[],
532
- manifest: BuildIslandManifest | null
533
- ): string[] {
534
- if (!manifest) return [];
535
-
536
- const cssAssets = new Set<string>();
537
-
538
- // Add global CSS
539
- for (const css of manifest.css) {
540
- cssAssets.add(css);
541
- }
542
-
543
- // Add island-specific CSS
544
- for (const name of islandNames) {
545
- const entry = manifest.islands[name];
546
- if (entry?.css) {
547
- for (const css of entry.css) {
548
- cssAssets.add(css);
549
- }
550
- }
551
- }
552
-
553
- return Array.from(cssAssets);
554
- }
555
-
556
- /**
557
- * Generates HTML preload tags for critical assets
558
- *
559
- * @param manifest - Island manifest
560
- * @returns HTML string with preload tags
561
- */
562
- export function generatePreloadTags(manifest: BuildIslandManifest): string {
563
- const tags: string[] = [];
564
-
565
- for (const hint of manifest.preloadHints) {
566
- const attrs = [
567
- `rel="preload"`,
568
- `href="${hint.href}"`,
569
- `as="${hint.as}"`,
570
- ];
571
-
572
- if (hint.type) {
573
- attrs.push(`type="${hint.type}"`);
574
- }
575
-
576
- if (hint.crossorigin) {
577
- attrs.push(`crossorigin="${hint.crossorigin}"`);
578
- }
579
-
580
- tags.push(`<link ${attrs.join(" ")}>`);
581
- }
582
-
583
- return tags.join("\n");
584
- }
1
+ /**
2
+ * Island Manifest Generation Module for Nitro
3
+ *
4
+ * This module provides island manifest generation during production builds.
5
+ * The manifest contains information about all discovered islands, their
6
+ * compiled asset paths, and framework metadata for client-side hydration.
7
+ *
8
+ * ## Build Output
9
+ *
10
+ * During production build, this module generates:
11
+ * - `island-manifest.json` in the build output directory
12
+ * - Contains metadata for all islands (src, framework, css, preload)
13
+ * - Includes build timestamp and content hashes for cache busting
14
+ * - Generates preload hints for critical assets
15
+ *
16
+ * ## Nitro Integration
17
+ *
18
+ * The manifest is used by Nitro's renderer to:
19
+ * - Resolve compiled island paths for hydration scripts
20
+ * - Inject CSS assets for islands used on a page
21
+ * - Generate preload tags for critical resources
22
+ *
23
+ * ## Asset Metadata
24
+ *
25
+ * Each island entry includes:
26
+ * - `src`: Compiled JavaScript path (e.g., `/islands/Counter.abc123.js`)
27
+ * - `framework`: Detected framework (react, preact, vue, svelte, solid, lit)
28
+ * - `css`: Associated CSS files
29
+ * - `contentHash`: Hash for cache busting
30
+ * - `preloadDeps`: Dependencies to preload
31
+ *
32
+ * @module nitro/island-manifest
33
+ */
34
+
35
+ import { readFile } from "node:fs/promises";
36
+ import type { Plugin } from "vite";
37
+ import type { ResolvedAvalonConfig } from "../vite-plugin/types.ts";
38
+ import type { IslandManifest, IslandEntry } from "./types.ts";
39
+
40
+ /**
41
+ * Extended island entry with build-time metadata
42
+ */
43
+ export interface BuildIslandEntry extends IslandEntry {
44
+ /** Original source file path */
45
+ sourcePath: string;
46
+ /** Compiled JavaScript chunk name */
47
+ chunkName: string;
48
+ /** Content hash for cache busting */
49
+ contentHash: string;
50
+ /** Dependencies that need to be preloaded */
51
+ preloadDeps: string[];
52
+ /** Whether the island uses streaming */
53
+ usesStreaming: boolean;
54
+ }
55
+
56
+ /**
57
+ * Asset metadata for Nitro's asset manifest format
58
+ * This matches Nitro's expected asset metadata structure
59
+ */
60
+ export interface AssetMetadata {
61
+ /** MIME type of the asset */
62
+ type: string;
63
+ /** ETag for cache validation */
64
+ etag: string;
65
+ /** Last modification time (ISO string) */
66
+ mtime: string;
67
+ /** File size in bytes */
68
+ size: number;
69
+ }
70
+
71
+ /**
72
+ * Build-time island manifest with additional metadata
73
+ */
74
+ export interface BuildIslandManifest extends IslandManifest {
75
+ /** Build timestamp */
76
+ buildTime: number;
77
+ /** Build version/hash */
78
+ buildHash: string;
79
+ /** Avalon version */
80
+ avalonVersion: string;
81
+ /** All CSS assets to inject */
82
+ cssAssets: string[];
83
+ /** Preload hints for critical assets */
84
+ preloadHints: PreloadHint[];
85
+ /** Framework-specific bundles */
86
+ frameworkBundles: Record<string, string>;
87
+ /** Asset metadata for cache headers (Nitro format) */
88
+ assetMetadata?: Record<string, AssetMetadata>;
89
+ }
90
+
91
+ /**
92
+ * Preload hint for resource optimization
93
+ */
94
+ export interface PreloadHint {
95
+ /** Resource URL */
96
+ href: string;
97
+ /** Resource type (script, style, font, etc.) */
98
+ as: "script" | "style" | "font" | "image";
99
+ /** MIME type */
100
+ type?: string;
101
+ /** Cross-origin setting */
102
+ crossorigin?: "anonymous" | "use-credentials";
103
+ }
104
+
105
+ /**
106
+ * Options for island manifest generation
107
+ */
108
+ export interface IslandManifestOptions {
109
+ /** Output path for the manifest file */
110
+ outputPath?: string;
111
+ /** Include source maps in manifest */
112
+ includeSourceMaps?: boolean;
113
+ /** Generate preload hints */
114
+ generatePreloadHints?: boolean;
115
+ /** Verbose logging */
116
+ verbose?: boolean;
117
+ }
118
+
119
+ /**
120
+ * Default manifest generation options
121
+ */
122
+ export const DEFAULT_MANIFEST_OPTIONS: Required<IslandManifestOptions> = {
123
+ outputPath: "dist/island-manifest.json",
124
+ includeSourceMaps: false,
125
+ generatePreloadHints: true,
126
+ verbose: false,
127
+ };
128
+
129
+ /**
130
+ * Framework detection patterns for island files
131
+ */
132
+ const FRAMEWORK_PATTERNS: Record<string, RegExp[]> = {
133
+ react: [
134
+ /from\s+['"]react['"]/,
135
+ /from\s+['"]react-dom['"]/,
136
+ /@jsxImportSource\s+react/,
137
+ ],
138
+ preact: [
139
+ /from\s+['"]preact['"]/,
140
+ /from\s+['"]preact\/hooks['"]/,
141
+ /@jsxImportSource\s+preact/,
142
+ ],
143
+ vue: [
144
+ /from\s+['"]vue['"]/,
145
+ /\.vue$/,
146
+ ],
147
+ svelte: [
148
+ /from\s+['"]svelte['"]/,
149
+ /\.svelte$/,
150
+ ],
151
+ solid: [
152
+ /from\s+['"]solid-js['"]/,
153
+ /\.solid\.(tsx|jsx)$/,
154
+ ],
155
+ lit: [
156
+ /from\s+['"]lit['"]/,
157
+ /from\s+['"]@lit['"]/,
158
+ /\.lit\.(ts|js)$/,
159
+ ],
160
+ };
161
+
162
+ /**
163
+ * Detects the framework used by an island based on file content and extension
164
+ *
165
+ * @param filePath - Path to the island file
166
+ * @param content - File content
167
+ * @returns Detected framework name
168
+ */
169
+ export function detectIslandFramework(
170
+ filePath: string,
171
+ content: string
172
+ ): string {
173
+ // Check file extension first
174
+ if (filePath.endsWith(".vue")) return "vue";
175
+ if (filePath.endsWith(".svelte")) return "svelte";
176
+ if (filePath.includes(".solid.")) return "solid";
177
+ if (filePath.includes(".lit.")) return "lit";
178
+
179
+ // Check content patterns
180
+ for (const [framework, patterns] of Object.entries(FRAMEWORK_PATTERNS)) {
181
+ for (const pattern of patterns) {
182
+ if (pattern.test(content) || pattern.test(filePath)) {
183
+ return framework;
184
+ }
185
+ }
186
+ }
187
+
188
+ // Default to preact for JSX/TSX files
189
+ if (filePath.endsWith(".tsx") || filePath.endsWith(".jsx")) {
190
+ return "preact";
191
+ }
192
+
193
+ return "unknown";
194
+ }
195
+
196
+ /**
197
+ * Generates a content hash for cache busting
198
+ *
199
+ * @param content - Content to hash
200
+ * @returns Short hash string
201
+ */
202
+ export async function generateContentHash(content: string): Promise<string> {
203
+ const encoder = new TextEncoder();
204
+ const data = encoder.encode(content);
205
+ const hashBuffer = await crypto.subtle.digest("SHA-256", data);
206
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
207
+ const hashHex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
208
+ return hashHex.slice(0, 8);
209
+ }
210
+
211
+ /**
212
+ * Extracts dependencies from island file content
213
+ *
214
+ * @param content - File content
215
+ * @returns Array of dependency names
216
+ */
217
+ export function extractIslandDependencies(content: string): string[] {
218
+ const deps: string[] = [];
219
+ const importRegex = /import\s+.*?\s+from\s+['"]([^'"]+)['"]/g;
220
+ const dynamicImportRegex = /import\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
221
+
222
+ let match;
223
+
224
+ // Static imports
225
+ while ((match = importRegex.exec(content)) !== null) {
226
+ const importPath = match[1];
227
+ if (!importPath.startsWith(".") && !importPath.startsWith("/")) {
228
+ deps.push(importPath);
229
+ }
230
+ }
231
+
232
+ // Dynamic imports
233
+ while ((match = dynamicImportRegex.exec(content)) !== null) {
234
+ const importPath = match[1];
235
+ if (!importPath.startsWith(".") && !importPath.startsWith("/")) {
236
+ deps.push(importPath);
237
+ }
238
+ }
239
+
240
+ return [...new Set(deps)];
241
+ }
242
+
243
+ /**
244
+ * Creates an island entry from discovered island information
245
+ *
246
+ * @param name - Island name
247
+ * @param sourcePath - Source file path
248
+ * @param content - File content
249
+ * @param compiledPath - Compiled asset path
250
+ * @returns Island entry
251
+ */
252
+ export async function createIslandEntry(
253
+ name: string,
254
+ sourcePath: string,
255
+ content: string,
256
+ compiledPath: string
257
+ ): Promise<BuildIslandEntry> {
258
+ const framework = detectIslandFramework(sourcePath, content);
259
+ const contentHash = await generateContentHash(content);
260
+ const deps = extractIslandDependencies(content);
261
+
262
+ return {
263
+ src: compiledPath,
264
+ framework,
265
+ css: [], // Will be populated during build
266
+ preload: deps.filter((d) => !d.includes("/")), // Only top-level packages
267
+ sourcePath,
268
+ chunkName: name,
269
+ contentHash,
270
+ preloadDeps: [],
271
+ usesStreaming: false,
272
+ };
273
+ }
274
+
275
+ /**
276
+ * Generates preload hints for critical assets
277
+ *
278
+ * @param manifest - Island manifest
279
+ * @returns Array of preload hints
280
+ */
281
+ export function generatePreloadHints(
282
+ manifest: Partial<BuildIslandManifest>
283
+ ): PreloadHint[] {
284
+ const hints: PreloadHint[] = [];
285
+
286
+ // Add client entry script
287
+ if (manifest.clientEntry) {
288
+ hints.push({
289
+ href: manifest.clientEntry,
290
+ as: "script",
291
+ type: "text/javascript",
292
+ });
293
+ }
294
+
295
+ // Add CSS assets
296
+ for (const css of manifest.css ?? []) {
297
+ hints.push({
298
+ href: css,
299
+ as: "style",
300
+ type: "text/css",
301
+ });
302
+ }
303
+
304
+ // Add framework bundles
305
+ for (const bundle of Object.values(manifest.frameworkBundles ?? {})) {
306
+ hints.push({
307
+ href: bundle,
308
+ as: "script",
309
+ type: "text/javascript",
310
+ });
311
+ }
312
+
313
+ return hints;
314
+ }
315
+
316
+ /**
317
+ * Creates a Vite plugin for island manifest generation
318
+ *
319
+ * @param avalonConfig - Resolved Avalon configuration
320
+ * @param options - Manifest generation options
321
+ * @returns Vite plugin
322
+ */
323
+ export function createIslandManifestPlugin(
324
+ avalonConfig: ResolvedAvalonConfig,
325
+ options: IslandManifestOptions = {}
326
+ ): Plugin {
327
+ const opts = { ...DEFAULT_MANIFEST_OPTIONS, ...options };
328
+ const islands: Map<string, BuildIslandEntry> = new Map();
329
+ const cssAssets: Set<string> = new Set();
330
+ let clientEntry = "";
331
+
332
+ return {
333
+ name: "avalon:island-manifest",
334
+ enforce: "post",
335
+
336
+ // Track island chunks during build
337
+ generateBundle(_outputOptions, bundle) {
338
+ for (const [fileName, chunk] of Object.entries(bundle)) {
339
+ // Track CSS assets
340
+ if (fileName.endsWith(".css")) {
341
+ cssAssets.add(`/${fileName}`);
342
+ }
343
+
344
+ // Track client entry
345
+ if (
346
+ chunk.type === "chunk" &&
347
+ (chunk.name === "client" || chunk.name === "main")
348
+ ) {
349
+ clientEntry = `/${fileName}`;
350
+ }
351
+
352
+ // Track island chunks
353
+ if (
354
+ chunk.type === "chunk" &&
355
+ (chunk.facadeModuleId?.includes("/islands/") ||
356
+ chunk.name?.startsWith("islands/"))
357
+ ) {
358
+ const name = chunk.name?.replace("islands/", "") ?? fileName;
359
+ const existingEntry = islands.get(name);
360
+
361
+ if (existingEntry) {
362
+ // Update with compiled path
363
+ existingEntry.src = `/${fileName}`;
364
+ } else {
365
+ // Create new entry
366
+ islands.set(name, {
367
+ src: `/${fileName}`,
368
+ framework: detectFrameworkFromChunk(chunk),
369
+ css: [],
370
+ preload: [],
371
+ sourcePath: chunk.facadeModuleId ?? "",
372
+ chunkName: name,
373
+ contentHash: /\.([a-f0-9]+)\.js$/.exec(fileName)?.[1] ?? "",
374
+ preloadDeps: chunk.imports ?? [],
375
+ usesStreaming: false,
376
+ });
377
+ }
378
+ }
379
+ }
380
+ },
381
+
382
+ // Write manifest after build
383
+ async writeBundle(_options: unknown, bundle: Record<string, { type: string; source?: string | Uint8Array; code?: string }>) {
384
+ // Generate asset metadata for Nitro's format (type, etag, mtime, size)
385
+ const assetMetadata: Record<string, AssetMetadata> = {};
386
+ const buildTime = new Date().toISOString();
387
+
388
+ for (const [fileName, chunk] of Object.entries(bundle)) {
389
+ const entry = await buildAssetMetadataEntry(fileName, chunk, buildTime);
390
+ if (entry) assetMetadata[`/${fileName}`] = entry;
391
+ }
392
+
393
+ const manifest: BuildIslandManifest = {
394
+ islands: Object.fromEntries(islands),
395
+ clientEntry,
396
+ css: Array.from(cssAssets),
397
+ buildTime: Date.now(),
398
+ buildHash: await generateContentHash(JSON.stringify(Object.fromEntries(islands))),
399
+ avalonVersion: "1.0.0",
400
+ cssAssets: Array.from(cssAssets),
401
+ preloadHints: opts.generatePreloadHints
402
+ ? generatePreloadHints({
403
+ clientEntry,
404
+ css: Array.from(cssAssets),
405
+ islands: Object.fromEntries(islands),
406
+ })
407
+ : [],
408
+ frameworkBundles: {},
409
+ assetMetadata,
410
+ };
411
+
412
+ // Write manifest file
413
+ const manifestJson = JSON.stringify(manifest, null, 2);
414
+
415
+ if (opts.verbose) {
416
+ console.log("📋 Island manifest generated:");
417
+ console.log(` Islands: ${islands.size}`);
418
+ console.log(` CSS assets: ${cssAssets.size}`);
419
+ console.log(` Client entry: ${clientEntry}`);
420
+ console.log(` Asset metadata entries: ${Object.keys(assetMetadata).length}`);
421
+ }
422
+
423
+ // Emit the manifest as an asset
424
+ this.emitFile({
425
+ type: "asset",
426
+ fileName: "island-manifest.json",
427
+ source: manifestJson,
428
+ });
429
+ },
430
+ };
431
+ }
432
+
433
+ /**
434
+ * Generates asset metadata entry for a single bundle chunk
435
+ */
436
+ async function buildAssetMetadataEntry(
437
+ fileName: string,
438
+ chunk: { type: string; source?: string | Uint8Array; code?: string },
439
+ buildTime: string
440
+ ): Promise<AssetMetadata | null> {
441
+ let content: string | Uint8Array | undefined;
442
+ if (chunk.type === "asset" && chunk.source) {
443
+ content = chunk.source;
444
+ } else if (chunk.type === "chunk" && chunk.code) {
445
+ content = chunk.code;
446
+ }
447
+ if (!content) return null;
448
+
449
+ const size = typeof content === "string"
450
+ ? new TextEncoder().encode(content).length
451
+ : content.length;
452
+ const contentStr = typeof content === "string"
453
+ ? content
454
+ : new TextDecoder().decode(content);
455
+ const etag = await generateContentHash(contentStr);
456
+
457
+ const ext = fileName.substring(fileName.lastIndexOf("."));
458
+ const mimeTypes: Record<string, string> = {
459
+ ".js": "application/javascript",
460
+ ".mjs": "application/javascript",
461
+ ".css": "text/css",
462
+ ".json": "application/json",
463
+ ".html": "text/html",
464
+ ".map": "application/json",
465
+ };
466
+
467
+ return {
468
+ type: mimeTypes[ext] ?? "application/octet-stream",
469
+ etag: `"${etag}"`,
470
+ mtime: buildTime,
471
+ size,
472
+ };
473
+ }
474
+
475
+ /**
476
+ * @param chunk - Rollup output chunk
477
+ * @returns Detected framework name
478
+ */
479
+ function detectFrameworkFromChunk(chunk: {
480
+ facadeModuleId?: string | null;
481
+ code?: string;
482
+ }): string {
483
+ const filePath = chunk.facadeModuleId ?? "";
484
+ const content = chunk.code ?? "";
485
+
486
+ return detectIslandFramework(filePath, content);
487
+ }
488
+
489
+ /**
490
+ * Loads an existing island manifest from disk
491
+ *
492
+ * @param manifestPath - Path to the manifest file
493
+ * @returns Loaded manifest or null if not found
494
+ */
495
+ export async function loadIslandManifest(
496
+ manifestPath: string = "dist/island-manifest.json"
497
+ ): Promise<BuildIslandManifest | null> {
498
+ try {
499
+ const content = await readFile(manifestPath, "utf-8");
500
+ return JSON.parse(content) as BuildIslandManifest;
501
+ } catch {
502
+ return null;
503
+ }
504
+ }
505
+
506
+ /**
507
+ * Gets the compiled asset path for an island
508
+ *
509
+ * @param islandName - Name of the island
510
+ * @param manifest - Island manifest
511
+ * @returns Compiled asset path or null if not found
512
+ */
513
+ export function getIslandAssetPath(
514
+ islandName: string,
515
+ manifest: BuildIslandManifest | null
516
+ ): string | null {
517
+ if (!manifest) return null;
518
+
519
+ const entry = manifest.islands[islandName];
520
+ return entry?.src ?? null;
521
+ }
522
+
523
+ /**
524
+ * Gets all CSS assets that should be injected for a page
525
+ *
526
+ * @param islandNames - Names of islands used on the page
527
+ * @param manifest - Island manifest
528
+ * @returns Array of CSS asset paths
529
+ */
530
+ export function getPageCssAssets(
531
+ islandNames: string[],
532
+ manifest: BuildIslandManifest | null
533
+ ): string[] {
534
+ if (!manifest) return [];
535
+
536
+ const cssAssets = new Set<string>();
537
+
538
+ // Add global CSS
539
+ for (const css of manifest.css) {
540
+ cssAssets.add(css);
541
+ }
542
+
543
+ // Add island-specific CSS
544
+ for (const name of islandNames) {
545
+ const entry = manifest.islands[name];
546
+ if (entry?.css) {
547
+ for (const css of entry.css) {
548
+ cssAssets.add(css);
549
+ }
550
+ }
551
+ }
552
+
553
+ return Array.from(cssAssets);
554
+ }
555
+
556
+ /**
557
+ * Generates HTML preload tags for critical assets
558
+ *
559
+ * @param manifest - Island manifest
560
+ * @returns HTML string with preload tags
561
+ */
562
+ export function generatePreloadTags(manifest: BuildIslandManifest): string {
563
+ const tags: string[] = [];
564
+
565
+ for (const hint of manifest.preloadHints) {
566
+ const attrs = [
567
+ `rel="preload"`,
568
+ `href="${hint.href}"`,
569
+ `as="${hint.as}"`,
570
+ ];
571
+
572
+ if (hint.type) {
573
+ attrs.push(`type="${hint.type}"`);
574
+ }
575
+
576
+ if (hint.crossorigin) {
577
+ attrs.push(`crossorigin="${hint.crossorigin}"`);
578
+ }
579
+
580
+ tags.push(`<link ${attrs.join(" ")}>`);
581
+ }
582
+
583
+ return tags.join("\n");
584
+ }