@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.
- package/README.md +54 -54
- package/mod.ts +302 -302
- package/package.json +49 -26
- package/src/build/integration-bundler-plugin.ts +116 -116
- package/src/build/integration-config.ts +168 -168
- package/src/build/integration-detection-plugin.ts +117 -117
- package/src/build/integration-resolver-plugin.ts +90 -90
- package/src/build/island-manifest.ts +269 -269
- package/src/build/island-types-generator.ts +476 -476
- package/src/build/mdx-island-transform.ts +464 -464
- package/src/build/mdx-plugin.ts +98 -98
- package/src/build/page-island-transform.ts +598 -598
- package/src/build/prop-extractors/index.ts +21 -21
- package/src/build/prop-extractors/lit.ts +140 -140
- package/src/build/prop-extractors/qwik.ts +16 -16
- package/src/build/prop-extractors/solid.ts +125 -125
- package/src/build/prop-extractors/svelte.ts +194 -194
- package/src/build/prop-extractors/vue.ts +111 -111
- package/src/build/sidecar-file-manager.ts +104 -104
- package/src/build/sidecar-renderer.ts +30 -30
- package/src/client/adapters/index.ts +21 -13
- package/src/client/components.ts +35 -35
- package/src/client/css-hmr-handler.ts +344 -344
- package/src/client/framework-adapter.ts +462 -462
- package/src/client/hmr-coordinator.ts +396 -396
- package/src/client/hmr-error-overlay.js +533 -533
- package/src/client/main.js +824 -816
- package/src/client/types/framework-runtime.d.ts +68 -68
- package/src/client/types/vite-hmr.d.ts +46 -46
- package/src/client/types/vite-virtual-modules.d.ts +70 -60
- package/src/components/Image.tsx +123 -123
- package/src/components/IslandErrorBoundary.tsx +145 -145
- package/src/components/LayoutDataErrorBoundary.tsx +141 -141
- package/src/components/LayoutErrorBoundary.tsx +127 -127
- package/src/components/PersistentIsland.tsx +52 -52
- package/src/components/StreamingErrorBoundary.tsx +233 -233
- package/src/components/StreamingLayout.tsx +538 -538
- package/src/core/components/component-analyzer.ts +192 -192
- package/src/core/components/component-detection.ts +508 -508
- package/src/core/components/enhanced-framework-detector.ts +500 -500
- package/src/core/components/framework-registry.ts +563 -563
- package/src/core/content/mdx-processor.ts +46 -46
- package/src/core/integrations/index.ts +19 -19
- package/src/core/integrations/loader.ts +125 -125
- package/src/core/integrations/registry.ts +175 -175
- package/src/core/islands/island-persistence.ts +325 -325
- package/src/core/islands/island-state-serializer.ts +258 -258
- package/src/core/islands/persistent-island-context.tsx +80 -80
- package/src/core/islands/use-persistent-state.ts +68 -68
- package/src/core/layout/enhanced-layout-resolver.ts +322 -322
- package/src/core/layout/layout-cache-manager.ts +485 -485
- package/src/core/layout/layout-composer.ts +357 -357
- package/src/core/layout/layout-data-loader.ts +516 -516
- package/src/core/layout/layout-discovery.ts +243 -243
- package/src/core/layout/layout-matcher.ts +299 -299
- package/src/core/layout/layout-types.ts +110 -110
- package/src/core/modules/framework-module-resolver.ts +273 -273
- package/src/islands/component-analysis.ts +213 -213
- package/src/islands/css-utils.ts +565 -565
- package/src/islands/discovery/index.ts +80 -80
- package/src/islands/discovery/registry.ts +340 -340
- package/src/islands/discovery/resolver.ts +477 -477
- package/src/islands/discovery/scanner.ts +386 -386
- package/src/islands/discovery/types.ts +117 -117
- package/src/islands/discovery/validator.ts +544 -544
- package/src/islands/discovery/watcher.ts +368 -368
- package/src/islands/framework-detection.ts +428 -428
- package/src/islands/integration-loader.ts +490 -490
- package/src/islands/island.tsx +565 -565
- package/src/islands/render-cache.ts +550 -550
- package/src/islands/types.ts +80 -80
- package/src/islands/universal-css-collector.ts +157 -157
- package/src/islands/universal-head-collector.ts +137 -137
- package/src/layout-system.d.ts +592 -592
- package/src/layout-system.ts +218 -218
- package/src/middleware/discovery.ts +268 -268
- package/src/middleware/executor.ts +315 -315
- package/src/middleware/index.ts +76 -76
- package/src/middleware/types.ts +99 -99
- package/src/nitro/build-config.ts +575 -575
- package/src/nitro/config.ts +483 -483
- package/src/nitro/error-handler.ts +636 -636
- package/src/nitro/index.ts +173 -173
- package/src/nitro/island-manifest.ts +584 -584
- package/src/nitro/middleware-adapter.ts +260 -260
- package/src/nitro/renderer.ts +1471 -1471
- package/src/nitro/route-discovery.ts +439 -439
- package/src/nitro/types.ts +321 -321
- package/src/render/collect-css.ts +198 -198
- package/src/render/error-pages.ts +79 -79
- package/src/render/isolated-ssr-renderer.ts +654 -654
- package/src/render/ssr.ts +1030 -1030
- package/src/schemas/api.ts +30 -30
- package/src/schemas/core.ts +64 -64
- package/src/schemas/index.ts +212 -212
- package/src/schemas/layout.ts +279 -279
- package/src/schemas/routing/index.ts +38 -38
- package/src/schemas/routing.ts +376 -376
- package/src/types/as-island.ts +20 -20
- package/src/types/image.d.ts +106 -106
- package/src/types/index.d.ts +22 -22
- package/src/types/island-jsx.d.ts +33 -33
- package/src/types/island-prop.d.ts +20 -20
- package/src/types/layout.ts +285 -285
- package/src/types/mdx.d.ts +6 -6
- package/src/types/routing.ts +555 -555
- package/src/types/types.ts +5 -5
- package/src/types/urlpattern.d.ts +49 -49
- package/src/types/vite-env.d.ts +11 -11
- package/src/utils/dev-logger.ts +299 -299
- package/src/utils/fs.ts +151 -151
- package/src/vite-plugin/auto-discover.ts +551 -551
- package/src/vite-plugin/config.ts +266 -266
- package/src/vite-plugin/errors.ts +127 -127
- package/src/vite-plugin/image-optimization.ts +156 -156
- package/src/vite-plugin/integration-activator.ts +126 -126
- package/src/vite-plugin/island-sidecar-plugin.ts +176 -176
- package/src/vite-plugin/module-discovery.ts +189 -189
- package/src/vite-plugin/nitro-integration.ts +1354 -1354
- package/src/vite-plugin/plugin.ts +403 -409
- package/src/vite-plugin/types.ts +327 -327
- package/src/vite-plugin/validation.ts +228 -228
- package/src/client/adapters/index.js +0 -12
- package/src/client/adapters/lit-adapter.js +0 -467
- package/src/client/adapters/lit-adapter.ts +0 -654
- package/src/client/adapters/preact-adapter.js +0 -223
- package/src/client/adapters/preact-adapter.ts +0 -331
- package/src/client/adapters/qwik-adapter.js +0 -259
- package/src/client/adapters/qwik-adapter.ts +0 -345
- package/src/client/adapters/react-adapter.js +0 -220
- package/src/client/adapters/react-adapter.ts +0 -353
- package/src/client/adapters/solid-adapter.js +0 -295
- package/src/client/adapters/solid-adapter.ts +0 -451
- package/src/client/adapters/svelte-adapter.js +0 -368
- package/src/client/adapters/svelte-adapter.ts +0 -524
- package/src/client/adapters/vue-adapter.js +0 -278
- package/src/client/adapters/vue-adapter.ts +0 -467
- package/src/client/components.js +0 -23
- package/src/client/css-hmr-handler.js +0 -263
- package/src/client/framework-adapter.js +0 -283
- package/src/client/hmr-coordinator.js +0 -274
|
@@ -1,598 +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
|
-
}
|
|
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
|
+
}
|