@ubean/islands 0.1.8 → 0.1.9
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/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/runtime.js +2 -0
- package/dist/virtual-registry.d.ts +26 -0
- package/dist/vite.d.ts +77 -1
- package/dist/vite.js +248 -3
- package/package.json +6 -2
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { UbeanIslandsPluginOptions, transformVueSfcIslands, ubeanIslandsPlugin } from "./vite.js";
|
|
1
|
+
import { ISLANDS_REGISTRY_VIRTUAL_ID, IslandComponentEntry, IslandComponentMap, UbeanIslandsPluginOptions, collectIslandComponents, generateRegistryModule, parseScriptImports, resolveIslandImportPath, scanIslandDirectiveNames, transformVueSfcIslands, ubeanIslandsPlugin } from "./vite.js";
|
|
2
2
|
//#region src/types.d.ts
|
|
3
3
|
type ClientDirective = 'client:load' | 'client:idle' | 'client:visible' | 'client:media' | 'client:only';
|
|
4
4
|
interface IslandDefinition {
|
|
@@ -37,4 +37,4 @@ declare const hydrationStrategyMeta: Record<ClientDirective, {
|
|
|
37
37
|
declare function getIslandsBootstrapScript(): string;
|
|
38
38
|
declare function getIslandsClearScript(): string;
|
|
39
39
|
//#endregion
|
|
40
|
-
export { type ClientDirective, type ClientHydrationStrategy, type IslandDefinition, type IslandSsrOptions, type IslandsContext, type UbeanIslandsPluginOptions, createIslandsContext, generateIslandPlaceholder, getIslandsBootstrapScript, getIslandsClearScript, getIslandsScript, hydrationStrategyMeta, registerIsland, renderIslandPlaceholder, transformVueSfcIslands, ubeanIslandsPlugin };
|
|
40
|
+
export { type ClientDirective, type ClientHydrationStrategy, ISLANDS_REGISTRY_VIRTUAL_ID, type IslandComponentEntry, type IslandComponentMap, type IslandDefinition, type IslandSsrOptions, type IslandsContext, type UbeanIslandsPluginOptions, collectIslandComponents, createIslandsContext, generateIslandPlaceholder, generateRegistryModule, getIslandsBootstrapScript, getIslandsClearScript, getIslandsScript, hydrationStrategyMeta, parseScriptImports, registerIsland, renderIslandPlaceholder, resolveIslandImportPath, scanIslandDirectiveNames, transformVueSfcIslands, ubeanIslandsPlugin };
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { transformVueSfcIslands, ubeanIslandsPlugin } from "./vite.js";
|
|
1
|
+
import { ISLANDS_REGISTRY_VIRTUAL_ID, collectIslandComponents, generateRegistryModule, parseScriptImports, resolveIslandImportPath, scanIslandDirectiveNames, transformVueSfcIslands, ubeanIslandsPlugin } from "./vite.js";
|
|
2
2
|
//#region src/types.ts
|
|
3
3
|
function createIslandsContext() {
|
|
4
4
|
return {
|
|
@@ -65,4 +65,4 @@ function getIslandsClearScript() {
|
|
|
65
65
|
return `(function(){function clearIslands(){var islands=document.querySelectorAll('ubean-island[data-island-id]');islands.forEach(function(el){if(el.getAttribute('data-directive')!=='client:only'){return}el.innerHTML='';el.setAttribute('data-cleared','true')})}if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',clearIslands)}else{clearIslands()}})();`;
|
|
66
66
|
}
|
|
67
67
|
//#endregion
|
|
68
|
-
export { createIslandsContext, generateIslandPlaceholder, getIslandsBootstrapScript, getIslandsClearScript, getIslandsScript, hydrationStrategyMeta, registerIsland, renderIslandPlaceholder, transformVueSfcIslands, ubeanIslandsPlugin };
|
|
68
|
+
export { ISLANDS_REGISTRY_VIRTUAL_ID, collectIslandComponents, createIslandsContext, generateIslandPlaceholder, generateRegistryModule, getIslandsBootstrapScript, getIslandsClearScript, getIslandsScript, hydrationStrategyMeta, parseScriptImports, registerIsland, renderIslandPlaceholder, resolveIslandImportPath, scanIslandDirectiveNames, transformVueSfcIslands, ubeanIslandsPlugin };
|
package/dist/runtime.js
CHANGED
|
@@ -120,6 +120,8 @@ function hydrateIslands(options = {}) {
|
|
|
120
120
|
function resolveComponent(name, components, getComponent) {
|
|
121
121
|
if (components[name]) return components[name];
|
|
122
122
|
if (getComponent) return getComponent(name);
|
|
123
|
+
const registered = Object.keys(components);
|
|
124
|
+
console.warn(`[ubean:islands] Island component "${name}" not found in registry.\nPossible causes:\n 1. Component is globally registered or dynamically imported — pass it via hydrateIslands({ components: { ${name}: YourComp } })\n 2. Component name mismatch between template tag and import\n 3. Component is auto-imported by unplugin-vue-components (no static import → not in auto-registry)\nRegistered components: ${registered.length > 0 ? registered.join(", ") : "(none)"}`);
|
|
123
125
|
return null;
|
|
124
126
|
}
|
|
125
127
|
//#endregion
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `virtual:ubean-islands-registry` 的 TypeScript 类型声明。
|
|
3
|
+
*
|
|
4
|
+
* 此虚拟模块由 `ubeanIslandsPlugin` 在构建/开发期生成,导出扫描到的 island 组件注册表。
|
|
5
|
+
* 详见 `docs/islands-auto-registry.md`(方案 C)。
|
|
6
|
+
*
|
|
7
|
+
* 用户项目通常无需手动引用此声明 —— 只要 `tsconfig.json` 的 `include` 涵盖了
|
|
8
|
+
* `node_modules/@ubean/islands/dist/**`,TypeScript 会自动发现此 ambient 声明。
|
|
9
|
+
* 若未自动发现,可在项目的 `env.d.ts` 中添加:
|
|
10
|
+
* ```ts
|
|
11
|
+
* /// <reference types="@ubean/islands" />
|
|
12
|
+
* ```
|
|
13
|
+
* 或手动声明 `declare module 'virtual:ubean-islands-registry'`。
|
|
14
|
+
*/
|
|
15
|
+
declare module 'virtual:ubean-islands-registry' {
|
|
16
|
+
import type { Component } from 'vue';
|
|
17
|
+
/**
|
|
18
|
+
* 自动扫描得到的 island 组件注册表。
|
|
19
|
+
*
|
|
20
|
+
* - key:模板中使用的组件标签名(如 `<IslandCounter client:load />` 中的 `IslandCounter`)
|
|
21
|
+
* - value:对应的 Vue 组件(可能是同步组件或返回 Promise 的懒加载函数)
|
|
22
|
+
*
|
|
23
|
+
* 当未扫描到任何 island 组件时,导出空对象 `{}`。
|
|
24
|
+
*/
|
|
25
|
+
export const islands: Record<string, Component | (() => Promise<Component>)>;
|
|
26
|
+
}
|
package/dist/vite.d.ts
CHANGED
|
@@ -1,6 +1,80 @@
|
|
|
1
1
|
import { Plugin } from "vite";
|
|
2
2
|
//#region src/vite.d.ts
|
|
3
3
|
type ClientDirective = 'client:load' | 'client:idle' | 'client:visible' | 'client:media' | 'client:only';
|
|
4
|
+
/**
|
|
5
|
+
* 一个 island 组件的注册条目。
|
|
6
|
+
*
|
|
7
|
+
* - `name`:模板中使用的标签名(如 `<IslandCounter client:load />` 中的 `IslandCounter`)
|
|
8
|
+
* - `importPath`:组件文件的绝对路径或 bare specifier(来自 `<script setup>` 的 import)
|
|
9
|
+
* - `sourceFile`:发现该 island 的源文件绝对路径,用于调试与 HMR 失效
|
|
10
|
+
*/
|
|
11
|
+
interface IslandComponentEntry {
|
|
12
|
+
name: string;
|
|
13
|
+
importPath: string;
|
|
14
|
+
sourceFile: string;
|
|
15
|
+
}
|
|
16
|
+
/** Plugin 内部的 island 组件收集表:组件名 → 条目 */
|
|
17
|
+
type IslandComponentMap = Map<string, IslandComponentEntry>;
|
|
18
|
+
/**
|
|
19
|
+
* 从 `<script setup>` / `<script>` 内容中解析 default import 与 `default as` 别名,
|
|
20
|
+
* 建立 { 局部名 → import 路径 } 映射。
|
|
21
|
+
*
|
|
22
|
+
* 仅处理静态 import 语句;动态 import、re-export 不在识别范围内
|
|
23
|
+
* (这类用法无法静态分析 → 由运行时 `getComponent` 兜底或用户手动注册)。
|
|
24
|
+
*
|
|
25
|
+
* 支持形式:
|
|
26
|
+
* import Foo from './Foo.vue'
|
|
27
|
+
* import Foo from "some-lib"
|
|
28
|
+
* import Foo, { bar } from './Foo.vue'
|
|
29
|
+
* import { default as Foo, bar } from './Foo.vue'
|
|
30
|
+
* import { default as Foo } from './Foo.vue'
|
|
31
|
+
*/
|
|
32
|
+
declare function parseScriptImports(scriptContent: string): Map<string, string>;
|
|
33
|
+
/**
|
|
34
|
+
* 扫描模板内容,返回所有带 `client:xxx` 指令的组件标签名集合。
|
|
35
|
+
*
|
|
36
|
+
* 复用 `findTagAt` 的标签解析逻辑,确保与 `transformTemplate` 的识别规则一致
|
|
37
|
+
* (仅匹配首字母大写的组件标签)。
|
|
38
|
+
*
|
|
39
|
+
* 与 `transformTemplate` 对称:非 island 标签会递归扫描其 innerHTML,
|
|
40
|
+
* island 标签不递归(其子内容属于该 island 的 SSR 输出,不作为独立 island)。
|
|
41
|
+
*/
|
|
42
|
+
declare function scanIslandDirectiveNames(template: string): Set<string>;
|
|
43
|
+
/**
|
|
44
|
+
* 将相对 import 路径解析为绝对路径;bare specifier(如 `vue`、`some-lib`)原样返回。
|
|
45
|
+
*/
|
|
46
|
+
declare function resolveIslandImportPath(importPath: string, sourceFile: string): string;
|
|
47
|
+
/**
|
|
48
|
+
* 从 Vue SFC 源码中收集 island 组件信息。
|
|
49
|
+
*
|
|
50
|
+
* 步骤:
|
|
51
|
+
* 1. 提取 `<script setup>` / `<script>` 内容
|
|
52
|
+
* 2. 解析 import 语句,建立 { 局部名 → import 路径 } 映射
|
|
53
|
+
* 3. 扫描模板中的 `<Comp client:xxx />` 指令,得到组件名集合
|
|
54
|
+
* 4. 交集:既在 import 映射中、又在 island 指令集合中的组件
|
|
55
|
+
* 5. 将相对 import 路径解析为绝对路径
|
|
56
|
+
*
|
|
57
|
+
* 对未在 import 映射中的 island 组件(如全局注册、动态 import、defineAsyncComponent),
|
|
58
|
+
* 输出警告,提示用户手动注册。
|
|
59
|
+
*/
|
|
60
|
+
declare function collectIslandComponents(code: string, sourceFile: string): IslandComponentEntry[];
|
|
61
|
+
/**
|
|
62
|
+
* 生成 `virtual:ubean-islands-registry` 模块内容。
|
|
63
|
+
*
|
|
64
|
+
* 输出示例:
|
|
65
|
+
* ```ts
|
|
66
|
+
* import __island_0 from '/src/components/IslandCounter.vue';
|
|
67
|
+
* import __island_1 from '/src/components/IslandMedia.vue';
|
|
68
|
+
*
|
|
69
|
+
* export const islands = {
|
|
70
|
+
* "IslandCounter": __island_0,
|
|
71
|
+
* "IslandMedia": __island_1
|
|
72
|
+
* };
|
|
73
|
+
* ```
|
|
74
|
+
*
|
|
75
|
+
* 当 map 为空时,返回 `export const islands = {};` 保证下游 import 不会因模块缺失而报错。
|
|
76
|
+
*/
|
|
77
|
+
declare function generateRegistryModule(components: IslandComponentMap): string;
|
|
4
78
|
declare function transformVueSfcIslands(code: string, filePath: string): {
|
|
5
79
|
code: string;
|
|
6
80
|
islandCount: number;
|
|
@@ -8,6 +82,8 @@ declare function transformVueSfcIslands(code: string, filePath: string): {
|
|
|
8
82
|
interface UbeanIslandsPluginOptions {
|
|
9
83
|
enabled?: boolean;
|
|
10
84
|
}
|
|
85
|
+
/** virtual:ubean-islands-registry 的虚拟模块 ID(遵循 AGENTS.md §8 教训 #7:用 `virtual:ubean-` 前缀) */
|
|
86
|
+
declare const ISLANDS_REGISTRY_VIRTUAL_ID = "virtual:ubean-islands-registry";
|
|
11
87
|
declare function ubeanIslandsPlugin(_options?: UbeanIslandsPluginOptions): Plugin;
|
|
12
88
|
//#endregion
|
|
13
|
-
export { ClientDirective, UbeanIslandsPluginOptions, transformVueSfcIslands, ubeanIslandsPlugin };
|
|
89
|
+
export { ClientDirective, ISLANDS_REGISTRY_VIRTUAL_ID, IslandComponentEntry, IslandComponentMap, UbeanIslandsPluginOptions, collectIslandComponents, generateRegistryModule, parseScriptImports, resolveIslandImportPath, scanIslandDirectiveNames, transformVueSfcIslands, ubeanIslandsPlugin };
|
package/dist/vite.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { dirname, resolve } from "node:path";
|
|
1
2
|
//#region src/vite.ts
|
|
2
3
|
const CLIENT_DIRECTIVES = [
|
|
3
4
|
"client:load",
|
|
@@ -10,6 +11,182 @@ const DIRECTIVE_RE = /\bclient:(load|idle|visible|media|only)\b/;
|
|
|
10
11
|
function isVueSfc(id) {
|
|
11
12
|
return /\.vue(?:\?.*)?$/.test(id) && !id.includes("&type=");
|
|
12
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* 判断 id 是否为 SFC 主模块(非 `?vue&type=...` 子查询)。
|
|
16
|
+
*
|
|
17
|
+
* 收集 island 组件时需要同时访问 `<script setup>` 与 `<template>` 块,
|
|
18
|
+
* 因此只对 SFC 主模块运行;模板子查询只含模板内容,无法解析 import。
|
|
19
|
+
*/
|
|
20
|
+
function isMainVueSfc(id) {
|
|
21
|
+
return id.split("?")[0].endsWith(".vue");
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* 从 `<script setup>` / `<script>` 内容中解析 default import 与 `default as` 别名,
|
|
25
|
+
* 建立 { 局部名 → import 路径 } 映射。
|
|
26
|
+
*
|
|
27
|
+
* 仅处理静态 import 语句;动态 import、re-export 不在识别范围内
|
|
28
|
+
* (这类用法无法静态分析 → 由运行时 `getComponent` 兜底或用户手动注册)。
|
|
29
|
+
*
|
|
30
|
+
* 支持形式:
|
|
31
|
+
* import Foo from './Foo.vue'
|
|
32
|
+
* import Foo from "some-lib"
|
|
33
|
+
* import Foo, { bar } from './Foo.vue'
|
|
34
|
+
* import { default as Foo, bar } from './Foo.vue'
|
|
35
|
+
* import { default as Foo } from './Foo.vue'
|
|
36
|
+
*/
|
|
37
|
+
function parseScriptImports(scriptContent) {
|
|
38
|
+
const map = /* @__PURE__ */ new Map();
|
|
39
|
+
if (!scriptContent) return map;
|
|
40
|
+
const defaultRe = /import\s+([A-Za-z_$][\w$]*)(?:\s*,\s*(?:\{[^}]*\}|\*\s+as\s+[A-Za-z_$][\w$]*))?\s+from\s+['"]([^'"]+)['"]/g;
|
|
41
|
+
let m;
|
|
42
|
+
while ((m = defaultRe.exec(scriptContent)) !== null) {
|
|
43
|
+
const localName = m[1];
|
|
44
|
+
const importPath = m[2];
|
|
45
|
+
if (!map.has(localName)) map.set(localName, importPath);
|
|
46
|
+
}
|
|
47
|
+
const namedDefaultRe = /import\s+\{([^}]*)\}\s+from\s+['"]([^'"]+)['"]/g;
|
|
48
|
+
while ((m = namedDefaultRe.exec(scriptContent)) !== null) {
|
|
49
|
+
const clause = m[1];
|
|
50
|
+
const importPath = m[2];
|
|
51
|
+
const asMatch = clause.match(/\bdefault\s+as\s+([A-Za-z_$][\w$]*)/);
|
|
52
|
+
if (asMatch) {
|
|
53
|
+
const localName = asMatch[1];
|
|
54
|
+
if (!map.has(localName)) map.set(localName, importPath);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return map;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* 扫描模板内容,返回所有带 `client:xxx` 指令的组件标签名集合。
|
|
61
|
+
*
|
|
62
|
+
* 复用 `findTagAt` 的标签解析逻辑,确保与 `transformTemplate` 的识别规则一致
|
|
63
|
+
* (仅匹配首字母大写的组件标签)。
|
|
64
|
+
*
|
|
65
|
+
* 与 `transformTemplate` 对称:非 island 标签会递归扫描其 innerHTML,
|
|
66
|
+
* island 标签不递归(其子内容属于该 island 的 SSR 输出,不作为独立 island)。
|
|
67
|
+
*/
|
|
68
|
+
function scanIslandDirectiveNames(template) {
|
|
69
|
+
const names = /* @__PURE__ */ new Set();
|
|
70
|
+
scan(template);
|
|
71
|
+
return names;
|
|
72
|
+
function scan(t) {
|
|
73
|
+
let pos = 0;
|
|
74
|
+
while (pos < t.length) {
|
|
75
|
+
const lt = t.indexOf("<", pos);
|
|
76
|
+
if (lt === -1) break;
|
|
77
|
+
if (t[lt + 1] === "/" || t[lt + 1] === "!" || t[lt + 1] === "?") {
|
|
78
|
+
pos = lt + 1;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const tag = findTagAt(t, lt);
|
|
82
|
+
if (!tag) {
|
|
83
|
+
pos = lt + 1;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (CLIENT_DIRECTIVES.some((d) => tag.attrs.has(d))) names.add(tag.tagName);
|
|
87
|
+
if (!tag.selfClosing) scan(tag.innerHTML);
|
|
88
|
+
pos = tag.end;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* 将相对 import 路径解析为绝对路径;bare specifier(如 `vue`、`some-lib`)原样返回。
|
|
94
|
+
*/
|
|
95
|
+
function resolveIslandImportPath(importPath, sourceFile) {
|
|
96
|
+
if (importPath.startsWith(".")) return resolve(dirname(sourceFile), importPath);
|
|
97
|
+
return importPath;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* 从 Vue SFC 源码中收集 island 组件信息。
|
|
101
|
+
*
|
|
102
|
+
* 步骤:
|
|
103
|
+
* 1. 提取 `<script setup>` / `<script>` 内容
|
|
104
|
+
* 2. 解析 import 语句,建立 { 局部名 → import 路径 } 映射
|
|
105
|
+
* 3. 扫描模板中的 `<Comp client:xxx />` 指令,得到组件名集合
|
|
106
|
+
* 4. 交集:既在 import 映射中、又在 island 指令集合中的组件
|
|
107
|
+
* 5. 将相对 import 路径解析为绝对路径
|
|
108
|
+
*
|
|
109
|
+
* 对未在 import 映射中的 island 组件(如全局注册、动态 import、defineAsyncComponent),
|
|
110
|
+
* 输出警告,提示用户手动注册。
|
|
111
|
+
*/
|
|
112
|
+
function collectIslandComponents(code, sourceFile) {
|
|
113
|
+
const scriptContent = extractScriptBlock(code);
|
|
114
|
+
const templateContent = extractTemplateBlock(code)?.content ?? "";
|
|
115
|
+
if (!templateContent) return [];
|
|
116
|
+
if (!DIRECTIVE_RE.test(templateContent)) return [];
|
|
117
|
+
const importMap = parseScriptImports(scriptContent);
|
|
118
|
+
const islandNames = scanIslandDirectiveNames(templateContent);
|
|
119
|
+
const entries = [];
|
|
120
|
+
for (const name of islandNames) {
|
|
121
|
+
const importPath = importMap.get(name);
|
|
122
|
+
if (!importPath) {
|
|
123
|
+
console.warn(`[ubean:islands] Component "${name}" used with client:xxx 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
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const absolutePath = resolveIslandImportPath(importPath, sourceFile);
|
|
127
|
+
entries.push({
|
|
128
|
+
name,
|
|
129
|
+
importPath: absolutePath,
|
|
130
|
+
sourceFile
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
return entries;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* 提取 SFC 中 `<script setup>` 或 `<script>` 块的内容。
|
|
137
|
+
*
|
|
138
|
+
* 优先返回 `<script setup>`;若不存在则返回普通 `<script>`;都没有则返回空字符串。
|
|
139
|
+
* 与 `extractTemplateBlock` 对称,使用简单字符串匹配避免引入 SFC 编译器依赖。
|
|
140
|
+
*/
|
|
141
|
+
function extractScriptBlock(code) {
|
|
142
|
+
const setupMatch = code.match(/<script\s+[^>]*setup[^>]*>/);
|
|
143
|
+
if (setupMatch && setupMatch.index !== void 0) {
|
|
144
|
+
const openTagEnd = setupMatch.index + setupMatch[0].length;
|
|
145
|
+
const closeIdx = code.indexOf("<\/script>", openTagEnd);
|
|
146
|
+
if (closeIdx !== -1) return code.slice(openTagEnd, closeIdx);
|
|
147
|
+
}
|
|
148
|
+
const plainMatch = code.match(/<script(?!\s[^>]*setup)[^>]*>/);
|
|
149
|
+
if (plainMatch && plainMatch.index !== void 0) {
|
|
150
|
+
const openTagEnd = plainMatch.index + plainMatch[0].length;
|
|
151
|
+
const closeIdx = code.indexOf("<\/script>", openTagEnd);
|
|
152
|
+
if (closeIdx !== -1) return code.slice(openTagEnd, closeIdx);
|
|
153
|
+
}
|
|
154
|
+
return "";
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* 生成 `virtual:ubean-islands-registry` 模块内容。
|
|
158
|
+
*
|
|
159
|
+
* 输出示例:
|
|
160
|
+
* ```ts
|
|
161
|
+
* import __island_0 from '/src/components/IslandCounter.vue';
|
|
162
|
+
* import __island_1 from '/src/components/IslandMedia.vue';
|
|
163
|
+
*
|
|
164
|
+
* export const islands = {
|
|
165
|
+
* "IslandCounter": __island_0,
|
|
166
|
+
* "IslandMedia": __island_1
|
|
167
|
+
* };
|
|
168
|
+
* ```
|
|
169
|
+
*
|
|
170
|
+
* 当 map 为空时,返回 `export const islands = {};` 保证下游 import 不会因模块缺失而报错。
|
|
171
|
+
*/
|
|
172
|
+
function generateRegistryModule(components) {
|
|
173
|
+
if (components.size === 0) return "export const islands = {};";
|
|
174
|
+
const imports = [];
|
|
175
|
+
const entries = [];
|
|
176
|
+
let idx = 0;
|
|
177
|
+
for (const [name, entry] of components) {
|
|
178
|
+
const varName = `__island_${idx++}`;
|
|
179
|
+
imports.push(`import ${varName} from ${JSON.stringify(entry.importPath)};`);
|
|
180
|
+
entries.push(` ${JSON.stringify(name)}: ${varName},`);
|
|
181
|
+
}
|
|
182
|
+
return [
|
|
183
|
+
...imports,
|
|
184
|
+
"",
|
|
185
|
+
"export const islands = {",
|
|
186
|
+
...entries,
|
|
187
|
+
"};"
|
|
188
|
+
].join("\n");
|
|
189
|
+
}
|
|
13
190
|
function extractTemplateBlock(code) {
|
|
14
191
|
const openMatch = code.match(/<template([^>]*)>/);
|
|
15
192
|
if (!openMatch) return null;
|
|
@@ -163,7 +340,7 @@ function transformTemplate(template, islandCounter, filePath) {
|
|
|
163
340
|
if (directive === "client:media" && mediaRaw && mediaRaw !== true) mediaStr = ` data-media="${escapeAttr(String(mediaRaw).replace(/^["']|["']$/g, ""))}"`;
|
|
164
341
|
const props = collectStaticProps(tag.attrs);
|
|
165
342
|
const propsJson = escapeAttr(JSON.stringify(props));
|
|
166
|
-
out += `<ubean-island data-island-id="${islandId}" data-component="${tag.tagName}" data-directive="${directive}" data-props="${propsJson}"${mediaStr}>`;
|
|
343
|
+
out += `<ubean-island v-once data-island-id="${islandId}" data-component="${tag.tagName}" data-directive="${directive}" data-props="${propsJson}"${mediaStr}>`;
|
|
167
344
|
out += tag.selfClosing ? "" : tag.innerHTML;
|
|
168
345
|
out += `</ubean-island>`;
|
|
169
346
|
pos = tag.end;
|
|
@@ -187,9 +364,53 @@ function transformVueSfcIslands(code, filePath) {
|
|
|
187
364
|
islandCount: counter.count
|
|
188
365
|
};
|
|
189
366
|
}
|
|
367
|
+
/** virtual:ubean-islands-registry 的虚拟模块 ID(遵循 AGENTS.md §8 教训 #7:用 `virtual:ubean-` 前缀) */
|
|
368
|
+
const ISLANDS_REGISTRY_VIRTUAL_ID = "virtual:ubean-islands-registry";
|
|
369
|
+
/** Vite 内部解析后的虚拟模块 ID(`\0` 前缀防止其他插件处理) */
|
|
370
|
+
const ISLANDS_REGISTRY_RESOLVED_ID = `\0${ISLANDS_REGISTRY_VIRTUAL_ID}`;
|
|
190
371
|
function ubeanIslandsPlugin(_options = {}) {
|
|
191
372
|
let viteConfig;
|
|
192
373
|
let enabled = true;
|
|
374
|
+
let devServer;
|
|
375
|
+
const islandComponents = /* @__PURE__ */ new Map();
|
|
376
|
+
const entriesByFile = /* @__PURE__ */ new Map();
|
|
377
|
+
const transformedFiles = /* @__PURE__ */ new Set();
|
|
378
|
+
/**
|
|
379
|
+
* 用某次扫描结果更新 island 组件注册表。
|
|
380
|
+
*
|
|
381
|
+
* - 先清理该源文件之前贡献、现已不再使用的组件条目(若其他文件未引用)
|
|
382
|
+
* - 再添加新条目;同名组件若 import 路径不同 → 警告(首次发现的路径优先)
|
|
383
|
+
* - 返回 registry 是否发生变化(用于决定是否触发 HMR)
|
|
384
|
+
*/
|
|
385
|
+
function updateRegistry(entries, sourceFile) {
|
|
386
|
+
let changed = false;
|
|
387
|
+
const oldNames = entriesByFile.get(sourceFile) ?? /* @__PURE__ */ new Set();
|
|
388
|
+
const newNames = new Set(entries.map((e) => e.name));
|
|
389
|
+
for (const name of oldNames) {
|
|
390
|
+
if (newNames.has(name)) continue;
|
|
391
|
+
let stillUsed = false;
|
|
392
|
+
for (const [otherFile, names] of entriesByFile) {
|
|
393
|
+
if (otherFile === sourceFile) continue;
|
|
394
|
+
if (names.has(name)) {
|
|
395
|
+
stillUsed = true;
|
|
396
|
+
break;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
if (!stillUsed && islandComponents.has(name)) {
|
|
400
|
+
islandComponents.delete(name);
|
|
401
|
+
changed = true;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
for (const entry of entries) {
|
|
405
|
+
const existing = islandComponents.get(entry.name);
|
|
406
|
+
if (!existing) {
|
|
407
|
+
islandComponents.set(entry.name, entry);
|
|
408
|
+
changed = true;
|
|
409
|
+
} else if (existing.importPath !== entry.importPath) console.warn(`[ubean:islands] Component "${entry.name}" is imported from different paths:\n existing: "${existing.importPath}" (in ${existing.sourceFile})\n new: "${entry.importPath}" (in ${sourceFile})\nUsing the first-seen path. To fix, ensure all imports use the same path.`);
|
|
410
|
+
}
|
|
411
|
+
entriesByFile.set(sourceFile, newNames);
|
|
412
|
+
return changed;
|
|
413
|
+
}
|
|
193
414
|
return {
|
|
194
415
|
name: "ubean:islands",
|
|
195
416
|
enforce: "pre",
|
|
@@ -197,13 +418,37 @@ function ubeanIslandsPlugin(_options = {}) {
|
|
|
197
418
|
viteConfig = config;
|
|
198
419
|
enabled = _options.enabled !== false;
|
|
199
420
|
},
|
|
421
|
+
configureServer(server) {
|
|
422
|
+
devServer = server;
|
|
423
|
+
},
|
|
424
|
+
resolveId(id) {
|
|
425
|
+
if (id === "virtual:ubean-islands-registry") return ISLANDS_REGISTRY_RESOLVED_ID;
|
|
426
|
+
},
|
|
427
|
+
load(id) {
|
|
428
|
+
if (id !== ISLANDS_REGISTRY_RESOLVED_ID) return void 0;
|
|
429
|
+
return generateRegistryModule(islandComponents);
|
|
430
|
+
},
|
|
200
431
|
transform(code, id) {
|
|
201
432
|
if (!enabled) return null;
|
|
202
433
|
if (!isVueSfc(id)) return null;
|
|
203
434
|
if (!DIRECTIVE_RE.test(code)) return null;
|
|
204
|
-
|
|
435
|
+
const absolutePath = id.split("?")[0];
|
|
436
|
+
const result = transformVueSfcIslands(code, absolutePath.replace(viteConfig.root, "").replace(/^[/\\]/, ""));
|
|
437
|
+
if (isMainVueSfc(id)) {
|
|
438
|
+
const registryChanged = updateRegistry(collectIslandComponents(code, absolutePath), absolutePath);
|
|
439
|
+
const isHmrUpdate = transformedFiles.has(absolutePath);
|
|
440
|
+
transformedFiles.add(absolutePath);
|
|
441
|
+
if (registryChanged && devServer && isHmrUpdate) {
|
|
442
|
+
const mod = devServer.moduleGraph.getModuleById(ISLANDS_REGISTRY_RESOLVED_ID);
|
|
443
|
+
if (mod) {
|
|
444
|
+
devServer.moduleGraph.invalidateModule(mod);
|
|
445
|
+
devServer.ws.send({ type: "full-reload" });
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return result;
|
|
205
450
|
}
|
|
206
451
|
};
|
|
207
452
|
}
|
|
208
453
|
//#endregion
|
|
209
|
-
export { transformVueSfcIslands, ubeanIslandsPlugin };
|
|
454
|
+
export { ISLANDS_REGISTRY_VIRTUAL_ID, collectIslandComponents, generateRegistryModule, parseScriptImports, resolveIslandImportPath, scanIslandDirectiveNames, transformVueSfcIslands, ubeanIslandsPlugin };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ubean/islands",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"description": "Islands architecture for ubean (Vite plugin for client:load/idle/visible/media/only directives, bootstrap script, client hydrate runtime)",
|
|
5
5
|
"files": [
|
|
6
6
|
"dist"
|
|
@@ -21,6 +21,10 @@
|
|
|
21
21
|
"./runtime": {
|
|
22
22
|
"types": "./dist/runtime.d.ts",
|
|
23
23
|
"import": "./dist/runtime.js"
|
|
24
|
+
},
|
|
25
|
+
"./virtual-registry": {
|
|
26
|
+
"types": "./dist/virtual-registry.d.ts",
|
|
27
|
+
"import": null
|
|
24
28
|
}
|
|
25
29
|
},
|
|
26
30
|
"dependencies": {
|
|
@@ -41,7 +45,7 @@
|
|
|
41
45
|
}
|
|
42
46
|
},
|
|
43
47
|
"scripts": {
|
|
44
|
-
"build": "vp pack",
|
|
48
|
+
"build": "vp pack && cp src/virtual-registry.d.ts dist/virtual-registry.d.ts",
|
|
45
49
|
"dev": "vp pack --watch",
|
|
46
50
|
"test": "vp test",
|
|
47
51
|
"typecheck": "tsc --noEmit --skipLibCheck"
|