@useavalon/avalon 0.1.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 (159) hide show
  1. package/README.md +54 -0
  2. package/mod.ts +301 -0
  3. package/package.json +85 -0
  4. package/src/build/README.md +310 -0
  5. package/src/build/integration-bundler-plugin.ts +116 -0
  6. package/src/build/integration-config.ts +168 -0
  7. package/src/build/integration-detection-plugin.ts +117 -0
  8. package/src/build/integration-resolver-plugin.ts +90 -0
  9. package/src/build/island-manifest.ts +269 -0
  10. package/src/build/island-types-generator.ts +476 -0
  11. package/src/build/mdx-island-transform.ts +464 -0
  12. package/src/build/mdx-plugin.ts +98 -0
  13. package/src/build/page-island-transform.ts +598 -0
  14. package/src/build/prop-extractors/index.ts +21 -0
  15. package/src/build/prop-extractors/lit.ts +140 -0
  16. package/src/build/prop-extractors/qwik.ts +16 -0
  17. package/src/build/prop-extractors/solid.ts +125 -0
  18. package/src/build/prop-extractors/svelte.ts +194 -0
  19. package/src/build/prop-extractors/vue.ts +111 -0
  20. package/src/build/sidecar-file-manager.ts +104 -0
  21. package/src/build/sidecar-renderer.ts +30 -0
  22. package/src/client/adapters/index.ts +13 -0
  23. package/src/client/adapters/lit-adapter.ts +654 -0
  24. package/src/client/adapters/preact-adapter.ts +331 -0
  25. package/src/client/adapters/qwik-adapter.ts +345 -0
  26. package/src/client/adapters/react-adapter.ts +353 -0
  27. package/src/client/adapters/solid-adapter.ts +451 -0
  28. package/src/client/adapters/svelte-adapter.ts +524 -0
  29. package/src/client/adapters/vue-adapter.ts +467 -0
  30. package/src/client/components.ts +35 -0
  31. package/src/client/css-hmr-handler.ts +344 -0
  32. package/src/client/framework-adapter.ts +462 -0
  33. package/src/client/hmr-coordinator.ts +396 -0
  34. package/src/client/hmr-error-overlay.js +533 -0
  35. package/src/client/main.js +816 -0
  36. package/src/client/tests/css-hmr-handler.test.ts +360 -0
  37. package/src/client/tests/framework-adapter.test.ts +519 -0
  38. package/src/client/tests/hmr-coordinator.test.ts +176 -0
  39. package/src/client/tests/hydration-option-parsing.test.ts +107 -0
  40. package/src/client/tests/lit-adapter.test.ts +427 -0
  41. package/src/client/tests/preact-adapter.test.ts +353 -0
  42. package/src/client/tests/qwik-adapter.test.ts +343 -0
  43. package/src/client/tests/react-adapter.test.ts +317 -0
  44. package/src/client/tests/solid-adapter.test.ts +396 -0
  45. package/src/client/tests/svelte-adapter.test.ts +387 -0
  46. package/src/client/tests/vue-adapter.test.ts +407 -0
  47. package/src/client/types/framework-runtime.d.ts +68 -0
  48. package/src/client/types/vite-hmr.d.ts +46 -0
  49. package/src/client/types/vite-virtual-modules.d.ts +60 -0
  50. package/src/components/Image.tsx +123 -0
  51. package/src/components/IslandErrorBoundary.tsx +145 -0
  52. package/src/components/LayoutDataErrorBoundary.tsx +141 -0
  53. package/src/components/LayoutErrorBoundary.tsx +127 -0
  54. package/src/components/PersistentIsland.tsx +52 -0
  55. package/src/components/StreamingErrorBoundary.tsx +233 -0
  56. package/src/components/StreamingLayout.tsx +538 -0
  57. package/src/components/tests/component-analyzer.test.ts +96 -0
  58. package/src/components/tests/component-detection.test.ts +347 -0
  59. package/src/components/tests/persistent-islands.test.ts +398 -0
  60. package/src/core/components/component-analyzer.ts +192 -0
  61. package/src/core/components/component-detection.ts +508 -0
  62. package/src/core/components/enhanced-framework-detector.ts +500 -0
  63. package/src/core/components/framework-registry.ts +563 -0
  64. package/src/core/components/tests/enhanced-framework-detector.test.ts +577 -0
  65. package/src/core/components/tests/framework-registry.test.ts +465 -0
  66. package/src/core/content/mdx-processor.ts +46 -0
  67. package/src/core/integrations/README.md +282 -0
  68. package/src/core/integrations/index.ts +19 -0
  69. package/src/core/integrations/loader.ts +125 -0
  70. package/src/core/integrations/registry.ts +195 -0
  71. package/src/core/islands/island-persistence.ts +325 -0
  72. package/src/core/islands/island-state-serializer.ts +258 -0
  73. package/src/core/islands/persistent-island-context.tsx +80 -0
  74. package/src/core/islands/use-persistent-state.ts +68 -0
  75. package/src/core/layout/enhanced-layout-resolver.ts +322 -0
  76. package/src/core/layout/layout-cache-manager.ts +485 -0
  77. package/src/core/layout/layout-composer.ts +357 -0
  78. package/src/core/layout/layout-data-loader.ts +516 -0
  79. package/src/core/layout/layout-discovery.ts +243 -0
  80. package/src/core/layout/layout-matcher.ts +299 -0
  81. package/src/core/layout/layout-types.ts +110 -0
  82. package/src/core/layout/tests/enhanced-layout-resolver.test.ts +477 -0
  83. package/src/core/layout/tests/layout-cache-optimization.test.ts +149 -0
  84. package/src/core/layout/tests/layout-composer.test.ts +486 -0
  85. package/src/core/layout/tests/layout-data-loader.test.ts +443 -0
  86. package/src/core/layout/tests/layout-discovery.test.ts +253 -0
  87. package/src/core/layout/tests/layout-matcher.test.ts +480 -0
  88. package/src/core/modules/framework-module-resolver.ts +273 -0
  89. package/src/core/modules/tests/framework-module-resolver.test.ts +263 -0
  90. package/src/core/modules/tests/module-resolution-integration.test.ts +117 -0
  91. package/src/islands/component-analysis.ts +213 -0
  92. package/src/islands/css-utils.ts +565 -0
  93. package/src/islands/discovery/index.ts +80 -0
  94. package/src/islands/discovery/registry.ts +340 -0
  95. package/src/islands/discovery/resolver.ts +477 -0
  96. package/src/islands/discovery/scanner.ts +386 -0
  97. package/src/islands/discovery/tests/island-discovery.test.ts +881 -0
  98. package/src/islands/discovery/types.ts +117 -0
  99. package/src/islands/discovery/validator.ts +544 -0
  100. package/src/islands/discovery/watcher.ts +368 -0
  101. package/src/islands/framework-detection.ts +428 -0
  102. package/src/islands/integration-loader.ts +490 -0
  103. package/src/islands/island.tsx +565 -0
  104. package/src/islands/render-cache.ts +550 -0
  105. package/src/islands/types.ts +80 -0
  106. package/src/islands/universal-css-collector.ts +157 -0
  107. package/src/islands/universal-head-collector.ts +137 -0
  108. package/src/layout-system.d.ts +592 -0
  109. package/src/layout-system.ts +218 -0
  110. package/src/middleware/__tests__/discovery.test.ts +107 -0
  111. package/src/middleware/discovery.ts +268 -0
  112. package/src/middleware/executor.ts +315 -0
  113. package/src/middleware/index.ts +76 -0
  114. package/src/middleware/types.ts +99 -0
  115. package/src/nitro/build-config.ts +576 -0
  116. package/src/nitro/config.ts +483 -0
  117. package/src/nitro/error-handler.ts +636 -0
  118. package/src/nitro/index.ts +173 -0
  119. package/src/nitro/island-manifest.ts +584 -0
  120. package/src/nitro/middleware-adapter.ts +260 -0
  121. package/src/nitro/renderer.ts +1458 -0
  122. package/src/nitro/route-discovery.ts +439 -0
  123. package/src/nitro/types.ts +321 -0
  124. package/src/render/collect-css.ts +198 -0
  125. package/src/render/error-pages.ts +79 -0
  126. package/src/render/isolated-ssr-renderer.ts +654 -0
  127. package/src/render/ssr.ts +1030 -0
  128. package/src/schemas/api.ts +30 -0
  129. package/src/schemas/core.ts +64 -0
  130. package/src/schemas/index.ts +212 -0
  131. package/src/schemas/layout.ts +279 -0
  132. package/src/schemas/routing/index.ts +38 -0
  133. package/src/schemas/routing.ts +376 -0
  134. package/src/types/as-island.ts +20 -0
  135. package/src/types/image.d.ts +106 -0
  136. package/src/types/index.d.ts +22 -0
  137. package/src/types/island-jsx.d.ts +33 -0
  138. package/src/types/island-prop.d.ts +20 -0
  139. package/src/types/layout.ts +285 -0
  140. package/src/types/mdx.d.ts +6 -0
  141. package/src/types/routing.ts +555 -0
  142. package/src/types/tests/layout-types.test.ts +197 -0
  143. package/src/types/types.ts +5 -0
  144. package/src/types/urlpattern.d.ts +49 -0
  145. package/src/types/vite-env.d.ts +11 -0
  146. package/src/utils/dev-logger.ts +299 -0
  147. package/src/utils/fs.ts +151 -0
  148. package/src/vite-plugin/auto-discover.ts +551 -0
  149. package/src/vite-plugin/config.ts +266 -0
  150. package/src/vite-plugin/errors.ts +127 -0
  151. package/src/vite-plugin/image-optimization.ts +151 -0
  152. package/src/vite-plugin/integration-activator.ts +126 -0
  153. package/src/vite-plugin/island-sidecar-plugin.ts +176 -0
  154. package/src/vite-plugin/module-discovery.ts +189 -0
  155. package/src/vite-plugin/nitro-integration.ts +1334 -0
  156. package/src/vite-plugin/plugin.ts +329 -0
  157. package/src/vite-plugin/tests/image-optimization.test.ts +54 -0
  158. package/src/vite-plugin/types.ts +327 -0
  159. package/src/vite-plugin/validation.ts +228 -0
