@ubean/islands 0.1.13 → 0.2.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.
- package/dist/directive.d.ts +149 -0
- package/dist/directive.js +158 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/runtime.d.ts +304 -1
- package/dist/runtime.js +396 -17
- package/dist/server-component.d.ts +39 -0
- package/dist/server-component.js +70 -0
- package/dist/virtual-registry.d.ts +1 -1
- package/dist/vite.d.ts +61 -4
- package/dist/vite.js +529 -28
- package/package.json +18 -8
package/dist/vite.js
CHANGED
|
@@ -1,17 +1,89 @@
|
|
|
1
|
+
import { legacyDirectiveToStrategy, strategyToLegacyDirective } from "./directive.js";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
1
3
|
import { dirname, resolve } from "node:path";
|
|
2
4
|
//#region src/vite.ts
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
/**
|
|
6
|
+
* `v-client.*` 指令属性名(Phase 4: 唯一支持的客户端 island 语法)。
|
|
7
|
+
*
|
|
8
|
+
* 旧的 `client:*` attribute 语法已移除 — 请迁移至 `v-client.*`,
|
|
9
|
+
* 或使用运行时 `defineIsland()` 包装组件。
|
|
10
|
+
*/
|
|
11
|
+
const V_CLIENT_DIRECTIVES = [
|
|
12
|
+
"v-client.load",
|
|
13
|
+
"v-client.idle",
|
|
14
|
+
"v-client.visible",
|
|
15
|
+
"v-client.media",
|
|
16
|
+
"v-client.only"
|
|
9
17
|
];
|
|
10
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Matches `v-client.*` (用于客户端 island 检测)。
|
|
20
|
+
*
|
|
21
|
+
* Phase 4 起 `client:*` legacy 语法不再支持,正则只匹配 `v-client.*`。
|
|
22
|
+
*/
|
|
23
|
+
const ANY_CLIENT_DIRECTIVE_RE = /\bv-client\.(?:load|idle|visible|media|only)\b/;
|
|
24
|
+
/** Matches `v-client.*` (用于快速跳过非 island SFC) */
|
|
25
|
+
const ANY_DIRECTIVE_RE = /\bv-client\.(?:load|idle|visible|media|only)\b/;
|
|
11
26
|
function isVueSfc(id) {
|
|
12
27
|
return /\.vue(?:\?.*)?$/.test(id) && !id.includes("&type=");
|
|
13
28
|
}
|
|
14
29
|
/**
|
|
30
|
+
* 判断 id 是否为 `.server.vue` 文件 (Task 9.1)。
|
|
31
|
+
*
|
|
32
|
+
* 仅检查路径部分(忽略 `?query`),避免 `?vue&type=template` 子查询误匹配。
|
|
33
|
+
*/
|
|
34
|
+
function isServerComponentFile(id) {
|
|
35
|
+
return id.split("?")[0].endsWith(".server.vue");
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* 判断 id 是否为 `.client.vue` 文件 (Task 9.2)。
|
|
39
|
+
*
|
|
40
|
+
* 仅检查路径部分(忽略 `?query`)。注意:从虚拟包装模块内部对真实文件的
|
|
41
|
+
* import 不会被此函数拦截(由 `resolveId` 中的 importer 检查排除)。
|
|
42
|
+
*/
|
|
43
|
+
function isClientComponentFile(id) {
|
|
44
|
+
return id.split("?")[0].endsWith(".client.vue");
|
|
45
|
+
}
|
|
46
|
+
/** `.server.vue` 在 client 构建中的通用虚拟 stub 模块 ID */
|
|
47
|
+
const SERVER_COMPONENT_STUB_VIRTUAL_ID = "virtual:ubean-server-component-stub";
|
|
48
|
+
const SERVER_COMPONENT_STUB_RESOLVED_ID = `\0${SERVER_COMPONENT_STUB_VIRTUAL_ID}`;
|
|
49
|
+
/** `.client.vue` 在 SSR 构建中的通用占位符虚拟模块 ID */
|
|
50
|
+
const CLIENT_COMPONENT_PLACEHOLDER_VIRTUAL_ID = "virtual:ubean-client-component-placeholder";
|
|
51
|
+
const CLIENT_COMPONENT_PLACEHOLDER_RESOLVED_ID = `\0${CLIENT_COMPONENT_PLACEHOLDER_VIRTUAL_ID}`;
|
|
52
|
+
/** `.client.vue` 在 client 构建中文件级包装虚拟模块 ID 前缀 */
|
|
53
|
+
const CLIENT_COMPONENT_WRAPPER_PREFIX = "\0virtual:ubean-client-component:";
|
|
54
|
+
/**
|
|
55
|
+
* Task 9.3: 配对组件 (`Foo.vue` 同时存在 `.server.vue` + `.client.vue`) 在
|
|
56
|
+
* SSR / client 构建中生成的虚拟包装模块 ID 前缀。
|
|
57
|
+
*
|
|
58
|
+
* 后缀格式: `${serverPath}|${clientPath}` (两个绝对路径用 `|` 分隔)。
|
|
59
|
+
*
|
|
60
|
+
* - SSR `load`: 仅 `import ServerComp from serverPath; export default ServerComp;`
|
|
61
|
+
* (导入真实 `.server.vue`,经现有规则解析为真实文件 + 模板包裹)
|
|
62
|
+
* - client `load`: 同时导入 `.server.vue` (→ stub) 与 `.client.vue` (→ 真实文件,
|
|
63
|
+
* 通过 importer 检查跳过 `defineClientComponent` 包装),调用 `definePairedComponent`
|
|
64
|
+
* 实现初始渲染 ServerComp(stub) → onMounted 后切换为 ClientComp。
|
|
65
|
+
*/
|
|
66
|
+
const PAIRED_COMPONENT_WRAPPER_PREFIX = "\0virtual:ubean-paired-component:";
|
|
67
|
+
/** `|` 在文件路径中几乎不会出现,用作 `serverPath` 与 `clientPath` 的分隔符。 */
|
|
68
|
+
const PAIRED_PATH_SEPARATOR = "|";
|
|
69
|
+
/**
|
|
70
|
+
* 将 `.server.vue` SFC 的 `<template>` 内容包裹在 `<ubean-server-only v-once>` 中。
|
|
71
|
+
*
|
|
72
|
+
* SSR 渲染输出 `<ubean-server-only v-once>真实内容</ubean-server-only>`,
|
|
73
|
+
* 客户端 stub 渲染 `<ubean-server-only></ubean-server-only>` (无子节点)。
|
|
74
|
+
* `v-once` 标记内容为静态,Vue 水合时保留 SSR HTML 不清除。
|
|
75
|
+
*
|
|
76
|
+
* 仅在 SSR 上下文运行(client 构建中 `.server.vue` 被 `resolveId` 重定向到 stub,
|
|
77
|
+
* 真实文件不会被加载/转换)。
|
|
78
|
+
*/
|
|
79
|
+
function wrapServerComponentTemplate(code) {
|
|
80
|
+
const tpl = extractTemplateBlock(code);
|
|
81
|
+
if (!tpl) return null;
|
|
82
|
+
if (tpl.content.trim().startsWith("<ubean-server-only")) return null;
|
|
83
|
+
const newContent = `<ubean-server-only v-once>${tpl.content}</ubean-server-only>`;
|
|
84
|
+
return `${code.slice(0, tpl.start)}<template${tpl.attrs}>${newContent}</template>${code.slice(tpl.end)}`;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
15
87
|
* 判断 id 是否为 SFC 主模块(非 `?vue&type=...` 子查询)。
|
|
16
88
|
*
|
|
17
89
|
* 收集 island 组件时需要同时访问 `<script setup>` 与 `<template>` 块,
|
|
@@ -56,14 +128,358 @@ function parseScriptImports(scriptContent) {
|
|
|
56
128
|
}
|
|
57
129
|
return map;
|
|
58
130
|
}
|
|
131
|
+
const DEFINE_SERVER_ISLAND_RE = /\bdefineServerIsland\s*\(/g;
|
|
132
|
+
/**
|
|
133
|
+
* 预扫描代码,返回所有"非代码"区间 `[start, end)`:注释(块/行)和字符串字面量。
|
|
134
|
+
*
|
|
135
|
+
* 用于跳过注释/字符串内的 `defineServerIsland(` 匹配(如 JSDoc 示例或说明性字符串)。
|
|
136
|
+
* 复用 `@ubean/actions` Vite 插件的成熟模式 (见 lessons #P-lessons)。
|
|
137
|
+
*/
|
|
138
|
+
function findNonCodeRanges(code) {
|
|
139
|
+
const ranges = [];
|
|
140
|
+
const len = code.length;
|
|
141
|
+
let i = 0;
|
|
142
|
+
while (i < len) {
|
|
143
|
+
if (code[i] === "/" && code[i + 1] === "*") {
|
|
144
|
+
const start = i;
|
|
145
|
+
i += 2;
|
|
146
|
+
while (i < len && !(code[i] === "*" && code[i + 1] === "/")) i++;
|
|
147
|
+
i += 2;
|
|
148
|
+
ranges.push([start, Math.min(i, len)]);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (code[i] === "/" && code[i + 1] === "/") {
|
|
152
|
+
const start = i;
|
|
153
|
+
i += 2;
|
|
154
|
+
while (i < len && code[i] !== "\n") i++;
|
|
155
|
+
ranges.push([start, i]);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (code[i] === "\"" || code[i] === "'" || code[i] === "`") {
|
|
159
|
+
const start = i;
|
|
160
|
+
const quote = code[i];
|
|
161
|
+
i++;
|
|
162
|
+
while (i < len) {
|
|
163
|
+
if (code[i] === "\\") {
|
|
164
|
+
i += 2;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (quote === "`" && code[i] === "$" && code[i + 1] === "{") {
|
|
168
|
+
i += 2;
|
|
169
|
+
let depth = 1;
|
|
170
|
+
while (i < len && depth > 0) {
|
|
171
|
+
if (code[i] === "{") depth++;
|
|
172
|
+
else if (code[i] === "}") depth--;
|
|
173
|
+
i++;
|
|
174
|
+
}
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (code[i] === quote) {
|
|
178
|
+
i++;
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
i++;
|
|
182
|
+
}
|
|
183
|
+
ranges.push([start, i]);
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
i++;
|
|
187
|
+
}
|
|
188
|
+
return ranges;
|
|
189
|
+
}
|
|
190
|
+
function isInsideRanges(pos, ranges) {
|
|
191
|
+
for (const [start, end] of ranges) if (pos >= start && pos < end) return true;
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* 从 `startIdx` 开始查找匹配的闭合括号(支持字符串/模板字面量/注释跳过)。
|
|
196
|
+
* `startIdx` 应指向 `(` 后的第一个字符。返回 `)` 的索引,或 `null`。
|
|
197
|
+
*/
|
|
198
|
+
function findBalanced(code, openChar, closeChar, startIdx) {
|
|
199
|
+
let depth = 1;
|
|
200
|
+
let i = startIdx;
|
|
201
|
+
let inString = null;
|
|
202
|
+
let escaped = false;
|
|
203
|
+
let inTemplateExpr = 0;
|
|
204
|
+
while (i < code.length && depth > 0) {
|
|
205
|
+
const ch = code[i];
|
|
206
|
+
if (escaped) {
|
|
207
|
+
escaped = false;
|
|
208
|
+
i++;
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (ch === "\\") {
|
|
212
|
+
escaped = true;
|
|
213
|
+
i++;
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (inString) {
|
|
217
|
+
if (inString === "`" && ch === "$" && code[i + 1] === "{") {
|
|
218
|
+
inTemplateExpr++;
|
|
219
|
+
i += 2;
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
if (inString === "`" && ch === "}" && inTemplateExpr > 0) {
|
|
223
|
+
inTemplateExpr--;
|
|
224
|
+
i++;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (ch === inString) inString = null;
|
|
228
|
+
i++;
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
if (ch === "\"" || ch === "'" || ch === "`") {
|
|
232
|
+
inString = ch;
|
|
233
|
+
i++;
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (ch === "/" && code[i + 1] === "/") {
|
|
237
|
+
while (i < code.length && code[i] !== "\n") i++;
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
if (ch === "/" && code[i + 1] === "*") {
|
|
241
|
+
i += 2;
|
|
242
|
+
while (i < code.length && !(code[i] === "*" && code[i + 1] === "/")) i++;
|
|
243
|
+
i += 2;
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
if (ch === openChar) depth++;
|
|
247
|
+
else if (ch === closeChar) {
|
|
248
|
+
depth--;
|
|
249
|
+
if (depth === 0) return i;
|
|
250
|
+
}
|
|
251
|
+
i++;
|
|
252
|
+
}
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* 在 `[argStart, end)` 区间内查找顶层逗号(深度 0)位置数组。
|
|
257
|
+
*/
|
|
258
|
+
function findTopLevelCommas(code, argStart, end) {
|
|
259
|
+
const commas = [];
|
|
260
|
+
let depth = 0;
|
|
261
|
+
let i = argStart;
|
|
262
|
+
let inString = null;
|
|
263
|
+
let escaped = false;
|
|
264
|
+
let inTemplateExpr = 0;
|
|
265
|
+
while (i < end) {
|
|
266
|
+
const ch = code[i];
|
|
267
|
+
if (escaped) {
|
|
268
|
+
escaped = false;
|
|
269
|
+
i++;
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (ch === "\\") {
|
|
273
|
+
escaped = true;
|
|
274
|
+
i++;
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
if (inString) {
|
|
278
|
+
if (inString === "`" && ch === "$" && code[i + 1] === "{") {
|
|
279
|
+
inTemplateExpr++;
|
|
280
|
+
i += 2;
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
if (inString === "`" && ch === "}" && inTemplateExpr > 0) {
|
|
284
|
+
inTemplateExpr--;
|
|
285
|
+
i++;
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
if (ch === inString) inString = null;
|
|
289
|
+
i++;
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
if (ch === "\"" || ch === "'" || ch === "`") {
|
|
293
|
+
inString = ch;
|
|
294
|
+
i++;
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
if (ch === "/" && code[i + 1] === "/") {
|
|
298
|
+
while (i < end && code[i] !== "\n") i++;
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
if (ch === "/" && code[i + 1] === "*") {
|
|
302
|
+
i += 2;
|
|
303
|
+
while (i < end && !(code[i] === "*" && code[i + 1] === "/")) i++;
|
|
304
|
+
i += 2;
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
if (ch === "(" || ch === "[" || ch === "{") depth++;
|
|
308
|
+
else if (ch === ")" || ch === "]" || ch === "}") depth--;
|
|
309
|
+
else if (ch === "," && depth === 0) commas.push(i);
|
|
310
|
+
i++;
|
|
311
|
+
}
|
|
312
|
+
return commas;
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* 提取源码中所有 `defineServerIsland(...)` 调用信息。
|
|
316
|
+
*
|
|
317
|
+
* 跳过注释/字符串内的匹配(如 JSDoc 示例)和函数声明(`function defineServerIsland(`)。
|
|
318
|
+
*/
|
|
319
|
+
function findDefineServerIslandCalls(code) {
|
|
320
|
+
const results = [];
|
|
321
|
+
const nonCodeRanges = findNonCodeRanges(code);
|
|
322
|
+
let m;
|
|
323
|
+
DEFINE_SERVER_ISLAND_RE.lastIndex = 0;
|
|
324
|
+
while ((m = DEFINE_SERVER_ISLAND_RE.exec(code)) !== null) {
|
|
325
|
+
const start = m.index;
|
|
326
|
+
if (isInsideRanges(start, nonCodeRanges)) continue;
|
|
327
|
+
const before = code.slice(0, start);
|
|
328
|
+
if (/\bfunction\s+$/.test(before)) continue;
|
|
329
|
+
const argStart = start + m[0].length;
|
|
330
|
+
const end = findBalanced(code, "(", ")", argStart);
|
|
331
|
+
if (end === null) continue;
|
|
332
|
+
const commas = findTopLevelCommas(code, argStart, end);
|
|
333
|
+
const argRanges = [];
|
|
334
|
+
let prev = argStart;
|
|
335
|
+
for (const comma of commas) {
|
|
336
|
+
argRanges.push([prev, comma]);
|
|
337
|
+
prev = comma + 1;
|
|
338
|
+
}
|
|
339
|
+
argRanges.push([prev, end]);
|
|
340
|
+
const args = argRanges.map(([s, e]) => code.slice(s, e).trim());
|
|
341
|
+
results.push({
|
|
342
|
+
start,
|
|
343
|
+
argStart,
|
|
344
|
+
end,
|
|
345
|
+
args
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
return results;
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* 提取 SFC 中 `<script setup>` 或 `<script>` 块的内容及其位置区间。
|
|
352
|
+
*
|
|
353
|
+
* 返回 `{ content, contentStart, contentEnd }` 或 `null`。`contentStart`/`contentEnd`
|
|
354
|
+
* 是内容在原 code 中的起止位置(不含开闭标签)。
|
|
355
|
+
*/
|
|
356
|
+
function extractScriptBlockWithPositions(code) {
|
|
357
|
+
const setupMatch = code.match(/<script\s+[^>]*setup[^>]*>/);
|
|
358
|
+
if (setupMatch && setupMatch.index !== void 0) {
|
|
359
|
+
const openTagEnd = setupMatch.index + setupMatch[0].length;
|
|
360
|
+
const closeIdx = code.indexOf("<\/script>", openTagEnd);
|
|
361
|
+
if (closeIdx !== -1) return {
|
|
362
|
+
content: code.slice(openTagEnd, closeIdx),
|
|
363
|
+
contentStart: openTagEnd,
|
|
364
|
+
contentEnd: closeIdx
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
const plainMatch = code.match(/<script(?!\s[^>]*setup)[^>]*>/);
|
|
368
|
+
if (plainMatch && plainMatch.index !== void 0) {
|
|
369
|
+
const openTagEnd = plainMatch.index + plainMatch[0].length;
|
|
370
|
+
const closeIdx = code.indexOf("<\/script>", openTagEnd);
|
|
371
|
+
if (closeIdx !== -1) return {
|
|
372
|
+
content: code.slice(openTagEnd, closeIdx),
|
|
373
|
+
contentStart: openTagEnd,
|
|
374
|
+
contentEnd: closeIdx
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Task 9.4: 扫描代码中的 `defineServerIsland(Identifier, { ... rerenderOnPropsChange: true ... })`
|
|
381
|
+
* 调用,解析 `Identifier` 的 import 路径,将其作为第 3 个参数注入:
|
|
382
|
+
*
|
|
383
|
+
* ```ts
|
|
384
|
+
* defineServerIsland(Comp, { rerenderOnPropsChange: true }, "/abs/path.server.vue")
|
|
385
|
+
* ```
|
|
386
|
+
*
|
|
387
|
+
* ## 工作机制
|
|
388
|
+
*
|
|
389
|
+
* 1. 提取 `<script>` 块内容(`.vue` 文件)或整个代码(`.ts` 文件)
|
|
390
|
+
* 2. 查找所有 `defineServerIsland(...)` 调用,跳过注释/字符串/函数声明
|
|
391
|
+
* 3. 对每个调用:
|
|
392
|
+
* - 参数 < 2 → 跳过(至少需要 Component + options)
|
|
393
|
+
* - 参数 ≥ 3 → 跳过(已有路径,幂等)
|
|
394
|
+
* - options 对象不含 `rerenderOnPropsChange: true` → 跳过(非 props 重渲染场景)
|
|
395
|
+
* - 通过 `parseScriptImports` 解析 identifier 的 import 路径,相对路径解析为绝对路径
|
|
396
|
+
* - 在 `)` 前注入 `, "/abs/path"`
|
|
397
|
+
*
|
|
398
|
+
* ## 幂等性
|
|
399
|
+
*
|
|
400
|
+
* 已注入的调用(参数 ≥ 3)不会被再次处理。
|
|
401
|
+
*
|
|
402
|
+
* @param code 完整源码(`.vue` SFC 或 `.ts` 文件)
|
|
403
|
+
* @param sourceFile 源文件绝对路径(用于解析相对 import)
|
|
404
|
+
* @returns 修改后的源码,或 `null` 表示无修改
|
|
405
|
+
*/
|
|
406
|
+
function injectServerComponentPath(code, sourceFile) {
|
|
407
|
+
if (sourceFile.endsWith(".vue")) {
|
|
408
|
+
const scriptBlock = extractScriptBlockWithPositions(code);
|
|
409
|
+
if (!scriptBlock) return null;
|
|
410
|
+
const newContent = _doInject(scriptBlock.content, sourceFile);
|
|
411
|
+
if (newContent === null) return null;
|
|
412
|
+
return code.slice(0, scriptBlock.contentStart) + newContent + code.slice(scriptBlock.contentEnd);
|
|
413
|
+
}
|
|
414
|
+
return _doInject(code, sourceFile);
|
|
415
|
+
}
|
|
416
|
+
function _doInject(scriptContent, sourceFile) {
|
|
417
|
+
const calls = findDefineServerIslandCalls(scriptContent);
|
|
418
|
+
if (calls.length === 0) return null;
|
|
419
|
+
const importMap = parseScriptImports(scriptContent);
|
|
420
|
+
let result = scriptContent;
|
|
421
|
+
let changed = false;
|
|
422
|
+
for (let i = calls.length - 1; i >= 0; i--) {
|
|
423
|
+
const call = calls[i];
|
|
424
|
+
if (call.args.length < 2) continue;
|
|
425
|
+
if (call.args.length >= 3) continue;
|
|
426
|
+
const optionsText = call.args[1];
|
|
427
|
+
if (!optionsText.startsWith("{")) continue;
|
|
428
|
+
if (!/\brerenderOnPropsChange\s*:\s*true\b/.test(optionsText)) continue;
|
|
429
|
+
const identifier = call.args[0].trim();
|
|
430
|
+
const importPath = importMap.get(identifier);
|
|
431
|
+
if (!importPath) continue;
|
|
432
|
+
const absolutePath = resolveIslandImportPath(importPath, sourceFile);
|
|
433
|
+
const insertPos = call.end;
|
|
434
|
+
const insertText = `, ${JSON.stringify(absolutePath)}`;
|
|
435
|
+
result = result.slice(0, insertPos) + insertText + result.slice(insertPos);
|
|
436
|
+
changed = true;
|
|
437
|
+
}
|
|
438
|
+
return changed ? result : null;
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Find the `v-client.*` directive on a tag.
|
|
442
|
+
*
|
|
443
|
+
* Returns the legacy directive name (e.g. `'client:load'`) for internal
|
|
444
|
+
* consistency with `<ubean-island data-directive="...">` format consumed by
|
|
445
|
+
* `hydrateIslands()`, or `null` if no client directive is present.
|
|
446
|
+
*
|
|
447
|
+
* Also returns the media query value for `v-client.media`.
|
|
448
|
+
*
|
|
449
|
+
* Phase 4: 旧的 `client:*` attribute 语法已移除,仅识别 `v-client.*`。
|
|
450
|
+
*/
|
|
451
|
+
function findClientDirectiveOnTag(attrs) {
|
|
452
|
+
for (const vcd of V_CLIENT_DIRECTIVES) if (attrs.has(vcd)) {
|
|
453
|
+
const strategy = legacyDirectiveToStrategy(vcd) || "load";
|
|
454
|
+
return {
|
|
455
|
+
directive: strategyToLegacyDirective(strategy),
|
|
456
|
+
mediaQuery: strategy === "media" ? extractMediaQuery(attrs.get(vcd)) : void 0
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Extract a media query string from a directive value.
|
|
463
|
+
*
|
|
464
|
+
* For `v-client.media="'(max-width: 768px)'"` the value is a Vue expression
|
|
465
|
+
* (string literal), so we strip surrounding quotes.
|
|
466
|
+
*/
|
|
467
|
+
function extractMediaQuery(raw) {
|
|
468
|
+
if (raw === void 0 || raw === true) return void 0;
|
|
469
|
+
let value = String(raw).trim();
|
|
470
|
+
if (value.startsWith("'") && value.endsWith("'") || value.startsWith("\"") && value.endsWith("\"")) value = value.slice(1, -1);
|
|
471
|
+
return value || void 0;
|
|
472
|
+
}
|
|
59
473
|
/**
|
|
60
|
-
* 扫描模板内容,返回所有带 `client
|
|
474
|
+
* 扫描模板内容,返回所有带 `v-client.*` 指令的组件标签名集合。
|
|
61
475
|
*
|
|
62
476
|
* 复用 `findTagAt` 的标签解析逻辑,确保与 `transformTemplate` 的识别规则一致
|
|
63
477
|
* (仅匹配首字母大写的组件标签)。
|
|
64
478
|
*
|
|
65
479
|
* 与 `transformTemplate` 对称:非 island 标签会递归扫描其 innerHTML,
|
|
66
480
|
* island 标签不递归(其子内容属于该 island 的 SSR 输出,不作为独立 island)。
|
|
481
|
+
*
|
|
482
|
+
* Phase 4: 仅识别 `v-client.*`(旧的 `client:*` 语法已移除)。
|
|
67
483
|
*/
|
|
68
484
|
function scanIslandDirectiveNames(template) {
|
|
69
485
|
const names = /* @__PURE__ */ new Set();
|
|
@@ -83,7 +499,7 @@ function scanIslandDirectiveNames(template) {
|
|
|
83
499
|
pos = lt + 1;
|
|
84
500
|
continue;
|
|
85
501
|
}
|
|
86
|
-
if (
|
|
502
|
+
if (findClientDirectiveOnTag(tag.attrs) !== null) names.add(tag.tagName);
|
|
87
503
|
if (!tag.selfClosing) scan(tag.innerHTML);
|
|
88
504
|
pos = tag.end;
|
|
89
505
|
}
|
|
@@ -102,7 +518,7 @@ function resolveIslandImportPath(importPath, sourceFile) {
|
|
|
102
518
|
* 步骤:
|
|
103
519
|
* 1. 提取 `<script setup>` / `<script>` 内容
|
|
104
520
|
* 2. 解析 import 语句,建立 { 局部名 → import 路径 } 映射
|
|
105
|
-
* 3. 扫描模板中的 `<Comp client
|
|
521
|
+
* 3. 扫描模板中的 `<Comp v-client.* />` 指令,得到组件名集合
|
|
106
522
|
* 4. 交集:既在 import 映射中、又在 island 指令集合中的组件
|
|
107
523
|
* 5. 将相对 import 路径解析为绝对路径
|
|
108
524
|
*
|
|
@@ -113,14 +529,14 @@ function collectIslandComponents(code, sourceFile) {
|
|
|
113
529
|
const scriptContent = extractScriptBlock(code);
|
|
114
530
|
const templateContent = extractTemplateBlock(code)?.content ?? "";
|
|
115
531
|
if (!templateContent) return [];
|
|
116
|
-
if (!
|
|
532
|
+
if (!ANY_CLIENT_DIRECTIVE_RE.test(templateContent)) return [];
|
|
117
533
|
const importMap = parseScriptImports(scriptContent);
|
|
118
534
|
const islandNames = scanIslandDirectiveNames(templateContent);
|
|
119
535
|
const entries = [];
|
|
120
536
|
for (const name of islandNames) {
|
|
121
537
|
const importPath = importMap.get(name);
|
|
122
538
|
if (!importPath) {
|
|
123
|
-
console.warn(`[ubean:islands] Component "${name}" used with client
|
|
539
|
+
console.warn(`[ubean:islands] Component "${name}" used with v-client.* directive in ${sourceFile} has no corresponding static import in <script setup>. It will not be auto-registered. Add it manually via hydrateIslands({ components: { ${name}: YourComp } }) or pass a getComponent() resolver.`);
|
|
124
540
|
continue;
|
|
125
541
|
}
|
|
126
542
|
const absolutePath = resolveIslandImportPath(importPath, sourceFile);
|
|
@@ -187,11 +603,42 @@ function generateRegistryModule(components) {
|
|
|
187
603
|
"};"
|
|
188
604
|
].join("\n");
|
|
189
605
|
}
|
|
606
|
+
/**
|
|
607
|
+
* 提取 SFC 中顶层的 `<template>` 块。
|
|
608
|
+
*
|
|
609
|
+
* 处理嵌套 `<template>` 标签:Vue SFC 的 `<template>` 块内部可能包含
|
|
610
|
+
* `<template #slot>` 等嵌套 template 标签(如作用域插槽、`v-if`/`v-for`
|
|
611
|
+
* 包装)。简单 `indexOf('</template>')` 会错误匹配到嵌套关闭标签,因此用
|
|
612
|
+
* 深度计数找到匹配的顶层关闭标签。
|
|
613
|
+
*
|
|
614
|
+
* 仅匹配小写 `<template`(SFC 块标签),不会误匹配 `<Template>` 组件。
|
|
615
|
+
*/
|
|
190
616
|
function extractTemplateBlock(code) {
|
|
191
617
|
const openMatch = code.match(/<template([^>]*)>/);
|
|
192
618
|
if (!openMatch) return null;
|
|
193
619
|
const openTagEnd = openMatch.index + openMatch[0].length;
|
|
194
|
-
const
|
|
620
|
+
const openRe = /<template[\s>]/g;
|
|
621
|
+
const closeRe = /<\/template>/g;
|
|
622
|
+
openRe.lastIndex = openTagEnd;
|
|
623
|
+
closeRe.lastIndex = openTagEnd;
|
|
624
|
+
let depth = 1;
|
|
625
|
+
let closeIdx = -1;
|
|
626
|
+
while (depth > 0) {
|
|
627
|
+
const nextOpen = openRe.exec(code);
|
|
628
|
+
const nextClose = closeRe.exec(code);
|
|
629
|
+
if (!nextClose) return null;
|
|
630
|
+
if (nextOpen && nextOpen.index < nextClose.index) {
|
|
631
|
+
depth++;
|
|
632
|
+
closeRe.lastIndex = nextOpen.index + nextOpen[0].length;
|
|
633
|
+
} else {
|
|
634
|
+
depth--;
|
|
635
|
+
if (depth === 0) {
|
|
636
|
+
closeIdx = nextClose.index;
|
|
637
|
+
break;
|
|
638
|
+
}
|
|
639
|
+
openRe.lastIndex = nextClose.index + nextClose[0].length;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
195
642
|
if (closeIdx === -1) return null;
|
|
196
643
|
return {
|
|
197
644
|
start: openMatch.index,
|
|
@@ -285,7 +732,7 @@ function parseAttrs(str) {
|
|
|
285
732
|
function collectStaticProps(attrs) {
|
|
286
733
|
const props = {};
|
|
287
734
|
for (const [key, val] of attrs) {
|
|
288
|
-
if (key.startsWith("client
|
|
735
|
+
if (key.startsWith("v-client.")) continue;
|
|
289
736
|
if (key.startsWith("v-") || key.startsWith("@") || key.startsWith(":")) continue;
|
|
290
737
|
if (key === "key" || key === "ref") continue;
|
|
291
738
|
if (val === true) props[key] = true;
|
|
@@ -324,8 +771,8 @@ function transformTemplate(template, islandCounter, filePath) {
|
|
|
324
771
|
pos = lt + 1;
|
|
325
772
|
continue;
|
|
326
773
|
}
|
|
327
|
-
const
|
|
328
|
-
if (!
|
|
774
|
+
const clientDirectiveInfo = findClientDirectiveOnTag(tag.attrs);
|
|
775
|
+
if (!clientDirectiveInfo) {
|
|
329
776
|
const inner = tag.selfClosing ? "" : transformTemplate(tag.innerHTML, islandCounter, filePath);
|
|
330
777
|
out += tag.fullOpenTag;
|
|
331
778
|
out += inner;
|
|
@@ -333,11 +780,11 @@ function transformTemplate(template, islandCounter, filePath) {
|
|
|
333
780
|
pos = tag.end;
|
|
334
781
|
continue;
|
|
335
782
|
}
|
|
783
|
+
const { directive, mediaQuery } = clientDirectiveInfo;
|
|
336
784
|
islandCounter.count++;
|
|
337
785
|
const islandId = `island-${filePath.replace(/[^a-zA-Z0-9]/g, "-")}-${islandCounter.count}`;
|
|
338
|
-
const mediaRaw = tag.attrs.get("client:media");
|
|
339
786
|
let mediaStr = "";
|
|
340
|
-
if (directive === "client:media" &&
|
|
787
|
+
if (directive === "client:media" && mediaQuery) mediaStr = ` data-media="${escapeAttr(mediaQuery)}"`;
|
|
341
788
|
const props = collectStaticProps(tag.attrs);
|
|
342
789
|
const propsJson = escapeAttr(JSON.stringify(props));
|
|
343
790
|
out += `<ubean-island v-once data-island-id="${islandId}" data-component="${tag.tagName}" data-directive="${directive}" data-props="${propsJson}"${mediaStr}>`;
|
|
@@ -353,7 +800,7 @@ function transformVueSfcIslands(code, filePath) {
|
|
|
353
800
|
code,
|
|
354
801
|
islandCount: 0
|
|
355
802
|
};
|
|
356
|
-
if (!
|
|
803
|
+
if (!ANY_DIRECTIVE_RE.test(tpl.content)) return {
|
|
357
804
|
code,
|
|
358
805
|
islandCount: 0
|
|
359
806
|
};
|
|
@@ -421,20 +868,74 @@ function ubeanIslandsPlugin(_options = {}) {
|
|
|
421
868
|
configureServer(server) {
|
|
422
869
|
devServer = server;
|
|
423
870
|
},
|
|
424
|
-
resolveId(id) {
|
|
871
|
+
async resolveId(id, importer, options) {
|
|
425
872
|
if (id === "virtual:ubean-islands-registry") return ISLANDS_REGISTRY_RESOLVED_ID;
|
|
873
|
+
if (!enabled) return void 0;
|
|
874
|
+
if (!options?.ssr && isServerComponentFile(id)) return SERVER_COMPONENT_STUB_RESOLVED_ID;
|
|
875
|
+
if (isClientComponentFile(id) && !importer?.startsWith(CLIENT_COMPONENT_WRAPPER_PREFIX) && !importer?.startsWith(PAIRED_COMPONENT_WRAPPER_PREFIX)) {
|
|
876
|
+
if (options?.ssr) return CLIENT_COMPONENT_PLACEHOLDER_RESOLVED_ID;
|
|
877
|
+
const resolved = await this.resolve(id, importer, { skipSelf: true });
|
|
878
|
+
if (!resolved) return void 0;
|
|
879
|
+
return `${CLIENT_COMPONENT_WRAPPER_PREFIX}${resolved.id}`;
|
|
880
|
+
}
|
|
881
|
+
if (importer && !importer.startsWith("\0") && id.endsWith(".vue") && !isServerComponentFile(id) && !isClientComponentFile(id) && id.startsWith(".")) {
|
|
882
|
+
const importerDir = dirname(resolve(importer));
|
|
883
|
+
const baseName = resolve(importerDir, id).slice(0, -4);
|
|
884
|
+
const serverSibling = `${baseName}.server.vue`;
|
|
885
|
+
const clientSibling = `${baseName}.client.vue`;
|
|
886
|
+
const hasServer = existsSync(serverSibling);
|
|
887
|
+
const hasClient = existsSync(clientSibling);
|
|
888
|
+
if (hasServer && hasClient) return `${PAIRED_COMPONENT_WRAPPER_PREFIX}${serverSibling}${PAIRED_PATH_SEPARATOR}${clientSibling}`;
|
|
889
|
+
if (hasServer) return serverSibling;
|
|
890
|
+
if (hasClient) return clientSibling;
|
|
891
|
+
}
|
|
426
892
|
},
|
|
427
|
-
load(id) {
|
|
428
|
-
if (id
|
|
429
|
-
|
|
893
|
+
load(id, options) {
|
|
894
|
+
if (id === ISLANDS_REGISTRY_RESOLVED_ID) return generateRegistryModule(islandComponents);
|
|
895
|
+
if (id === SERVER_COMPONENT_STUB_RESOLVED_ID) return `import { ServerComponentStub } from '@ubean/islands/runtime';\nexport default ServerComponentStub;`;
|
|
896
|
+
if (id === CLIENT_COMPONENT_PLACEHOLDER_RESOLVED_ID) return `import { ClientComponentPlaceholder } from '@ubean/islands/runtime';\nexport default ClientComponentPlaceholder;`;
|
|
897
|
+
if (id.startsWith(CLIENT_COMPONENT_WRAPPER_PREFIX)) {
|
|
898
|
+
const realPath = id.slice(32);
|
|
899
|
+
return `import RealComp from ${JSON.stringify(realPath)};\nimport { defineClientComponent } from '@ubean/islands/runtime';\nexport default defineClientComponent(RealComp);`;
|
|
900
|
+
}
|
|
901
|
+
if (id.startsWith(PAIRED_COMPONENT_WRAPPER_PREFIX)) {
|
|
902
|
+
const payload = id.slice(32);
|
|
903
|
+
const sepIdx = payload.indexOf(PAIRED_PATH_SEPARATOR);
|
|
904
|
+
if (sepIdx === -1) return void 0;
|
|
905
|
+
const serverPath = payload.slice(0, sepIdx);
|
|
906
|
+
const clientPath = payload.slice(sepIdx + 1);
|
|
907
|
+
if (options?.ssr) return `import ServerComp from ${JSON.stringify(serverPath)};\nexport default ServerComp;`;
|
|
908
|
+
return `import ServerComp from ${JSON.stringify(serverPath)};\nimport ClientComp from ${JSON.stringify(clientPath)};\nimport { definePairedComponent } from '@ubean/islands/runtime';\nexport default definePairedComponent(ServerComp, ClientComp);`;
|
|
909
|
+
}
|
|
430
910
|
},
|
|
431
911
|
transform(code, id) {
|
|
432
912
|
if (!enabled) return null;
|
|
433
|
-
|
|
434
|
-
|
|
913
|
+
const normalizedId = id.split("?")[0];
|
|
914
|
+
const isVue = isVueSfc(id);
|
|
915
|
+
const isTs = normalizedId.endsWith(".ts") && !id.includes("?");
|
|
916
|
+
const originalCode = code;
|
|
917
|
+
if (isVue || isTs) {
|
|
918
|
+
const injected = injectServerComponentPath(code, normalizedId);
|
|
919
|
+
if (injected !== null) code = injected;
|
|
920
|
+
}
|
|
921
|
+
if (!isVue) return isTs && code !== originalCode ? { code } : null;
|
|
922
|
+
let serverWrapped = false;
|
|
923
|
+
if (isServerComponentFile(id)) {
|
|
924
|
+
const wrapped = wrapServerComponentTemplate(code);
|
|
925
|
+
if (wrapped !== null) {
|
|
926
|
+
code = wrapped;
|
|
927
|
+
serverWrapped = true;
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
if (!ANY_DIRECTIVE_RE.test(code) && !serverWrapped) return code !== originalCode ? { code } : null;
|
|
435
931
|
const absolutePath = id.split("?")[0];
|
|
436
|
-
const
|
|
437
|
-
|
|
932
|
+
const filePath = absolutePath.replace(viteConfig.root, "").replace(/^[/\\]/, "");
|
|
933
|
+
let result = {
|
|
934
|
+
code,
|
|
935
|
+
islandCount: 0
|
|
936
|
+
};
|
|
937
|
+
if (ANY_DIRECTIVE_RE.test(code)) result = transformVueSfcIslands(code, filePath);
|
|
938
|
+
if (isMainVueSfc(id) && ANY_DIRECTIVE_RE.test(code)) {
|
|
438
939
|
const registryChanged = updateRegistry(collectIslandComponents(code, absolutePath), absolutePath);
|
|
439
940
|
const isHmrUpdate = transformedFiles.has(absolutePath);
|
|
440
941
|
transformedFiles.add(absolutePath);
|
|
@@ -451,4 +952,4 @@ function ubeanIslandsPlugin(_options = {}) {
|
|
|
451
952
|
};
|
|
452
953
|
}
|
|
453
954
|
//#endregion
|
|
454
|
-
export { ISLANDS_REGISTRY_VIRTUAL_ID, collectIslandComponents, generateRegistryModule, parseScriptImports, resolveIslandImportPath, scanIslandDirectiveNames, transformVueSfcIslands, ubeanIslandsPlugin };
|
|
955
|
+
export { CLIENT_COMPONENT_PLACEHOLDER_VIRTUAL_ID, ISLANDS_REGISTRY_VIRTUAL_ID, SERVER_COMPONENT_STUB_VIRTUAL_ID, collectIslandComponents, generateRegistryModule, injectServerComponentPath, isClientComponentFile, isServerComponentFile, parseScriptImports, resolveIslandImportPath, scanIslandDirectiveNames, transformVueSfcIslands, ubeanIslandsPlugin, wrapServerComponentTemplate };
|