@@ -0,0 +1,598 @@
1
+ /**
2
+ * Page Island Transform Plugin
3
+ *
4
+ * Transforms components with an `island` prop in TSX/JSX page files so that developers
5
+ * can use any component as an island by simply adding the `island` prop.
6
+ *
7
+ * Before (manual):
8
+ * import { renderIsland } from '@useavalon/avalon';
9
+ * {await renderIsland({ src: '/src/components/Counter.tsx', condition: 'on:interaction', framework: 'preact' })}
10
+ *
11
+ * After (auto-wrapped):
12
+ * import Counter from '../components/Counter.tsx';
13
+ * <Counter island={{ condition: 'on:interaction' }} someProp={42} />
14
+ *
15
+ * How it works:
16
+ * The plugin rewrites each `<Component island={opts} ...props />` JSX usage
17
+ * into an `{await renderIsland({...})}` expression inline in the JSX.
18
+ * Any component can be an island - no special directory required.
19
+ *
20
+ * Preact's renderToString does NOT support async child components in the JSX
21
+ * tree, so we cannot use async wrapper functions. Instead we directly replace
22
+ * the JSX element with an await expression.
23
+ *
24
+ * Only applies to files inside the configured pages or layouts directories.
25
+ */
26
+
27
+ import type { Plugin } from 'vite';
28
+ import { dirname } from 'node:path';
29
+
30
+ export interface PageIslandTransformOptions {
31
+ /** Directory containing page files (default: src/pages/) */
32
+ pagesDir?: string;
33
+ /** Directory containing layout files (default: src/layouts/) */
34
+ layoutsDir?: string;
35
+ /** Modules configuration for modular architecture */
36
+ modules?: {
37
+ dir: string;
38
+ pagesDirName: string;
39
+ layoutsDirName: string;
40
+ } | null;
41
+ /** Whether to enable verbose logging */
42
+ verbose?: boolean;
43
+ }
44
+
45
+ interface ComponentImport {
46
+ localName: string;
47
+ importPath: string;
48
+ fullMatch: string;
49
+ }
50
+
51
+ interface ParsedJSXElement {
52
+ endIdx: number;
53
+ islandProp: string | null;
54
+ otherProps: string[];
55
+ }
56
+
57
+ interface ParsedAttribute {
58
+ name: string;
59
+ value: string | null;
60
+ endIdx: number;
61
+ }
62
+
63
+ // ─── Import Discovery ────────────────────────────────────────────────
64
+
65
+ /**
66
+ * Find all default imports in the code (any component import, not filtered by path)
67
+ */
68
+ function findAllDefaultImports(code: string): ComponentImport[] {
69
+ const imports: ComponentImport[] = [];
70
+ const re = /^[ \t]*import\s+([A-Z]\w*)\s+from\s+(['"][^'"]+['"])/gm;
71
+ let m;
72
+ while ((m = re.exec(code)) !== null) {
73
+ imports.push({
74
+ localName: m[1],
75
+ importPath: m[2].slice(1, -1),
76
+ fullMatch: m[0].trimStart(),
77
+ });
78
+ }
79
+ return imports;
80
+ }
81
+
82
+ /**
83
+ * Resolve an import path to an absolute src path for renderIsland
84
+ */
85
+ function resolveIslandSrc(importPath: string, fileId: string): string {
86
+ // Already absolute
87
+ if (importPath.startsWith('/src/')) return importPath;
88
+ if (importPath.startsWith('/app/')) return importPath;
89
+ if (importPath.startsWith('/')) return importPath;
90
+
91
+ // Handle aliases - convert to absolute paths
92
+ if (importPath.startsWith('@/')) {
93
+ return '/app/' + importPath.slice(2);
94
+ }
95
+ if (importPath.startsWith('@shared/')) {
96
+ return '/app/shared/' + importPath.slice(8);
97
+ }
98
+ if (importPath.startsWith('@modules/')) {
99
+ return '/app/modules/' + importPath.slice(9);
100
+ }
101
+ if (importPath.startsWith('$components/')) {
102
+ return '/src/components/' + importPath.slice(12);
103
+ }
104
+ if (importPath.startsWith('$islands/')) {
105
+ return '/src/islands/' + importPath.slice(9);
106
+ }
107
+ if (importPath.startsWith('~/')) {
108
+ return '/src/' + importPath.slice(2);
109
+ }
110
+
111
+ // Relative import - resolve relative to the file
112
+ if (importPath.startsWith('.')) {
113
+ const normalized = fileId.replaceAll('\\', '/');
114
+
115
+ // Try to find /app/ or /src/ in the path
116
+ let baseIndex = normalized.indexOf('/app/');
117
+ if (baseIndex === -1) baseIndex = normalized.indexOf('/src/');
118
+
119
+ if (baseIndex !== -1) {
120
+ const fileDir = dirname(normalized.slice(baseIndex));
121
+ // Simple path resolution
122
+ const parts = fileDir.split('/');
123
+ const importParts = importPath.split('/');
124
+
125
+ for (const part of importParts) {
126
+ if (part === '..') {
127
+ parts.pop();
128
+ } else if (part !== '.') {
129
+ parts.push(part);
130
+ }
131
+ }
132
+
133
+ return parts.join('/');
134
+ }
135
+ }
136
+
137
+ // Fallback: return as-is with /src/ prefix
138
+ return '/src/' + importPath.split('/').pop();
139
+ }
140
+
141
+ function detectFramework(src: string): string | undefined {
142
+ if (src.endsWith('.vue')) return 'vue';
143
+ if (src.endsWith('.svelte')) return 'svelte';
144
+ if (src.includes('.solid.')) return 'solid';
145
+ if (src.includes('.lit.')) return 'lit';
146
+ if (src.includes('.qwik.')) return 'qwik';
147
+ return undefined;
148
+ }
149
+
150
+ function isPageFile(id: string, pagesDir: string, modules?: PageIslandTransformOptions['modules']): boolean {
151
+ const normalized = id.replaceAll('\\', '/');
152
+
153
+ // Check traditional pages directory
154
+ const dir = pagesDir.replace(/^\//, '');
155
+ if (normalized.includes('/' + dir + '/') && /\.(tsx|jsx)$/.test(normalized)) {
156
+ return true;
157
+ }
158
+
159
+ // Check modular pages directories
160
+ if (modules) {
161
+ const modulesDir = modules.dir.replace(/^\//, '');
162
+ // Pattern: /modules/*/pages/
163
+ const modulePagePattern = new RegExp('/' + modulesDir + '/[^/]+/' + modules.pagesDirName + '/');
164
+ if (modulePagePattern.test(normalized) && /\.(tsx|jsx)$/.test(normalized)) {
165
+ return true;
166
+ }
167
+ }
168
+
169
+ return false;
170
+ }
171
+
172
+ /** Check whether a file is inside the layouts directory */
173
+ function isLayoutFile(id: string, layoutsDir: string, modules?: PageIslandTransformOptions['modules']): boolean {
174
+ const normalized = id.replaceAll('\\', '/');
175
+
176
+ // Check traditional layouts directory
177
+ const dir = layoutsDir.replace(/^\//, '');
178
+ if (normalized.includes('/' + dir + '/') && /\.(tsx|jsx)$/.test(normalized)) {
179
+ return true;
180
+ }
181
+
182
+ // Check modular layouts directories
183
+ if (modules) {
184
+ const modulesDir = modules.dir.replace(/^\//, '');
185
+ // Pattern: /modules/*/layouts/
186
+ const moduleLayoutPattern = new RegExp('/' + modulesDir + '/[^/]+/' + modules.layoutsDirName + '/');
187
+ if (moduleLayoutPattern.test(normalized) && /\.(tsx|jsx)$/.test(normalized)) {
188
+ return true;
189
+ }
190
+ }
191
+
192
+ return false;
193
+ }
194
+
195
+ /** Frameworks that are auto-wrapped as islands without requiring the `island` prop.
196
+ * Qwik is resumable — it gets SSR'd with ssrOnly:true and the Qwikloader handles the rest. */
197
+ const AUTO_ISLAND_FRAMEWORKS = new Set(['qwik']);
198
+
199
+ /** Check if a component import is for an auto-island framework */
200
+ function isAutoIslandImport(importPath: string): boolean {
201
+ const src = importPath; // raw import path, not resolved
202
+ const framework = detectFramework(src);
203
+ return framework !== undefined && AUTO_ISLAND_FRAMEWORKS.has(framework);
204
+ }
205
+
206
+ function hasIslandPropUsage(code: string, componentNames: string[]): boolean {
207
+ return componentNames.some((name) => {
208
+ const pattern = new RegExp('<' + name + String.raw`[\s][^>]*island[\s]*[={]`);
209
+ return pattern.test(code);
210
+ });
211
+ }
212
+
213
+ /** Check if any auto-island components are used as JSX elements */
214
+ function hasAutoIslandUsage(code: string, imports: ComponentImport[]): boolean {
215
+ return imports.some((imp) => {
216
+ if (!isAutoIslandImport(imp.importPath)) return false;
217
+ const pattern = new RegExp('<' + imp.localName + String.raw`[\s/>]`);
218
+ return pattern.test(code);
219
+ });
220
+ }
221
+
222
+ /**
223
+ * Build metadata for components that are used with island prop
224
+ */
225
+ function buildIslandMeta(
226
+ code: string,
227
+ imports: ComponentImport[],
228
+ fileId: string,
229
+ ): Map<string, { srcPath: string; framework: string | undefined; importPath: string; autoIsland: boolean }> {
230
+ const meta = new Map<string, { srcPath: string; framework: string | undefined; importPath: string; autoIsland: boolean }>();
231
+ for (const imp of imports) {
232
+ const srcPath = resolveIslandSrc(imp.importPath, fileId);
233
+ const framework = detectFramework(srcPath);
234
+
235
+ // Check for explicit island prop usage
236
+ const islandPattern = new RegExp('<' + imp.localName + String.raw`[\s][^>]*island[\s]*[={]`);
237
+ if (islandPattern.test(code)) {
238
+ meta.set(imp.localName, { srcPath, framework, importPath: imp.importPath, autoIsland: false });
239
+ continue;
240
+ }
241
+
242
+ // Check for auto-island frameworks (e.g. Qwik) used as JSX without island prop
243
+ if (framework && AUTO_ISLAND_FRAMEWORKS.has(framework)) {
244
+ const usagePattern = new RegExp('<' + imp.localName + String.raw`[\s/>]`);
245
+ if (usagePattern.test(code)) {
246
+ meta.set(imp.localName, { srcPath, framework, importPath: imp.importPath, autoIsland: true });
247
+ }
248
+ }
249
+ }
250
+ return meta;
251
+ }
252
+
253
+ // ─── Low-level string scanning helpers ───────────────────────────────
254
+
255
+ function skipWhitespace(code: string, pos: number): number {
256
+ while (pos < code.length && /\s/.test(code[pos])) pos++;
257
+ return pos;
258
+ }
259
+
260
+ /** Skip a string literal (single, double, or backtick). Returns index after closing quote. */
261
+ function skipStringLiteral(code: string, pos: number): number {
262
+ const quote = code[pos];
263
+ pos++;
264
+ while (pos < code.length && code[pos] !== quote) {
265
+ if (code[pos] === '\\') pos++; // skip escaped char
266
+ pos++;
267
+ }
268
+ return pos < code.length ? pos + 1 : pos;
269
+ }
270
+
271
+ /** Skip a template literal including ${...} expressions. Returns index after closing backtick. */
272
+ function skipTemplateLiteral(code: string, pos: number): number {
273
+ pos++; // skip opening backtick
274
+ while (pos < code.length && code[pos] !== '`') {
275
+ if (code[pos] === '\\') {
276
+ pos += 2;
277
+ continue;
278
+ }
279
+ if (code[pos] === '$' && code[pos + 1] === '{') {
280
+ pos = skipBracedExpression(pos + 1, code);
281
+ continue;
282
+ }
283
+ pos++;
284
+ }
285
+ return pos < code.length ? pos + 1 : pos;
286
+ }
287
+
288
+ /** Skip a brace-delimited expression `{...}`, handling nested braces and strings. */
289
+ function skipBracedExpression(openBraceIdx: number, code: string): number {
290
+ let pos = openBraceIdx + 1;
291
+ let depth = 1;
292
+ while (pos < code.length && depth > 0) {
293
+ const ch = code[pos];
294
+ if (ch === '{') { depth++; pos++; }
295
+ else if (ch === '}') { depth--; if (depth > 0) pos++; }
296
+ else if (ch === "'" || ch === '"' || ch === '`') { pos = skipStringLiteral(code, pos); }
297
+ else { pos++; }
298
+ }
299
+ return pos < code.length ? pos + 1 : pos;
300
+ }
301
+
302
+ // ─── JSX Attribute Parsing ───────────────────────────────────────────
303
+
304
+ /** Parse a JSX expression value `{...}`. Returns the inner expression and end index (after `}`). */
305
+ function parseJSXExpressionValue(code: string, pos: number): { value: string; endIdx: number } {
306
+ const exprStart = pos + 1;
307
+ const endIdx = skipBracedExpression(pos, code);
308
+ // endIdx is after the closing }, inner content is between { and }
309
+ return { value: code.slice(exprStart, endIdx - 1), endIdx };
310
+ }
311
+
312
+ /** Parse a quoted string value `"..."` or `'...'`. Returns the value (with double quotes) and end index. */
313
+ function parseQuotedValue(code: string, pos: number): { value: string; endIdx: number } {
314
+ const quote = code[pos];
315
+ let i = pos + 1;
316
+ while (i < code.length && code[i] !== quote) {
317
+ if (code[i] === '\\') i++;
318
+ i++;
319
+ }
320
+ const value = '"' + code.slice(pos + 1, i) + '"';
321
+ return { value, endIdx: i + 1 };
322
+ }
323
+
324
+ /** Parse a single JSX attribute (name + optional value). Returns null on failure. */
325
+ function parseAttribute(code: string, pos: number): ParsedAttribute | null {
326
+ const nameStart = pos;
327
+ let i = pos;
328
+ while (i < code.length && /[a-zA-Z0-9_$]/.test(code[i])) i++;
329
+ const name = code.slice(nameStart, i);
330
+ if (!name) return null;
331
+
332
+ i = skipWhitespace(code, i);
333
+
334
+ // Boolean attribute (no `=`)
335
+ if (code[i] !== '=') {
336
+ return { name, value: null, endIdx: i };
337
+ }
338
+ i = skipWhitespace(code, i + 1); // skip `=` and whitespace
339
+
340
+ // Expression value: {expr}
341
+ if (code[i] === '{') {
342
+ const parsed = parseJSXExpressionValue(code, i);
343
+ return { name, value: parsed.value, endIdx: parsed.endIdx };
344
+ }
345
+
346
+ // Quoted string value
347
+ if (code[i] === '"' || code[i] === "'") {
348
+ const parsed = parseQuotedValue(code, i);
349
+ return { name, value: parsed.value, endIdx: parsed.endIdx };
350
+ }
351
+
352
+ return null; // unexpected token
353
+ }
354
+
355
+ // ─── JSX Element Parsing ─────────────────────────────────────────────
356
+
357
+ /** Find the end of a JSX tag — either self-closing `/>` or `>...</Component>`. */
358
+ function findTagEnd(
359
+ code: string,
360
+ pos: number,
361
+ componentName: string,
362
+ ): { endIdx: number; selfClosing: boolean } | null {
363
+ if (code[pos] === '/' && code[pos + 1] === '>') {
364
+ return { endIdx: pos + 2, selfClosing: true };
365
+ }
366
+ if (code[pos] === '>') {
367
+ const closeTag = '</' + componentName + '>';
368
+ const closeIdx = code.indexOf(closeTag, pos + 1);
369
+ if (closeIdx === -1) return null;
370
+ return { endIdx: closeIdx + closeTag.length, selfClosing: false };
371
+ }
372
+ return null;
373
+ }
374
+
375
+ /**
376
+ * Parse a JSX element starting at `<ComponentName`.
377
+ * Returns the end index and extracted props, or null if parsing fails.
378
+ */
379
+ function parseJSXElement(
380
+ code: string,
381
+ startIdx: number,
382
+ componentName: string,
383
+ ): ParsedJSXElement | null {
384
+ let i = skipWhitespace(code, startIdx + 1 + componentName.length);
385
+
386
+ let islandProp: string | null = null;
387
+ const otherProps: string[] = [];
388
+
389
+ while (i < code.length) {
390
+ i = skipWhitespace(code, i);
391
+
392
+ // Check for end of opening tag
393
+ const tagEnd = findTagEnd(code, i, componentName);
394
+ if (tagEnd) {
395
+ return { endIdx: tagEnd.endIdx, islandProp, otherProps };
396
+ }
397
+
398
+ // Parse next attribute
399
+ const attr = parseAttribute(code, i);
400
+ if (!attr) return null;
401
+ i = attr.endIdx;
402
+
403
+ if (attr.name === 'island') {
404
+ islandProp = attr.value ?? '{}';
405
+ } else {
406
+ const propValue = attr.value === null
407
+ ? attr.name + ': true'
408
+ : attr.name + ': ' + attr.value;
409
+ otherProps.push(propValue);
410
+ }
411
+ }
412
+
413
+ return null;
414
+ }
415
+
416
+ // ─── JSX Replacement ─────────────────────────────────────────────────
417
+
418
+ /** Build the `{await __pageRenderIsland({...})}` call from parsed element data. */
419
+ function buildRenderCall(
420
+ parsed: ParsedJSXElement,
421
+ srcPath: string,
422
+ framework: string | undefined,
423
+ autoIsland: boolean,
424
+ ): string {
425
+ const fwArg = framework ? ', framework: "' + framework + '"' : '';
426
+ const propsArg = parsed.otherProps.length > 0
427
+ ? ', props: { ' + parsed.otherProps.join(', ') + ' }'
428
+ : '';
429
+
430
+ if (autoIsland) {
431
+ // Auto-island (e.g. Qwik): SSR-only, no client hydration needed
432
+ return '{await __pageRenderIsland({ src: "' + srcPath + '"' + fwArg
433
+ + propsArg
434
+ + ', ssr: true, ssrOnly: true'
435
+ + ' })}';
436
+ }
437
+
438
+ const islandValue = parsed.islandProp!;
439
+ // Qwik is resumable — SSR the HTML but skip client hydration.
440
+ // The Qwikloader handles resumption automatically.
441
+ const ssrOnlyArg = framework === 'qwik' ? ', ssrOnly: true' : '';
442
+
443
+ return '{await __pageRenderIsland({ src: "' + srcPath + '"' + fwArg
444
+ + ', ...(' + islandValue + ')'
445
+ + propsArg
446
+ + ssrOnlyArg
447
+ + ', ssr: (' + islandValue + ').ssr !== undefined ? (' + islandValue + ').ssr : true'
448
+ + ' })}';
449
+ }
450
+
451
+ /** Check if position `i` is the start of a `<ComponentName` tag (not a longer identifier). */
452
+ function isComponentTagStart(code: string, pos: number, tag: string): boolean {
453
+ if (!code.startsWith(tag, pos)) return false;
454
+ const afterTag = pos + tag.length;
455
+ return afterTag >= code.length || !/[a-zA-Z0-9_$]/.test(code[afterTag]);
456
+ }
457
+
458
+ /**
459
+ * Replace all `<Component island={...} />` JSX usages with `{await __pageRenderIsland({...})}`.
460
+ */
461
+ function replaceIslandJSX(
462
+ code: string,
463
+ componentName: string,
464
+ srcPath: string,
465
+ framework: string | undefined,
466
+ autoIsland: boolean,
467
+ ): string {
468
+ const tag = '<' + componentName;
469
+ let result = '';
470
+ let i = 0;
471
+
472
+ while (i < code.length) {
473
+ // Skip template literals to avoid transforming code examples
474
+ if (code[i] === '`') {
475
+ const start = i;
476
+ i = skipTemplateLiteral(code, i);
477
+ result += code.slice(start, i);
478
+ continue;
479
+ }
480
+
481
+ // Skip JSX comments: {/* ... */}
482
+ // When we see '{' followed by '/*', skip until '*/' then '}'
483
+ if (code[i] === '{' && code[i + 1] === '/' && code[i + 2] === '*') {
484
+ const commentEnd = code.indexOf('*/', i + 3);
485
+ if (commentEnd !== -1) {
486
+ // Find the closing '}' after '*/'
487
+ let afterComment = commentEnd + 2;
488
+ while (afterComment < code.length && /\s/.test(code[afterComment])) afterComment++;
489
+ if (afterComment < code.length && code[afterComment] === '}') {
490
+ result += code.slice(i, afterComment + 1);
491
+ i = afterComment + 1;
492
+ continue;
493
+ }
494
+ }
495
+ }
496
+
497
+ // Skip single-line comments
498
+ if (code[i] === '/' && code[i + 1] === '/') {
499
+ const lineEnd = code.indexOf('\n', i);
500
+ const end = lineEnd === -1 ? code.length : lineEnd + 1;
501
+ result += code.slice(i, end);
502
+ i = end;
503
+ continue;
504
+ }
505
+
506
+ // Skip block comments
507
+ if (code[i] === '/' && code[i + 1] === '*') {
508
+ const commentEnd = code.indexOf('*/', i + 2);
509
+ const end = commentEnd === -1 ? code.length : commentEnd + 2;
510
+ result += code.slice(i, end);
511
+ i = end;
512
+ continue;
513
+ }
514
+
515
+ // Check for component tag
516
+ if (!isComponentTagStart(code, i, tag)) {
517
+ result += code[i];
518
+ i++;
519
+ continue;
520
+ }
521
+
522
+ const parsed = parseJSXElement(code, i, componentName);
523
+ if (!parsed || (!parsed.islandProp && !autoIsland)) {
524
+ // Not parseable, or no island prop and not an auto-island — emit as-is
525
+ const end = parsed ? parsed.endIdx : i + 1;
526
+ result += code.slice(i, end);
527
+ i = end;
528
+ continue;
529
+ }
530
+
531
+ result += buildRenderCall(parsed, srcPath, framework, autoIsland && !parsed.islandProp);
532
+ i = parsed.endIdx;
533
+ }
534
+
535
+ return result;
536
+ }
537
+
538
+ // ─── Vite Plugin ─────────────────────────────────────────────────────
539
+
540
+ export function pageIslandTransform(
541
+ options: PageIslandTransformOptions = {},
542
+ ): Plugin {
543
+ const {
544
+ pagesDir = 'src/pages',
545
+ layoutsDir = 'src/layouts',
546
+ modules = null,
547
+ } = options;
548
+
549
+ return {
550
+ name: 'avalon:page-island-transform',
551
+ enforce: 'pre',
552
+
553
+ transform(code: string, id: string) {
554
+ const isLayout = isLayoutFile(id, layoutsDir, modules);
555
+ if (!isPageFile(id, pagesDir, modules) && !isLayout) return null;
556
+
557
+ // Find all component imports (PascalCase default imports)
558
+ const componentImports = findAllDefaultImports(code);
559
+ if (componentImports.length === 0) return null;
560
+
561
+ const componentNames = componentImports.map((i) => i.localName);
562
+ if (!hasIslandPropUsage(code, componentNames) && !hasAutoIslandUsage(code, componentImports)) return null;
563
+
564
+ // Build metadata only for components actually used with island prop
565
+ const islandMeta = buildIslandMeta(code, componentImports, id);
566
+ if (islandMeta.size === 0) return null;
567
+
568
+ let transformed =
569
+ "import { renderIsland as __pageRenderIsland } from '@useavalon/avalon';\n" + code;
570
+
571
+ for (const [name, meta] of islandMeta) {
572
+ transformed = replaceIslandJSX(transformed, name, meta.srcPath, meta.framework, meta.autoIsland);
573
+ }
574
+
575
+ // Update imports for components used as islands
576
+ for (const imp of componentImports) {
577
+ if (islandMeta.has(imp.localName)) {
578
+ if (isLayout) {
579
+ // In layouts, keep the import as a side-effect-only import so the
580
+ // island module (and its CSS) stays in Vite's module graph for CSS
581
+ // collection. Only the default binding is removed.
582
+ transformed = transformed.replace(
583
+ imp.fullMatch,
584
+ "import '" + imp.importPath + "'; // [page-island-transform] kept for CSS graph: " + imp.localName,
585
+ );
586
+ } else {
587
+ transformed = transformed.replace(
588
+ imp.fullMatch,
589
+ '// [page-island-transform] removed: ' + imp.localName,
590
+ );
591
+ }
592
+ }
593
+ }
594
+
595
+ return { code: transformed, map: null };
596
+ },
597
+ };
598
+ }
@@ -0,0 +1,21 @@
1
+ export { type PropExtractionResult, FALLBACK_PROPS, extractVueProps } from "./vue";
2
+ export { extractSvelteProps } from "./svelte";
3
+ export { extractLitProps } from "./lit";
4
+ export { extractSolidProps } from "./solid";
5
+ export { extractQwikProps } from "./qwik";
6
+
7
+ import type { PropExtractionResult } from "./vue";
8
+ import { extractVueProps } from "./vue";
9
+ import { extractSvelteProps } from "./svelte";
10
+ import { extractLitProps } from "./lit";
11
+ import { extractSolidProps } from "./solid";
12
+ import { extractQwikProps } from "./qwik";
13
+
14
+ /** Maps framework name to its prop extractor function */
15
+ export const EXTRACTOR_MAP: Record<string, (source: string) => PropExtractionResult> = {
16
+ vue: extractVueProps,
17
+ svelte: extractSvelteProps,
18
+ lit: extractLitProps,
19
+ solid: extractSolidProps,
20
+ qwik: extractQwikProps,
21
+ };